Browse Source

Redesign IoT Hub browse page to match design

- Two-column layout: left sidebar filters (280px, own scroll) + right card grid
- Filters use mat-expansion-panel accordions with checkbox lists
- Type, Category, Use Case filter sections with expand/collapse
- Top bar: results count + search field, sort button with mat-menu
- Active filter chips row with clear all button
- 4-column card grid (responsive: 3 at 1200px, 2 at 900px)
- Card subtype label: @Input showSubtype, 12px Medium uppercase above name
- Tabs use plural item type translations
- Remove old header, filter dropdowns, and footer sections
pull/15508/head
Igor Kulikov 6 months ago
parent
commit
42b343f033
  1. 332
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html
  2. 412
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss
  3. 12
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts
  4. 3
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html
  5. 10
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss
  6. 1
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts

332
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html

@ -15,214 +15,210 @@
limitations under the License. limitations under the License.
--> -->
<div class="tb-iot-hub-browse-container" [class.embedded]="embedded"> <div class="tb-iot-hub-browse" [class.embedded]="embedded">
<!-- Page header -->
@if (!embedded) {
<div class="tb-iot-hub-browse-header">
<div>
<h1>{{ getTitle() | translate }}</h1>
<p>{{ getSubtitle() | translate }}</p>
</div>
<button mat-stroked-button color="primary" (click)="navigateToInstalledItems()">
<mat-icon>inventory_2</mat-icon>
{{ 'iot-hub.installed-items' | translate }}
</button>
</div>
}
<!-- Type tabs --> <!-- Type tabs -->
<nav mat-tab-nav-bar class="tb-iot-hub-type-tabs" [tabPanel]="tabPanel"> <nav mat-tab-nav-bar class="tb-iot-hub-tabs" [tabPanel]="tabPanel">
<a mat-tab-link <a mat-tab-link
[active]="activeType === ItemType.WIDGET" [active]="activeType === ItemType.WIDGET"
(click)="onTypeTabChange(ItemType.WIDGET)"> (click)="onTypeTabChange(ItemType.WIDGET)">
{{ 'item.type-widget' | translate }}s {{ 'item.type-widget-plural' | translate }}
</a> </a>
<a mat-tab-link <a mat-tab-link
[active]="activeType === ItemType.DASHBOARD" [active]="activeType === ItemType.DASHBOARD"
(click)="onTypeTabChange(ItemType.DASHBOARD)"> (click)="onTypeTabChange(ItemType.DASHBOARD)">
{{ 'item.type-dashboard' | translate }}s {{ 'item.type-dashboard-plural' | translate }}
</a> </a>
<a mat-tab-link <a mat-tab-link
[active]="activeType === ItemType.SOLUTION_TEMPLATE" [active]="activeType === ItemType.SOLUTION_TEMPLATE"
(click)="onTypeTabChange(ItemType.SOLUTION_TEMPLATE)"> (click)="onTypeTabChange(ItemType.SOLUTION_TEMPLATE)">
{{ 'item.type-solution-template' | translate }}s {{ 'item.type-solution-template-plural' | translate }}
</a> </a>
<a mat-tab-link <a mat-tab-link
[active]="activeType === ItemType.CALCULATED_FIELD" [active]="activeType === ItemType.CALCULATED_FIELD"
(click)="onTypeTabChange(ItemType.CALCULATED_FIELD)"> (click)="onTypeTabChange(ItemType.CALCULATED_FIELD)">
{{ 'item.type-calculated-field' | translate }}s {{ 'item.type-calculated-field-plural' | translate }}
</a> </a>
<a mat-tab-link <a mat-tab-link
[active]="activeType === ItemType.RULE_CHAIN" [active]="activeType === ItemType.RULE_CHAIN"
(click)="onTypeTabChange(ItemType.RULE_CHAIN)"> (click)="onTypeTabChange(ItemType.RULE_CHAIN)">
{{ 'item.type-rule-chain' | translate }}s {{ 'item.type-rule-chain-plural' | translate }}
</a> </a>
<a mat-tab-link <a mat-tab-link
[active]="activeType === ItemType.DEVICE" [active]="activeType === ItemType.DEVICE"
(click)="onTypeTabChange(ItemType.DEVICE)"> (click)="onTypeTabChange(ItemType.DEVICE)">
{{ 'item.type-device' | translate }}s {{ 'item.type-device-plural' | translate }}
</a> </a>
</nav> </nav>
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel> <mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
<!-- Search + sort row --> <!-- Content: filters sidebar + card grid -->
<div class="tb-iot-hub-search-row"> <div class="tb-iot-hub-content">
<mat-form-field appearance="outline" class="tb-iot-hub-search-field"> <!-- Left sidebar filters -->
<mat-icon matPrefix>search</mat-icon> <div class="tb-iot-hub-filters">
<input matInput <div class="tb-iot-hub-filters-inner">
[placeholder]="getSearchPlaceholder() | translate" @if (getSubtypeMap(); as subtypeMap) {
[value]="textSearch" <mat-expansion-panel [expanded]="true" class="tb-iot-hub-filter-panel">
(input)="onSearchChange($any($event.target).value)"> <mat-expansion-panel-header>
@if (textSearch) { <mat-panel-title>{{ 'iot-hub.type-filter-label' | translate }}</mat-panel-title>
<button matSuffix mat-icon-button (click)="onSearchChange('')"> </mat-expansion-panel-header>
<mat-icon>close</mat-icon> @for (entry of subtypeMap | keyvalue; track entry.key) {
</button> <mat-checkbox [checked]="isSubtypeActive(entry.key)"
} (change)="onSubtypeToggle(entry.key)">
</mat-form-field> {{ entry.value | translate }}
<mat-form-field appearance="outline" class="tb-iot-hub-sort-field"> </mat-checkbox>
<mat-select [value]="selectedSortIndex" (selectionChange)="onSortChange($event.value)"> }
@for (option of sortOptions; track option.value; let i = $index) { </mat-expansion-panel>
<mat-option [value]="i">{{ option.label | translate }}</mat-option>
} }
</mat-select>
</mat-form-field>
</div>
<!-- Filter dropdowns row --> @if (categories.size > 0) {
<div class="tb-iot-hub-filter-row"> <mat-expansion-panel [expanded]="true" class="tb-iot-hub-filter-panel">
@if (getSubtypeMap(); as subtypeMap) { <mat-expansion-panel-header>
<mat-form-field appearance="outline" class="tb-iot-hub-filter-select"> <mat-panel-title>{{ 'iot-hub.category' | translate }}</mat-panel-title>
<mat-select [value]="getActiveSubtypesArray()" multiple </mat-expansion-panel-header>
[placeholder]="'iot-hub.type-filter-label' | translate" @for (entry of categories | keyvalue; track entry.key) {
(selectionChange)="onSubtypesChange($event.value)"> <mat-checkbox [checked]="isCategoryActive(entry.key)"
@for (entry of subtypeMap | keyvalue; track entry.key) { (change)="onCategoryToggle(entry.key)">
<mat-option [value]="entry.key">{{ entry.value | translate }}</mat-option> {{ entry.value | translate }}
} </mat-checkbox>
</mat-select> }
</mat-form-field> </mat-expansion-panel>
}
@if (categories.size > 0) {
<mat-form-field appearance="outline" class="tb-iot-hub-filter-select">
<mat-select [value]="getActiveCategoriesArray()" multiple
[placeholder]="'iot-hub.category' | translate"
(selectionChange)="onCategoriesChange($event.value)">
@for (entry of categories | keyvalue; track entry.key) {
<mat-option [value]="entry.key">{{ entry.value | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="tb-iot-hub-filter-select">
<mat-select [value]="getActiveUseCasesArray()" multiple
[placeholder]="'iot-hub.use-case' | translate"
(selectionChange)="onUseCasesChange($event.value)">
@for (entry of useCases | keyvalue; track entry.key) {
<mat-option [value]="entry.key">{{ entry.value | translate }}</mat-option>
} }
</mat-select>
</mat-form-field>
</div>
<!-- Active filter chips -->
@if (hasActiveDropdownFilters()) {
<div class="tb-iot-hub-active-filters">
@for (key of getActiveSubtypesArray(); track key) {
<span class="tb-iot-hub-active-chip" (click)="onSubtypeToggle(key)">
{{ getSubtypeLabel(key) }}
<mat-icon>close</mat-icon>
</span>
}
@for (key of getActiveCategoriesArray(); track key) {
<span class="tb-iot-hub-active-chip" (click)="onCategoryToggle(key)">
{{ getCategoryLabel(key) }}
<mat-icon>close</mat-icon>
</span>
}
@for (key of getActiveUseCasesArray(); track key) {
<span class="tb-iot-hub-active-chip" (click)="onUseCaseToggle(key)">
{{ getUseCaseLabel(key) }}
<mat-icon>close</mat-icon>
</span>
}
<button class="tb-iot-hub-clear-filters" (click)="clearAllFilters()">
{{ 'iot-hub.clear-all-filters' | translate }}
</button>
</div>
}
<!-- Results count -->
@if (!isLoading && !hasError) {
<div class="tb-iot-hub-results-count">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</div>
}
<!-- Loading --> <mat-expansion-panel [expanded]="false" class="tb-iot-hub-filter-panel">
@if (isLoading) { <mat-expansion-panel-header>
<div class="tb-iot-hub-loading"> <mat-panel-title>{{ 'iot-hub.use-case' | translate }}</mat-panel-title>
<mat-spinner diameter="40"></mat-spinner> </mat-expansion-panel-header>
@for (entry of useCases | keyvalue; track entry.key) {
<mat-checkbox [checked]="isUseCaseActive(entry.key)"
(change)="onUseCaseToggle(entry.key)">
{{ entry.value | translate }}
</mat-checkbox>
}
</mat-expansion-panel>
</div>
</div> </div>
}
<!-- Error --> <!-- Right content area -->
@if (hasError && !isLoading) { <div class="tb-iot-hub-results">
<div class="tb-iot-hub-empty-state"> <!-- Top bar: results count + search + sort -->
<mat-icon class="tb-iot-hub-empty-icon">error_outline</mat-icon> <div class="tb-iot-hub-results-top">
<h3>{{ 'iot-hub.unable-to-load' | translate }}</h3> <span class="tb-iot-hub-results-count">
<p>{{ 'iot-hub.unable-to-load-text' | translate }}</p> {{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
<button mat-raised-button color="primary" (click)="loadItems()"> </span>
{{ 'iot-hub.retry' | translate }} <mat-form-field appearance="outline" class="tb-iot-hub-search-field">
</button> <mat-icon matPrefix>search</mat-icon>
</div> <input matInput
} [placeholder]="'iot-hub.search-placeholder' | translate"
[value]="textSearch"
(input)="onSearchChange($any($event.target).value)">
@if (textSearch) {
<button matSuffix mat-icon-button (click)="onSearchChange('')">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
</div>
<!-- Empty state --> <!-- Sort button -->
@if (!isLoading && !hasError && items.length === 0) { <div class="tb-iot-hub-sort-row">
<div class="tb-iot-hub-empty-state"> <!-- Active filter chips -->
<mat-icon class="tb-iot-hub-empty-icon">search_off</mat-icon> @if (hasActiveDropdownFilters()) {
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3> <div class="tb-iot-hub-active-filters">
<p>{{ 'iot-hub.no-items-found-text' | translate }}</p> @for (key of getActiveSubtypesArray(); track key) {
@if (hasActiveFilters()) { <span class="tb-iot-hub-active-chip" (click)="onSubtypeToggle(key)">
<button mat-raised-button color="primary" (click)="clearAllFilters()"> {{ getSubtypeLabel(key) }}
{{ 'iot-hub.clear-all-filters' | translate }} <mat-icon>close</mat-icon>
</span>
}
@for (key of getActiveCategoriesArray(); track key) {
<span class="tb-iot-hub-active-chip" (click)="onCategoryToggle(key)">
{{ getCategoryLabel(key) }}
<mat-icon>close</mat-icon>
</span>
}
@for (key of getActiveUseCasesArray(); track key) {
<span class="tb-iot-hub-active-chip" (click)="onUseCaseToggle(key)">
{{ getUseCaseLabel(key) }}
<mat-icon>close</mat-icon>
</span>
}
<button class="tb-iot-hub-clear-btn" (click)="clearAllFilters()">
{{ 'iot-hub.clear-all-filters' | translate }}
</button>
</div>
}
<span class="tb-iot-hub-sort-spacer"></span>
<button mat-button class="tb-iot-hub-sort-btn" [matMenuTriggerFor]="sortMenu">
<mat-icon>filter_list</mat-icon>
{{ sortOptions[selectedSortIndex].label | translate }}
</button> </button>
<mat-menu #sortMenu="matMenu">
@for (option of sortOptions; track option.value; let i = $index) {
<button mat-menu-item (click)="onSortChange(i)">{{ option.label | translate }}</button>
}
</mat-menu>
</div>
<!-- Loading -->
@if (isLoading) {
<div class="tb-iot-hub-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
} }
</div>
}
<!-- Card grid --> <!-- Error -->
@if (!isLoading && !hasError && items.length > 0) { @if (hasError && !isLoading) {
<div class="tb-iot-hub-card-grid"> <div class="tb-iot-hub-empty-state">
@for (item of items; track item.id) { <mat-icon class="tb-iot-hub-empty-icon">error_outline</mat-icon>
<tb-iot-hub-item-card <h3>{{ 'iot-hub.unable-to-load' | translate }}</h3>
[item]="item" <p>{{ 'iot-hub.unable-to-load-text' | translate }}</p>
[installedItem]="getInstalledItem(item)" <button mat-flat-button color="primary" (click)="loadItems()">
[showTypeChip]="false" {{ 'iot-hub.retry' | translate }}
[showCreator]="!creatorId" </button>
(cardClick)="openItemDetail($event)" </div>
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"
(updateClick)="updateItem($event)">
</tb-iot-hub-item-card>
} }
</div>
<!-- Pagination --> <!-- Empty state -->
<mat-paginator @if (!isLoading && !hasError && items.length === 0) {
[length]="totalElements" <div class="tb-iot-hub-empty-state">
[pageSize]="pageSize" <mat-icon class="tb-iot-hub-empty-icon">search_off</mat-icon>
[pageIndex]="pageIndex" <h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
[pageSizeOptions]="[12, 24, 48]" <p>{{ 'iot-hub.no-items-found-text' | translate }}</p>
(page)="onPageChange($event)"> @if (hasActiveFilters()) {
</mat-paginator> <button mat-flat-button color="primary" (click)="clearAllFilters()">
} {{ 'iot-hub.clear-all-filters' | translate }}
</button>
}
</div>
}
<!-- Footer --> <!-- Card grid -->
@if (!creatorId) { @if (!isLoading && !hasError && items.length > 0) {
<div class="tb-iot-hub-browse-footer"> <div class="tb-iot-hub-card-grid">
<a class="tb-iot-hub-become-creator" href="https://iothub.thingsboard.io/signup" target="_blank"> @for (item of items; track item.id) {
{{ 'iot-hub.become-a-creator' | translate }} <tb-iot-hub-item-card
</a> [item]="item"
[installedItem]="getInstalledItem(item)"
[showTypeChip]="false"
[showSubtype]="true"
[showCreator]="!creatorId"
(cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"
(updateClick)="updateItem($event)">
</tb-iot-hub-item-card>
}
</div>
<!-- Pagination -->
<mat-paginator
[length]="totalElements"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="[12, 24, 48]"
(page)="onPageChange($event)">
</mat-paginator>
}
</div> </div>
} </div>
</div> </div>

412
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss

@ -18,249 +18,147 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
padding: 16px 24px; }
overflow: auto;
&.embedded { .tb-iot-hub-browse {
padding: 0; display: flex;
overflow: visible; flex-direction: column;
height: auto; height: 100%;
}
} }
.tb-iot-hub-browse-container { // Tabs — Design: px-40, border-bottom
max-width: 1400px; .tb-iot-hub-tabs {
margin: 0 auto; padding: 0 40px;
width: 100%; flex-shrink: 0;
background: white;
padding: 24px;
border-radius: 4px;
box-shadow: 0 0 1px rgba(0, 0, 0, 0.14), 0 1px 1px rgba(0, 0, 0, 0.12);
&.embedded {
max-width: none;
padding: 0;
border-radius: 0;
box-shadow: none;
background: transparent;
}
} }
.tb-iot-hub-browse-header { // Two-column layout below tabs
.tb-iot-hub-content {
display: flex; display: flex;
justify-content: space-between; flex: 1;
align-items: flex-start; min-height: 0;
margin-bottom: 24px;
h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 4px;
color: rgba(0, 0, 0, 0.87);
}
p {
font-size: 14px;
color: rgba(0, 0, 0, 0.54);
margin: 0;
}
} }
.tb-iot-hub-type-tabs { // Left sidebar filters — Design: 280px wide, right border, own scroll
margin-bottom: 16px; .tb-iot-hub-filters {
border-bottom: 1px solid rgba(0, 0, 0, 0.12); width: 280px;
flex-shrink: 0;
::ng-deep { border-right: 1px solid rgba(0, 0, 0, 0.12);
.mat-mdc-tab-link-container { overflow-y: auto;
flex-grow: 0; }
}
.mat-mdc-tab-link {
text-transform: none;
font-weight: 400;
min-width: auto;
padding: 0 16px;
letter-spacing: normal;
height: 40px;
flex: 0 0 auto;
}
--mat-tab-active-indicator-color: #1976d2;
.mdc-tab__text-label {
color: rgba(0, 0, 0, 0.6);
}
.mdc-tab--active .mdc-tab__text-label {
font-weight: 500;
}
--mat-tab-active-label-text-color: #1976d2; .tb-iot-hub-filters-inner {
--mat-tab-active-focus-label-text-color: #1976d2; display: flex;
--mat-tab-active-hover-label-text-color: #1976d2; flex-direction: column;
--mat-tab-active-ripple-color: #1976d2; gap: 8px;
--mat-tab-inactive-ripple-color: #1976d2; padding: 24px 0;
--mat-tab-active-focus-indicator-color: #1976d2;
}
} }
// Shared field styles for search/filter/sort dropdowns // Filter expansion panels — Design: no border, no shadow, pl-40 for content
%tb-iot-hub-field-base { .tb-iot-hub-filter-panel {
::ng-deep { ::ng-deep {
.mat-mdc-text-field-wrapper { &.mat-expansion-panel {
height: 42px; box-shadow: none !important;
padding: 0 12px; border-radius: 0 !important;
background: transparent;
} }
.mat-mdc-form-field-flex { .mat-expansion-panel-header {
height: 42px; padding: 8px 16px 8px 40px;
align-items: center;
} }
.mdc-notched-outline__leading, .mat-expansion-panel-header-title {
.mdc-notched-outline__notch,
.mdc-notched-outline__trailing {
border-color: #d5dbe3;
}
.mdc-notched-outline__leading {
border-radius: 8px 0 0 8px;
}
.mdc-notched-outline__trailing {
border-radius: 0 8px 8px 0;
}
.mat-mdc-form-field-infix {
padding-top: 0;
padding-bottom: 0;
min-height: unset;
}
.mdc-notched-outline__notch {
display: none;
}
.mat-mdc-floating-label {
display: none;
}
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
input.mat-mdc-input-element {
font-size: 14px; font-size: 14px;
color: #3e4a59; font-weight: 500;
color: rgba(0, 0, 0, 0.87);
&::placeholder {
color: #8b95a2;
opacity: 1;
}
} }
.mat-mdc-select-value-text { .mat-expansion-panel-body {
font-size: 14px; padding: 0;
color: #3e4a59;
} }
}
.mat-mdc-select-arrow-wrapper { mat-checkbox {
padding-left: 4px; display: block;
} padding: 8px 16px 8px 40px;
.mat-icon { &:hover {
color: #8b95a2; background: rgba(0, 0, 0, 0.04);
font-size: 20px;
width: 20px;
height: 20px;
} }
} }
}
&.mat-focused ::ng-deep { // Right content area — Design: flex-1, pr-40, scrollable
.mdc-notched-outline__leading, .tb-iot-hub-results {
.mdc-notched-outline__notch, flex: 1;
.mdc-notched-outline__trailing { min-width: 0;
border-color: #8b95a2; display: flex;
border-width: 1px; flex-direction: column;
} overflow-y: auto;
} padding: 0 40px 0 20px;
} }
.tb-iot-hub-search-row { // Top bar: results count + search
.tb-iot-hub-results-top {
display: flex; display: flex;
gap: 12px;
margin-bottom: 12px;
align-items: center; align-items: center;
justify-content: space-between;
padding: 16px 0 0;
flex-shrink: 0;
}
.tb-iot-hub-search-field, .tb-iot-hub-results-count {
.tb-iot-hub-sort-field { font-size: 14px;
@extend %tb-iot-hub-field-base; color: rgba(0, 0, 0, 0.54);
} }
.tb-iot-hub-search-field {
flex: 1;
::ng-deep {
.mat-mdc-form-field-icon-prefix {
padding: 0 8px 0 0;
> .mat-icon {
padding: 0;
}
}
.mat-mdc-form-field-icon-suffix {
padding: 0;
.mat-mdc-icon-button { .tb-iot-hub-search-field {
width: 32px; width: 240px;
height: 32px;
padding: 4px;
}
}
}
}
.tb-iot-hub-sort-field { .mat-mdc-form-field-subscript-wrapper {
width: 140px; display: none;
flex-shrink: 0;
} }
} }
.tb-iot-hub-filter-row { // Sort row + active filters
.tb-iot-hub-sort-row {
display: flex; display: flex;
gap: 12px; align-items: center;
margin-bottom: 16px; gap: 8px;
padding-right: 152px; padding: 8px 0;
flex-shrink: 0;
.tb-iot-hub-filter-select { flex-wrap: wrap;
@extend %tb-iot-hub-field-base; }
flex: 1;
min-width: 0; .tb-iot-hub-sort-spacer {
} flex: 1;
} }
.tb-iot-hub-sort-btn {
color: rgba(0, 0, 0, 0.54);
flex-shrink: 0;
}
// Active filter chips
.tb-iot-hub-active-filters { .tb-iot-hub-active-filters {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 12px;
} }
.tb-iot-hub-active-chip { .tb-iot-hub-active-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 4px 8px 4px 12px; padding: 4px 8px;
border-radius: 16px; border-radius: 4px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
background: #eef4ff; background: rgba(0, 0, 0, 0.06);
color: #305ECA; color: rgba(0, 0, 0, 0.76);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: background-color 0.15s;
mat-icon { mat-icon {
font-size: 14px; font-size: 14px;
@ -269,135 +167,85 @@
} }
&:hover { &:hover {
background: #dde8fc; background: rgba(0, 0, 0, 0.1);
} }
} }
.tb-iot-hub-clear-filters { .tb-iot-hub-clear-btn {
border: none;
background: none;
font-size: 12px; font-size: 12px;
color: #8b95a2; color: rgba(0, 0, 0, 0.54);
cursor: pointer; cursor: pointer;
background: none;
border: none;
padding: 4px 8px; padding: 4px 8px;
white-space: nowrap; font-family: inherit;
&:hover { &:hover {
color: #3e4a59; color: rgba(0, 0, 0, 0.87);
text-decoration: underline;
} }
} }
.tb-iot-hub-results-count { // Card grid — Design: 4 columns, gap 20px
font-size: 14px; .tb-iot-hub-card-grid {
color: rgba(0, 0, 0, 0.54); display: grid;
margin-bottom: 16px; grid-template-columns: repeat(4, 1fr);
gap: 20px;
padding: 8px 0 24px;
} }
// Loading / Empty states
.tb-iot-hub-loading { .tb-iot-hub-loading {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; padding: 80px 0;
min-height: 200px;
} }
.tb-iot-hub-empty-state { .tb-iot-hub-empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; padding: 80px 0;
min-height: 300px;
text-align: center; text-align: center;
color: rgba(0, 0, 0, 0.54);
.tb-iot-hub-empty-icon {
font-size: 64px;
width: 64px;
height: 64px;
margin-bottom: 16px;
opacity: 0.4;
}
h3 { h3 {
margin: 0 0 8px 0; font-size: 18px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87); color: rgba(0, 0, 0, 0.87);
margin: 16px 0 8px;
} }
p { p {
margin: 0 0 16px 0; font-size: 14px;
color: rgba(0, 0, 0, 0.54);
margin: 0 0 24px;
} }
} }
.tb-iot-hub-card-grid { .tb-iot-hub-empty-icon {
display: grid; font-size: 48px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); width: 48px;
gap: 16px; height: 48px;
margin-bottom: 16px; color: rgba(0, 0, 0, 0.2);
} }
::ng-deep mat-paginator { // Pagination
.mat-mdc-form-field { mat-paginator {
.mat-mdc-text-field-wrapper { flex-shrink: 0;
height: 42px; }
padding: 0 12px;
border: 1px solid #d5dbe3;
border-radius: 8px;
background: white;
}
.mat-mdc-form-field-flex {
height: 42px;
align-items: center;
}
.mdc-notched-outline {
display: none;
}
.mat-mdc-form-field-infix {
padding-top: 0;
padding-bottom: 0;
min-height: unset;
border-top: none;
}
.mat-mdc-floating-label {
display: none;
}
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
.mat-mdc-select-value-text {
font-size: 14px;
color: #3e4a59;
}
.mat-mdc-select-arrow-wrapper {
padding-left: 4px;
}
&.mat-focused .mat-mdc-text-field-wrapper { // Responsive
border-color: #8b95a2; @media (max-width: 1200px) {
} .tb-iot-hub-card-grid {
grid-template-columns: repeat(3, 1fr);
} }
} }
.tb-iot-hub-browse-footer { @media (max-width: 900px) {
text-align: center; .tb-iot-hub-filters {
padding: 24px 0; width: 220px;
border-top: 1px solid rgba(0, 0, 0, 0.08); }
margin-top: 16px;
.tb-iot-hub-become-creator {
color: rgba(0, 0, 0, 0.38);
font-size: 13px;
text-decoration: none;
&:hover { .tb-iot-hub-card-grid {
color: rgba(0, 0, 0, 0.54); grid-template-columns: repeat(2, 1fr);
text-decoration: underline;
}
} }
} }

