Browse Source

Add responsive adaptiveness for IoT Hub home, browse, and search pages

- Add MediaBreakpoints lt-xmd (≤1599), gt-xxl (≥2448) to constants
- Home: BreakpointObserver dynamically adjusts card counts per breakpoint
- Home: main-content container with 48px gap and responsive padding
- Home: hero icons positions/sizes adapt per breakpoint, hidden below ≤959px
- Home: subtitle line break after Solution Templates
- Home: installed button centered at ≤599px
- Browse: responsive card grid with compact type support (CF/RC/DEVICE)
- Search: responsive grid columns matching home page breakpoints
- Card component: min-width 0 for grid overflow prevention
- isCompactType includes DEVICE across all components
pull/15508/head
Igor Kulikov 6 months ago
parent
commit
5a1cbe6781
  1. 2
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html
  2. 42
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss
  3. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts
  4. 252
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html
  5. 178
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss
  6. 69
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts
  7. 1
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss
  8. 31
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.scss
  9. 2
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.ts
  10. 3
      ui-ngx/src/app/shared/models/constants.ts
  11. 2
      ui-ngx/src/scss/constants.scss

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

@ -202,7 +202,7 @@
<!-- Card grid --> <!-- Card grid -->
@if (!isLoading && !hasError && items.length > 0) { @if (!isLoading && !hasError && items.length > 0) {
<div class="tb-iot-hub-card-grid"> <div class="tb-iot-hub-card-grid" [class.tb-iot-hub-card-grid-compact]="isCompactType">
@for (item of items; track item.id) { @for (item of items; track item.id) {
<tb-iot-hub-item-card <tb-iot-hub-item-card
[item]="item" [item]="item"

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

@ -240,12 +240,16 @@
} }
} }
// Card grid — Design: 4 columns, gap 20px // Card grid — Design: 4 columns, gap 20px; compact: 3 columns
.tb-iot-hub-card-grid { .tb-iot-hub-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 20px; gap: 20px;
padding: 8px 0 24px; padding: 8px 0 24px;
&.tb-iot-hub-card-grid-compact {
grid-template-columns: repeat(3, 1fr);
}
} }
// Loading / Empty states // Loading / Empty states
@ -300,6 +304,7 @@
position: absolute; position: absolute;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
overflow: hidden;
.mat-mdc-icon-button { .mat-mdc-icon-button {
width: 40px; width: 40px;
@ -365,18 +370,43 @@
} }
// Responsive // Responsive
@media (max-width: 1200px) {
@media #{$mat-gt-xxl} {
.tb-iot-hub-card-grid { .tb-iot-hub-card-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(5, 1fr);
&.tb-iot-hub-card-grid-compact {
grid-template-columns: repeat(4, 1fr);
}
} }
} }
@media (max-width: 900px) { @media #{$mat-lt-xl} {
.tb-iot-hub-filters { .tb-iot-hub-card-grid {
width: 240px; grid-template-columns: repeat(3, 1fr);
&.tb-iot-hub-card-grid-compact {
grid-template-columns: repeat(2, 1fr);
}
} }
}
@media #{$mat-lt-xmd} {
.tb-iot-hub-card-grid { .tb-iot-hub-card-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
&.tb-iot-hub-card-grid-compact {
grid-template-columns: repeat(1, 1fr);
}
}
}
@media #{$mat-lt-lg} {
.tb-iot-hub-card-grid {
grid-template-columns: repeat(1, 1fr);
}
.tb-iot-hub-filters {
width: 240px;
} }
} }

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

@ -75,6 +75,10 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
return this._activeType; return this._activeType;
} }
get isCompactType(): boolean {
return this._activeType === ItemType.CALCULATED_FIELD || this._activeType === ItemType.RULE_CHAIN || this._activeType === ItemType.DEVICE;
}
items: MpItemVersionView[] = []; items: MpItemVersionView[] = [];
totalElements = 0; totalElements = 0;
pageSize = 12; pageSize = 12;

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