12
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts

@ -159,6 +159,18 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.loadItems(); this.loadItems();
} }
isSubtypeActive(key: string): boolean {
return this.getActiveSubtypes().has(key);
}
isCategoryActive(key: string): boolean {
return this.activeCategories.has(key);
}
isUseCaseActive(key: string): boolean {
return this.activeUseCases.has(key);
}
onCategoryToggle(category: string): void { onCategoryToggle(category: string): void {
if (this.activeCategories.has(category)) { if (this.activeCategories.has(category)) {
this.activeCategories.delete(category); this.activeCategories.delete(category);

3
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html

@ -54,6 +54,9 @@
} }
</div> </div>
<div class="tb-iot-hub-card-content"> <div class="tb-iot-hub-card-content">
@if (showSubtype && getSubtypeLabel(); as subtypeLabel) {
<span class="tb-iot-hub-card-subtype">{{ subtypeLabel }}</span>
}
<h3 class="tb-iot-hub-card-name">{{ item.name }}</h3> <h3 class="tb-iot-hub-card-name">{{ item.name }}</h3>
<div class="tb-iot-hub-card-footer"> <div class="tb-iot-hub-card-footer">
<tb-icon class="tb-iot-hub-card-author-icon">person</tb-icon> <tb-icon class="tb-iot-hub-card-author-icon">person</tb-icon>

10
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss

@ -205,6 +205,16 @@
text-transform: uppercase; text-transform: uppercase;
} }
// Subtype label — Design: 12px Medium uppercase, rgba(0,0,0,0.54), tracking 0.25
.tb-iot-hub-card-subtype {
font-size: 12px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.25px;
text-transform: uppercase;
color: rgba(0, 0, 0, 0.54);
}
// Name — Design: 16px Medium, line-height 24, tracking 0.25, ellipsis // Name — Design: 16px Medium, line-height 24, tracking 0.25, ellipsis
.tb-iot-hub-card-name { .tb-iot-hub-card-name {
font-size: 16px; font-size: 16px;

1
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts

@ -37,6 +37,7 @@ export class TbIotHubItemCardComponent {
@Input() installedItem: IotHubInstalledItem; @Input() installedItem: IotHubInstalledItem;
@Input() showCreator = true; @Input() showCreator = true;
@Input() showTypeChip = true; @Input() showTypeChip = true;
@Input() showSubtype = false;
@Output() cardClick = new EventEmitter<MpItemVersionView>(); @Output() cardClick = new EventEmitter<MpItemVersionView>();
@Output() creatorClick = new EventEmitter<string>(); @Output() creatorClick = new EventEmitter<string>();
@Output() installClick = new EventEmitter<MpItemVersionView>(); @Output() installClick = new EventEmitter<MpItemVersionView>();

Loading…
Cancel
Save