@ -49,12 +49,15 @@
<h1 class="tb-iot-hub-hero-title">{{ 'iot-hub.home-title' | translate }}</h1> <h1 class="tb-iot-hub-hero-title">{{ 'iot-hub.home-title' | translate }}</h1>
<div class="tb-iot-hub-hero-subtitle"> <div class="tb-iot-hub-hero-subtitle">
<span class="tb-iot-hub-hero-prefix">{{ 'iot-hub.home-subtitle-prefix' | translate }}</span> <span class="tb-iot-hub-hero-prefix">{{ 'iot-hub.home-subtitle-prefix' | translate }}</span>
@for (ht of heroTypes; track ht.type; let last = $last) { @for (ht of heroTypes; track ht.type; let i = $index; let last = $last) {
<span class="tb-iot-hub-hero-keyword" <span class="tb-iot-hub-hero-keyword"
[class.active]="activeHeroType === ht" [class.active]="activeHeroType === ht"
[style.--keyword-color]="ht.color" [style.--keyword-color]="ht.color"
(mouseenter)="onHeroTypeHover(ht)" (mouseenter)="onHeroTypeHover(ht)"
(mouseleave)="onHeroTypeLeave()">{{ ht.labelKey | translate }}@if (!last) {,}</span> (mouseleave)="onHeroTypeLeave()">{{ ht.labelKey | translate }}@if (!last) {,}</span>
@if (i === 2) {
<span class="tb-iot-hub-hero-subtitle-break"></span>
}
} }
</div> </div>
<mat-form-field appearance="outline" class="tb-iot-hub-hero-search" subscriptSizing="dynamic" #searchField> <mat-form-field appearance="outline" class="tb-iot-hub-hero-search" subscriptSizing="dynamic" #searchField>
@ -131,134 +134,145 @@
</div> </div>
</div> </div>
<!-- Category cards --> <!-- Main content -->
<div class="tb-iot-hub-categories"> <div class="tb-iot-hub-main-content">
@for (card of categoryCards; track card.type) { <!-- Category cards -->
<div class="tb-iot-hub-category-card" [ngClass]="card.cssClass" (click)="navigateToBrowse(card.type)"> <div class="tb-iot-hub-categories">
<span class="tb-iot-hub-category-title">{{ card.titleKey | translate }}</span> @for (card of categoryCards; track card.type) {
<img class="tb-iot-hub-category-img" [src]="card.image" alt=""> <div class="tb-iot-hub-category-card" [ngClass]="card.cssClass" (click)="navigateToBrowse(card.type)">
</div> <span class="tb-iot-hub-category-title">{{ card.titleKey | translate }}</span>
} <img class="tb-iot-hub-category-img" [src]="card.image" alt="">
</div> </div>
}
</div>
@if (!isLoading) { @if (!isLoading) {
<!-- Popular Widgets --> <!-- Popular Widgets -->
@if (popularWidgets.length) { @if (popularWidgets.length) {
<div class="tb-iot-hub-section"> <div class="tb-iot-hub-section">
<button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.WIDGET)"> <button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.WIDGET)">
{{ 'iot-hub.popular-widgets' | translate }} {{ 'iot-hub.popular-widgets' | translate }}
<mat-icon iconPositionEnd>chevron_right</mat-icon> <mat-icon iconPositionEnd>chevron_right</mat-icon>
</button> </button>
<div class="tb-iot-hub-big-cards-row"> <div class="tb-iot-hub-big-cards-row">
@for (item of popularWidgets; track item.id) { @for (item of popularWidgets; track item.id) {
<tb-iot-hub-item-card <tb-iot-hub-item-card
[item]="item" [item]="item"
[installedItem]="getInstalledWidget(item)" [installedItem]="getInstalledWidget(item)"
[showTypeChip]="false" [showTypeChip]="false"
[showCreator]="true" [showCreator]="true"
(cardClick)="openItemDetail($event)" (cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)" (creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)" (installClick)="installItem($event)"
(updateClick)="updateItem($event)" (updateClick)="updateItem($event)"
(deleteClick)="deleteInstalledItem($event)"> (deleteClick)="deleteInstalledItem($event)">
</tb-iot-hub-item-card> </tb-iot-hub-item-card>
} }
</div>
</div> </div>
</div> }
}
<!-- Popular Dashboards --> <!-- Popular Dashboards -->
@if (popularDashboards.length) { @if (popularDashboards.length) {
<div class="tb-iot-hub-section"> <div class="tb-iot-hub-section">
<button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.DASHBOARD)"> <button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.DASHBOARD)">
{{ 'iot-hub.popular-dashboards' | translate }} {{ 'iot-hub.popular-dashboards' | translate }}
<mat-icon iconPositionEnd>chevron_right</mat-icon> <mat-icon iconPositionEnd>chevron_right</mat-icon>
</button> </button>
<div class="tb-iot-hub-big-cards-row"> <div class="tb-iot-hub-big-cards-row">
@for (item of popularDashboards; track item.id) { @for (item of popularDashboards; track item.id) {
<tb-iot-hub-item-card <tb-iot-hub-item-card
[item]="item" [item]="item"
[showTypeChip]="false" [showTypeChip]="false"
[showCreator]="true" [showCreator]="true"
(cardClick)="openItemDetail($event)" (cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)" (creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"> (installClick)="installItem($event)">
</tb-iot-hub-item-card> </tb-iot-hub-item-card>
} }
</div>
</div> </div>
</div> }
}
<!-- Popular Solution Templates --> <!-- Popular Solution Templates -->
@if (popularSolutionTemplates.length) { @if (popularSolutionTemplates.length) {
<div class="tb-iot-hub-section"> <div class="tb-iot-hub-section">
<button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.SOLUTION_TEMPLATE)"> <button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.SOLUTION_TEMPLATE)">
{{ 'iot-hub.popular-solution-templates' | translate }} {{ 'iot-hub.popular-solution-templates' | translate }}
<mat-icon iconPositionEnd>chevron_right</mat-icon> <mat-icon iconPositionEnd>chevron_right</mat-icon>
</button> </button>
<div class="tb-iot-hub-big-cards-row"> <div class="tb-iot-hub-big-cards-row">
@for (item of popularSolutionTemplates; track item.id) { @for (item of popularSolutionTemplates; track item.id) {
<tb-iot-hub-item-card <tb-iot-hub-item-card
[item]="item" [item]="item"
[installedItem]="getInstalledSolutionTemplate(item)" [installedItem]="getInstalledSolutionTemplate(item)"
[showTypeChip]="false" [showTypeChip]="false"
[showCreator]="true" [showCreator]="true"
(cardClick)="openItemDetail($event)" (cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)" (creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)" (installClick)="installItem($event)"
(updateClick)="updateItem($event)" (updateClick)="updateItem($event)"
(deleteClick)="deleteInstalledItem($event)"> (deleteClick)="deleteInstalledItem($event)">
</tb-iot-hub-item-card> </tb-iot-hub-item-card>
} }
</div>
</div> </div>
</div> }
}
<!-- Popular Calculated Fields --> <!-- Popular Calculated Fields -->
@if (popularCalcFields.length) { @if (popularCalcFields.length) {
<div class="tb-iot-hub-section"> <div class="tb-iot-hub-section">
<button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.CALCULATED_FIELD)"> <button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.CALCULATED_FIELD)">
{{ 'iot-hub.popular-calculated-fields' | translate }} {{ 'iot-hub.popular-calculated-fields' | translate }}
<mat-icon iconPositionEnd>chevron_right</mat-icon> <mat-icon iconPositionEnd>chevron_right</mat-icon>
</button> </button>
<div class="tb-iot-hub-compact-cards-grid"> <div class="tb-iot-hub-compact-cards-grid">
@for (item of popularCalcFields; track item.id) { @for (item of popularCalcFields; track item.id) {
<tb-iot-hub-item-card <tb-iot-hub-item-card
[item]="item" [item]="item"
[showTypeChip]="false" [showTypeChip]="false"
[showCreator]="true" [showCreator]="true"
(cardClick)="openItemDetail($event)" (cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)" (creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"> (installClick)="installItem($event)">
</tb-iot-hub-item-card> </tb-iot-hub-item-card>
} }
</div>
</div> </div>
</div> }
}
<!-- Popular Rule Chains --> <!-- Popular Rule Chains -->
@if (popularRuleChains.length) { @if (popularRuleChains.length) {
<div class="tb-iot-hub-section"> <div class="tb-iot-hub-section">
<button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.RULE_CHAIN)"> <button mat-button class="tb-iot-hub-section-header" (click)="navigateToBrowse(ItemType.RULE_CHAIN)">
{{ 'iot-hub.popular-rule-chains' | translate }} {{ 'iot-hub.popular-rule-chains' | translate }}
<mat-icon iconPositionEnd>chevron_right</mat-icon> <mat-icon iconPositionEnd>chevron_right</mat-icon>
</button> </button>
<div class="tb-iot-hub-compact-cards-grid"> <div class="tb-iot-hub-compact-cards-grid">
@for (item of popularRuleChains; track item.id) { @for (item of popularRuleChains; track item.id) {
<tb-iot-hub-item-card <tb-iot-hub-item-card
[item]="item" [item]="item"
[showTypeChip]="false" [showTypeChip]="false"
[showCreator]="true" [showCreator]="true"
(cardClick)="openItemDetail($event)" (cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)" (creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"> (installClick)="installItem($event)">
</tb-iot-hub-item-card> </tb-iot-hub-item-card>
} }
</div>
</div> </div>
}
}
@if (isLoading) {
<div class="tb-iot-hub-loading">
<mat-spinner diameter="40"></mat-spinner>
</div> </div>
} }
</div>
<!-- Become a Creator --> <!-- Become a Creator -->
@if (!isLoading) {
<div class="tb-iot-hub-divider"></div> <div class="tb-iot-hub-divider"></div>
<div class="tb-iot-hub-become-creator"> <div class="tb-iot-hub-become-creator">
<h2>{{ 'iot-hub.become-a-creator' | translate }}</h2> <h2>{{ 'iot-hub.become-a-creator' | translate }}</h2>
@ -268,10 +282,4 @@
</button> </button>
</div> </div>
} }
@if (isLoading) {
<div class="tb-iot-hub-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
</div> </div>

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

@ -102,6 +102,7 @@
} }
// Individual floating icon — SVG has rotation/bg/shadow baked in, CSS handles position + animation // Individual floating icon — SVG has rotation/bg/shadow baked in, CSS handles position + animation
// Base: ≥1600px — 78px top icons (top: 40px), 87px bottom icons (top: 216px, bottom edge: 303px)
.tb-iot-hub-hero-float-icon { .tb-iot-hub-hero-float-icon {
position: absolute; position: absolute;
width: 78px; width: 78px;
@ -116,35 +117,37 @@
// Top-left // Top-left
&.icon-pos-1 { &.icon-pos-1 {
top: 40px; top: 40px;
left: 23%; left: calc(50% - 404px);
--icon-rotation: ;
--start-x: 40px; --start-x: 40px;
--start-y: 12px; --start-y: 12px;
} }
// Top-right // Top-right — mirrors icon-pos-1
&.icon-pos-2 { &.icon-pos-2 {
top: 40px; top: 40px;
right: 23%; left: auto;
--icon-rotation: ; right: calc(50% - 404px);
--start-x: -40px; --start-x: -40px;
--start-y: 12px; --start-y: 12px;
} }
// Bottom-left // Bottom-left — 87px bounding box
&.icon-pos-3 { &.icon-pos-3 {
bottom: 100px; top: 216px;
left: 18%; left: calc(50% - 487px);
--icon-rotation: ; width: 87px;
height: 87px;
--start-x: 50px; --start-x: 50px;
--start-y: -8px; --start-y: -8px;
} }
// Bottom-right // Bottom-right — 87px bounding box, mirrors icon-pos-3
&.icon-pos-4 { &.icon-pos-4 {
bottom: 100px; top: 216px;
right: 18%; left: auto;
--icon-rotation: ; right: calc(50% - 487px);
width: 87px;
height: 87px;
--start-x: -45px; --start-x: -45px;
--start-y: -4px; --start-y: -4px;
} }
@ -178,6 +181,11 @@
gap: 6px; gap: 6px;
} }
.tb-iot-hub-hero-subtitle-break {
flex-basis: 100%;
height: 0;
}
.tb-iot-hub-hero-prefix { .tb-iot-hub-hero-prefix {
font-weight: 400; font-weight: 400;
color: rgba(0, 0, 0, 0.76); color: rgba(0, 0, 0, 0.76);
@ -425,19 +433,26 @@
} }
} }
// Category cards — Design: centered 1200px container, flex-col gap=20, pb=48, 3-col rows // Main content — wraps categories + item sections
.tb-iot-hub-main-content {
display: flex;
flex-direction: column;
gap: 48px;
padding: 0 40px 64px;
}
// Category cards — Design: centered 1200px container, 3-col rows
.tb-iot-hub-categories { .tb-iot-hub-categories {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 20px; gap: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding-bottom: 48px; width: 100%;
} }
// Card — Design: flex-col, gap=24, pt=24, rounded-8, border, overflow-clip, h=220 // Card — Design: flex-col, gap=24, pt=24, rounded-8, border, overflow-clip, h=220
.tb-iot-hub-category-card { .tb-iot-hub-category-card {
height: 220px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@ -470,8 +485,7 @@
// Card image — Design: h=148, w=full, overflow-clip, fills remaining space // Card image — Design: h=148, w=full, overflow-clip, fills remaining space
.tb-iot-hub-category-img { .tb-iot-hub-category-img {
width: 100%; width: 100%;
height: 148px; object-fit: contain;
object-fit: cover;
flex-shrink: 0; flex-shrink: 0;
} }
@ -500,10 +514,8 @@
background: linear-gradient(104.75deg, rgb(245, 255, 252) 0%, rgb(147, 240, 213) 100%); background: linear-gradient(104.75deg, rgb(245, 255, 252) 0%, rgb(147, 240, 213) 100%);
} }
// Sections — matches Design "items-section" (px=40, gap between sections ~48px) // Sections — inside main-content, no individual padding needed
.tb-iot-hub-section { .tb-iot-hub-section {
padding: 0 40px;
margin-top: 48px;
} }
// Section header — mat-button, Design: height=40, font-size=20, font-weight=500 // Section header — mat-button, Design: height=40, font-size=20, font-weight=500
@ -541,7 +553,6 @@
.tb-iot-hub-divider { .tb-iot-hub-divider {
height: 1px; height: 1px;
background: rgba(0, 0, 0, 0.12); background: rgba(0, 0, 0, 0.12);
margin: 48px 0 0;
} }
// Become a Creator — matches Design (pt=64, text centered, button at y=156) // Become a Creator — matches Design (pt=64, text centered, button at y=156)
@ -606,23 +617,41 @@
animation: iot-hub-fade-slide-up 1s cubic-bezier(0.19, 1, 0.22, 1) 0.2s both; animation: iot-hub-fade-slide-up 1s cubic-bezier(0.19, 1, 0.22, 1) 0.2s both;
} }
// Responsive — breakpoints from design: LG=1280, MD=960, SM=600, XS=375 // Responsive — breakpoints from design: XXXL=2448, XXL=1920, XL=1600, LG=1280, MD=960, SM=600, XS=375
// LG: 1280px content area (sidebar present) // ≥2448px: 6-col big cards, 4-col compact
@media (max-width: 1600px) { @media #{$mat-gt-xxl} {
.tb-iot-hub-big-cards-row { .tb-iot-hub-big-cards-row {
grid-template-columns: repeat(6, 1fr);
}
.tb-iot-hub-compact-cards-grid {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
} }
}
// ≤1919px: 4-col big cards, 3-col compact
@media #{$mat-lt-xl} {
.tb-iot-hub-big-cards-row {
grid-template-columns: repeat(4, 1fr);
}
}
// ≤1599px: 3-col big cards, 2-col compact
@media #{$mat-lt-xmd} {
.tb-iot-hub-big-cards-row {
grid-template-columns: repeat(3, 1fr);
}
.tb-iot-hub-compact-cards-grid { .tb-iot-hub-compact-cards-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
} }
// MD: 960px viewport — sidebar present, narrower content // ≤1279px: 2-col big cards, 1-col compact, 2-col categories, smaller icons
@media (max-width: 1280px) { @media #{$mat-lt-lg} {
.tb-iot-hub-big-cards-row { .tb-iot-hub-big-cards-row {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.tb-iot-hub-compact-cards-grid { .tb-iot-hub-compact-cards-grid {
@ -631,14 +660,10 @@
.tb-iot-hub-categories { .tb-iot-hub-categories {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
.tb-iot-hub-category-card {
height: 189px;
}
} }
.tb-iot-hub-hero { .tb-iot-hub-hero {
padding: 100px 24px 60px; padding: 140px 24px 60px;
} }
.tb-iot-hub-hero-title { .tb-iot-hub-hero-title {
@ -650,75 +675,102 @@
line-height: 22px; line-height: 22px;
} }
.tb-iot-hub-section { .tb-iot-hub-main-content {
padding: 0 24px; padding: 0 24px 64px;
} }
.tb-iot-hub-become-creator { .tb-iot-hub-become-creator {
padding: 64px 24px; padding: 64px 24px;
} }
}
// SM: 600px viewport — sidebar hidden, full width // Icons: ≥1280px — 69px top (top: 90px), 77px bottom (top: 246px, bottom edge: 323px)
@media (max-width: 960px) { .tb-iot-hub-hero-float-icon {
.tb-iot-hub-big-cards-row { width: 69px;
grid-template-columns: repeat(2, 1fr); height: 69px;
}
.tb-iot-hub-categories { &.icon-pos-1 {
.tb-iot-hub-category-card { top: 90px;
height: 168px; left: 106px;
}
&.icon-pos-2 {
top: 90px;
left: auto;
right: 106px;
}
&.icon-pos-3 {
top: 246px;
left: 28px;
width: 77px;
height: 77px;
}
&.icon-pos-4 {
top: 246px;
left: auto;
right: 28px;
width: 77px;
height: 77px;
} }
} }
}
// ≤959px: only top pair, area width ≤160px (icon 51px + gap + icon 51px)
@media #{$mat-lt-md} {
.tb-iot-hub-hero-float-icon { .tb-iot-hub-hero-float-icon {
width: 51px; width: 51px;
height: 51px; height: 51px;
img { &.icon-pos-1 {
width: 51px; top: 80px;
height: 51px; left: calc(50% - 80px);
}
&.icon-pos-2 {
top: 80px;
left: auto;
right: calc(50% - 80px);
}
&.icon-pos-3,
&.icon-pos-4 {
display: none;
} }
} }
} }
// XS: 375px viewport — single column, stacked layout // ≤599px: only top pair visible (51px), bottom pair hidden
@media (max-width: 600px) { @media #{$mat-lt-sm} {
:host { :host {
padding: 8px; padding: 8px;
} }
.tb-iot-hub-hero { .tb-iot-hub-hero {
padding: 80px 16px 40px; padding: 140px 16px 40px;
} }
.tb-iot-hub-categories { .tb-iot-hub-categories {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding-bottom: 32px;
.tb-iot-hub-category-card {
height: 197px;
}
} }
.tb-iot-hub-big-cards-row { .tb-iot-hub-big-cards-row {
grid-template-columns: repeat(2, 1fr); grid-template-columns: 1fr;
} }
.tb-iot-hub-section { .tb-iot-hub-main-content {
padding: 0 16px; padding: 0 16px 48px;
} }
.tb-iot-hub-become-creator { .tb-iot-hub-become-creator {
padding: 48px 16px; padding: 48px 16px;
} }
.tb-iot-hub-hero-float-icon {
display: none;
}
.tb-iot-hub-installed-btn { .tb-iot-hub-installed-btn {
top: 12px; top: 12px;
right: 12px; right: auto;
left: 50%;
transform: translateX(-50%);
} }
} }

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

@ -19,8 +19,10 @@ import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; import { MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { BreakpointObserver } from '@angular/cdk/layout';
import { forkJoin, Subject, Subscription } from 'rxjs'; import { forkJoin, Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { MediaBreakpoints } from '@shared/models/constants';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { Direction, SortOrder } from '@shared/models/page/sort-order';
import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
@ -134,15 +136,35 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
isLoading = true; isLoading = true;
bigCardCount = 5;
compactCardCount = 6;
private breakpointSubscription: Subscription;
constructor( constructor(
private router: Router, private router: Router,
private dialog: MatDialog, private dialog: MatDialog,
private iotHubApiService: IotHubApiService, private iotHubApiService: IotHubApiService,
private translate: TranslateService private translate: TranslateService,
private breakpointObserver: BreakpointObserver
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.updateCardCounts();
this.loadPopularItems(); this.loadPopularItems();
this.breakpointSubscription = this.breakpointObserver.observe([
MediaBreakpoints['lt-sm'],
MediaBreakpoints['lt-md'],
MediaBreakpoints['lt-lg'],
MediaBreakpoints['lt-xmd'],
MediaBreakpoints['lt-xl'],
MediaBreakpoints['gt-xxl']
]).subscribe(() => {
const prev = { big: this.bigCardCount, compact: this.compactCardCount };
this.updateCardCounts();
if (this.bigCardCount !== prev.big || this.compactCardCount !== prev.compact) {
this.loadPopularItems();
}
});
this.searchSubscription = this.searchSubject.pipe( this.searchSubscription = this.searchSubject.pipe(
debounceTime(300), debounceTime(300),
distinctUntilChanged(), distinctUntilChanged(),
@ -169,6 +191,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.stopHeroCycle(); this.stopHeroCycle();
this.searchSubscription?.unsubscribe(); this.searchSubscription?.unsubscribe();
this.breakpointSubscription?.unsubscribe();
} }
onHeroTypeHover(config: HeroTypeConfig): void { onHeroTypeHover(config: HeroTypeConfig): void {
@ -236,7 +259,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
} }
isCompactType(type: ItemType): boolean { isCompactType(type: ItemType): boolean {
return type === ItemType.CALCULATED_FIELD || type === ItemType.RULE_CHAIN; return type === ItemType.CALCULATED_FIELD || type === ItemType.RULE_CHAIN || type === ItemType.DEVICE;
} }
getCompactIcon(item: MpItemVersionView): string { getCompactIcon(item: MpItemVersionView): string {
@ -438,6 +461,38 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
window.open('https://iothub.thingsboard.io/signup', '_blank'); window.open('https://iothub.thingsboard.io/signup', '_blank');
} }
private updateCardCounts(): void {
if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-sm'])) {
// ≤599px: 1-col big cards, 1-col compact
this.bigCardCount = 2;
this.compactCardCount = 4;
} else if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-md'])) {
// ≤959px: 2-col big cards, 1-col compact
this.bigCardCount = 4;
this.compactCardCount = 4;
} else if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-lg'])) {
// ≤1279px: 2-col big cards, 1-col compact
this.bigCardCount = 4;
this.compactCardCount = 4;
} else if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-xmd'])) {
// ≤1599px: 3-col big cards, 2-col compact
this.bigCardCount = 3;
this.compactCardCount = 4;
} else if (this.breakpointObserver.isMatched(MediaBreakpoints['lt-xl'])) {
// ≤1919px: 4-col big cards, 3-col compact
this.bigCardCount = 4;
this.compactCardCount = 6;
} else if (this.breakpointObserver.isMatched(MediaBreakpoints['gt-xxl'])) {
// ≥2448px: 6-col big cards, 4-col compact
this.bigCardCount = 6;
this.compactCardCount = 8;
} else {
// ≥1920px: 5-col big cards, 3-col compact
this.bigCardCount = 5;
this.compactCardCount = 6;
}
}
private loadPopularItems(): void { private loadPopularItems(): void {
const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC }; const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC };
const config = { ignoreLoading: true }; const config = { ignoreLoading: true };
@ -449,11 +504,11 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
}; };
forkJoin({ forkJoin({
widgets: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.WIDGET, 5), config), widgets: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.WIDGET, this.bigCardCount), config),
dashboards: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DASHBOARD, 5), config), dashboards: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DASHBOARD, this.bigCardCount), config),
solutionTemplates: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.SOLUTION_TEMPLATE, 5), config), solutionTemplates: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.SOLUTION_TEMPLATE, this.bigCardCount), config),
calcFields: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.CALCULATED_FIELD, 6), config), calcFields: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.CALCULATED_FIELD, this.compactCardCount), config),
ruleChains: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.RULE_CHAIN, 6), config), ruleChains: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.RULE_CHAIN, this.compactCardCount), config),
installedWidgets: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.WIDGET, config), installedWidgets: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.WIDGET, config),
installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, config), installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, config),
installedCount: this.iotHubApiService.getInstalledItemsCount(null, config) installedCount: this.iotHubApiService.getInstalledItemsCount(null, config)

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

@ -19,6 +19,7 @@
:host { :host {
display: block; display: block;
height: 100%; height: 100%;
min-width: 0;
} }
// Card — Design: white bg, border rgba(0,0,0,0.12), rounded-8, overflow-clip, flex-col, gap-16, pb-16 // Card — Design: white bg, border rgba(0,0,0,0.12), rounded-8, overflow-clip, flex-col, gap-16, pb-16

31
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.scss

@ -214,7 +214,7 @@
} }
} }
// Card grid — Design: 5-column for big cards, 3-column for compact // Card grid — base: ≥1920 5-col big, 3-col compact
.tb-search-card-grid { .tb-search-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
@ -225,18 +225,29 @@
} }
} }
// Responsive // Responsive — same columns as iot-hub home
@media (max-width: 1400px) {
@media #{$mat-gt-xxl} {
.tb-search-card-grid {
grid-template-columns: repeat(6, 1fr);
&.tb-search-card-grid-compact {
grid-template-columns: repeat(4, 1fr);
}
}
}
@media #{$mat-lt-xl} {
.tb-search-card-grid { .tb-search-card-grid {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
&.tb-search-card-grid-compact { &.tb-search-card-grid-compact {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(3, 1fr);
} }
} }
} }
@media (max-width: 1100px) { @media #{$mat-lt-xmd} {
.tb-search-card-grid { .tb-search-card-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@ -246,7 +257,7 @@
} }
} }
@media (max-width: 900px) { @media #{$mat-lt-lg} {
.tb-search-card-grid { .tb-search-card-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@ -254,7 +265,9 @@
grid-template-columns: repeat(1, 1fr); grid-template-columns: repeat(1, 1fr);
} }
} }
}
@media #{$mat-lt-md} {
.tb-search-input-field { .tb-search-input-field {
width: 100%; width: 100%;
flex: 1; flex: 1;
@ -264,3 +277,9 @@
padding: 24px; padding: 24px;
} }
} }
@media #{$mat-lt-sm} {
.tb-search-card-grid {
grid-template-columns: repeat(1, 1fr);
}
}

2
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.ts

@ -173,7 +173,7 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
// Type helpers // Type helpers
isCompactType(type: ItemType): boolean { isCompactType(type: ItemType): boolean {
return type === ItemType.CALCULATED_FIELD || type === ItemType.RULE_CHAIN; return type === ItemType.CALCULATED_FIELD || type === ItemType.RULE_CHAIN || type === ItemType.DEVICE;
} }
getTypeLabel(type: ItemType): string { getTypeLabel(type: ItemType): string {

3
ui-ngx/src/app/shared/models/constants.ts

@ -69,11 +69,14 @@ export const MediaBreakpoints = {
'lt-sm': 'screen and (max-width: 599px)', 'lt-sm': 'screen and (max-width: 599px)',
'lt-md': 'screen and (max-width: 959px)', 'lt-md': 'screen and (max-width: 959px)',
'lt-lg': 'screen and (max-width: 1279px)', 'lt-lg': 'screen and (max-width: 1279px)',
'lt-xmd': 'screen and (max-width: 1599px)',
'lt-xl': 'screen and (max-width: 1919px)', 'lt-xl': 'screen and (max-width: 1919px)',
'gt-xs': 'screen and (min-width: 600px)', 'gt-xs': 'screen and (min-width: 600px)',
'gt-sm': 'screen and (min-width: 960px)', 'gt-sm': 'screen and (min-width: 960px)',
'gt-md': 'screen and (min-width: 1280px)', 'gt-md': 'screen and (min-width: 1280px)',
'gt-xmd': 'screen and (min-width: 1600px)',
'gt-lg': 'screen and (min-width: 1920px)', 'gt-lg': 'screen and (min-width: 1920px)',
'gt-xxl': 'screen and (min-width: 2448px)',
'gt-xl': 'screen and (min-width: 5001px)', 'gt-xl': 'screen and (min-width: 5001px)',
'md-lg': 'screen and (min-width: 960px) and (max-width: 1819px)' 'md-lg': 'screen and (min-width: 960px) and (max-width: 1819px)'
}; };

2
ui-ngx/src/scss/constants.scss

@ -25,7 +25,9 @@ $mat-lt-xl: "screen and (max-width: 1919px)";
$mat-gt-xs: "screen and (min-width: 600px)"; $mat-gt-xs: "screen and (min-width: 600px)";
$mat-gt-sm: "screen and (min-width: 960px)"; $mat-gt-sm: "screen and (min-width: 960px)";
$mat-gt-md: "screen and (min-width: 1280px)"; $mat-gt-md: "screen and (min-width: 1280px)";
$mat-lt-xmd: "screen and (max-width: 1599px)";
$mat-gt-xmd: "screen and (min-width: 1600px)"; $mat-gt-xmd: "screen and (min-width: 1600px)";
$mat-gt-xxl: "screen and (min-width: 2448px)";
$mat-gt-xl: "screen and (min-width: 1920px)"; $mat-gt-xl: "screen and (min-width: 1920px)";
$mat-md-lg: "screen and (min-width: 960px) and (max-width: 1819px)"; $mat-md-lg: "screen and (min-width: 960px) and (max-width: 1819px)";

Loading…
Cancel
Save