Browse Source

Extract reusable search component, redesign creator profile, add showCreator

Search component:
- New TbIotHubSearchComponent in home/components/iot-hub/ with search toolbar,
  pagination, grouped results, sort, loading/empty states
- Inputs: searchText (two-way), creatorId, showCreator; ng-content for title
- Custom search input (320x40px, border, search icon, clear button)
- Sort icon-only button on lt-sm, mobile results count in title row
- Divider before categories section

Search page:
- Renamed to TbIotHubSearchPageComponent, embeds TbIotHubSearchComponent
- Title projected via ng-content, responsive inset padding per breakpoint
- Full-height layout with flex

Creator profile:
- Two-column layout: creator info (360px) + vertical divider + published items
- Uses TbIotHubSearchComponent with creatorId and showCreator=false
- 140px avatar, 28px name, description, link rows with hover styles
- Responsive: column layout on lt-lg, adjustable padding per breakpoint
- Back button inside creator info column

Item detail dialog:
- showCreator parameter (default true), hides author section when false
- openItemDetail service method accepts showCreator argument

Item card:
- Respects showCreator input, hides author in both compact and big layouts
pull/15508/head
Igor Kulikov 6 months ago
parent
commit
6360f57d25
  1. 5
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts
  2. 7
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts
  3. 16
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html
  4. 20
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  5. 3
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts
  6. 143
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html
  7. 140
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss
  8. 44
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts
  9. 115
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html
  10. 312
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss
  11. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts
  12. 36
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.html
  13. 155
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.scss
  14. 45
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.ts
  15. 148
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.html
  16. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts

5
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts

@ -38,11 +38,12 @@ export class IotHubActionsService {
private iotHubApiService: IotHubApiService
) {}
openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, mode?: IotHubItemDetailDialogMode): Observable<any> {
openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem,
mode?: IotHubItemDetailDialogMode, showCreator?: boolean): Observable<any> {
return this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
data: { item, installedItem, mode } as IotHubItemDetailDialogData
data: { item, installedItem, mode, showCreator } as IotHubItemDetailDialogData
}).afterClosed();
}

7
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts

@ -25,6 +25,7 @@ import { TbIotHubInstallDialogComponent } from './iot-hub-install-dialog.compone
import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component';
import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component';
import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component';
import { TbIotHubSearchComponent } from './iot-hub-search.component';
import { IotHubActionsService } from './iot-hub-actions.service';
@NgModule({
@ -36,7 +37,8 @@ import { IotHubActionsService } from './iot-hub-actions.service';
TbIotHubInstallDialogComponent,
TbIotHubUpdateDialogComponent,
TbIotHubDeleteDialogComponent,
TbDeviceInstallDialogComponent
TbDeviceInstallDialogComponent,
TbIotHubSearchComponent
],
imports: [
CommonModule,
@ -53,7 +55,8 @@ import { IotHubActionsService } from './iot-hub-actions.service';
TbIotHubInstallDialogComponent,
TbIotHubUpdateDialogComponent,
TbIotHubDeleteDialogComponent,
TbDeviceInstallDialogComponent
TbDeviceInstallDialogComponent,
TbIotHubSearchComponent
]
})
export class IotHubComponentsModule { }

16
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html

@ -27,9 +27,11 @@
}
<h3 class="tb-iot-hub-compact-name">{{ item.name }}</h3>
<div class="tb-iot-hub-compact-meta">
<tb-icon class="tb-iot-hub-compact-author-icon">person</tb-icon>
<span class="tb-iot-hub-compact-creator" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && onCreatorClick($event)">{{ item.creatorDisplayName }}</span>
<span class="tb-iot-hub-compact-dot">&middot;</span>
@if (showCreator) {
<tb-icon class="tb-iot-hub-compact-author-icon">person</tb-icon>
<span class="tb-iot-hub-compact-creator" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && onCreatorClick($event)">{{ item.creatorDisplayName }}</span>
<span class="tb-iot-hub-compact-dot">&middot;</span>
}
<tb-icon class="tb-iot-hub-compact-download-icon">download</tb-icon>
<span class="tb-iot-hub-compact-count">{{ item.totalInstallCount | shortNumber }}</span>
</div>
@ -74,9 +76,11 @@
}
<h3 class="tb-iot-hub-card-name">{{ item.name }}</h3>
<div class="tb-iot-hub-card-footer">
<tb-icon class="tb-iot-hub-card-author-icon">person</tb-icon>
<span class="tb-iot-hub-card-creator" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && onCreatorClick($event)">{{ item.creatorDisplayName }}</span>
<span class="tb-iot-hub-card-dot">&middot;</span>
@if (showCreator) {
<tb-icon class="tb-iot-hub-card-author-icon">person</tb-icon>
<span class="tb-iot-hub-card-creator" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && onCreatorClick($event)">{{ item.creatorDisplayName }}</span>
<span class="tb-iot-hub-card-dot">&middot;</span>
}
<span class="tb-iot-hub-card-stat">
<tb-icon>download</tb-icon>
{{ item.totalInstallCount | shortNumber }}

20
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html

@ -73,10 +73,12 @@
<tb-markdown [data]="item.description"></tb-markdown>
</div>
}
<div class="dlg-author" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && navigateToCreator()">
<tb-icon>person</tb-icon>
<span>{{ item.creatorDisplayName }}</span>
</div>
@if (showCreator) {
<div class="dlg-author" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && navigateToCreator()">
<tb-icon>person</tb-icon>
<span>{{ item.creatorDisplayName }}</span>
</div>
}
</div>
<div class="dlg-info-actions">
@if (mode === 'add') {
@ -198,10 +200,12 @@
<tb-markdown [data]="item.description"></tb-markdown>
</div>
}
<div class="dlg-author" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && navigateToCreator()">
<tb-icon>person</tb-icon>
<span>{{ item.creatorDisplayName }}</span>
</div>
@if (showCreator) {
<div class="dlg-author" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && navigateToCreator()">
<tb-icon>person</tb-icon>
<span>{{ item.creatorDisplayName }}</span>
</div>
}
</div>
<!-- Action buttons -->
<div class="dlg-info-actions">

3
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts

@ -40,6 +40,7 @@ export interface IotHubItemDetailDialogData {
item: MpItemVersionView;
installedItem?: IotHubInstalledItem;
mode?: IotHubItemDetailDialogMode;
showCreator?: boolean;
}
@Component({
@ -53,6 +54,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
readonly ItemType = ItemType;
item: MpItemVersionView;
mode: IotHubItemDetailDialogMode;
showCreator: boolean;
typeTranslations = itemTypeTranslations;
readmeContent: string = '';
installedItem?: IotHubInstalledItem;
@ -74,6 +76,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
super(store, router, dialogRef);
this.item = data.item;
this.mode = data.mode || 'default';
this.showCreator = data.showCreator !== false;
this.installedItem = data.installedItem;
this.categoryMap = getCategoriesForType(this.item.type);
this.buildCarouselImages();

143
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html

@ -0,0 +1,143 @@
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-search-title-row">
<ng-content></ng-content>
@if (!isLoading && totalElements > 0) {
<span class="tb-search-results-count tb-search-results-count-mobile">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</span>
}
</div>
<!-- Search + Sort row -->
<div class="tb-search-toolbar">
<div class="tb-search-input-field">
<mat-icon class="tb-search-input-icon">search</mat-icon>
<input class="tb-search-input"
[placeholder]="'iot-hub.search' | translate"
[(ngModel)]="searchText"
(input)="onSearchInput()"
(keyup.enter)="onSearchEnter()">
@if (searchText) {
<button mat-icon-button class="tb-search-input-clear" (click)="clearSearch()">
<mat-icon>close</mat-icon>
</button>
}
</div>
<button mat-button color="primary" class="tb-search-sort-btn tb-search-sort-btn-full" [matMenuTriggerFor]="sortMenu">
<mat-icon>filter_list</mat-icon>
{{ sortOptions[selectedSortIndex].label | translate }}
</button>
<button mat-icon-button color="primary" class="tb-search-sort-btn tb-search-sort-btn-icon" [matMenuTriggerFor]="sortMenu">
<mat-icon>filter_list</mat-icon>
</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>
<!-- Results count + Pagination (shown when results exist) -->
@if (!isLoading && totalElements > 0) {
<div class="tb-search-pagination-row">
<span class="tb-search-results-count">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</span>
<div class="tb-search-pagination">
<div class="tb-search-pagination-pages">
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)">
<mat-icon>chevron_left</mat-icon>
</button>
@for (p of getPageNumbers(); track p) {
<button class="tb-search-page-btn" [class.active]="p === pageIndex" (click)="goToPage(p)">
{{ p + 1 }}
</button>
}
<button mat-icon-button [disabled]="pageIndex >= totalPages - 1" (click)="goToPage(pageIndex + 1)">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<div class="tb-search-page-size">
<span>{{ 'iot-hub.items-per-page' | translate }}:</span>
<mat-select [value]="pageSize" (selectionChange)="onPageSizeChange($event.value)" class="tb-search-page-size-select">
@for (size of pageSizeOptions; track size) {
<mat-option [value]="size">{{ size }}</mat-option>
}
</mat-select>
</div>
</div>
</div>
}
<!-- No results count when 0 -->
@if (!isLoading && totalElements === 0 && searchText?.trim()) {
<div class="tb-search-pagination-row">
<span class="tb-search-results-count">
{{ 'iot-hub.results-count' | translate:{ count: 0 } }}
</span>
</div>
}
<!-- Loading -->
@if (isLoading) {
<div class="tb-search-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
<!-- No results -->
@if (!isLoading && totalElements === 0 && searchText?.trim()) {
<div class="tb-search-empty">
<mat-icon class="tb-search-empty-icon">search_off</mat-icon>
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
<p>{{ 'iot-hub.try-adjusting-search' | translate }}</p>
</div>
}
<!-- Results grouped by type -->
@if (!isLoading && resultGroups.length > 0) {
<div class="tb-search-divider"></div>
<div class="tb-search-categories">
@for (group of resultGroups; track group.type) {
<div class="tb-search-section">
<!-- Section header -->
<button mat-button class="tb-search-section-header" (click)="navigateToType(group.type)">
{{ getTypeLabel(group.type) }}
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Card grid -->
<div class="tb-search-card-grid" [class.tb-search-card-grid-compact]="isCompactType(group.type)">
@for (item of group.items; track item.id) {
<tb-iot-hub-item-card
[item]="item"
[installedItem]="getInstalledItem(item)"
[showTypeChip]="false"
[showSubtype]="false"
[showCreator]="showCreator"
(cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"
(updateClick)="updateItem($event)"
(deleteClick)="deleteInstalledItem($event)">
</tb-iot-hub-item-card>
}
</div>
</div>
}
</div>
}

140
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.scss → ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss

@ -17,36 +17,21 @@
@import "../../../../../scss/constants";
:host {
display: block;
padding: 16px;
}
.tb-search-page {
background: white;
border-radius: 8px;
padding: 40px;
display: flex;
flex-direction: column;
gap: 40px;
}
// Back button
.tb-search-back-btn {
align-self: flex-start;
}
// Title — Design: 28px Medium, black-primary
.tb-search-title {
font-size: 28px;
font-weight: 500;
line-height: 36px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
margin: 0;
// Title row
.tb-search-title-row {
display: flex;
align-items: baseline;
gap: 16px;
}
.tb-search-title-query {
color: rgba(0, 0, 0, 0.54);
.tb-search-results-count-mobile {
display: none;
margin-left: auto;
}
// Search + Sort toolbar
@ -57,10 +42,55 @@
}
.tb-search-input-field {
width: 480px;
width: 320px;
min-width: 0;
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: white;
}
.tb-search-input-icon {
font-size: 24px;
width: 24px;
height: 24px;
color: rgba(0, 0, 0, 0.38);
flex-shrink: 0;
}
.tb-search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: rgba(0, 0, 0, 0.87);
background: transparent;
font-family: inherit;
::ng-deep .mat-mdc-text-field-wrapper {
border-radius: 8px;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
}
.tb-search-input-clear.mat-mdc-icon-button {
width: 24px;
height: 24px;
padding: 0;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.38);
--mdc-icon-button-state-layer-size: 24px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
@ -72,6 +102,10 @@
margin-left: auto;
}
.tb-search-sort-btn-icon {
display: none;
}
// Results count + Pagination row
.tb-search-pagination-row {
display: flex;
@ -85,6 +119,7 @@
font-weight: 500;
line-height: 20px;
color: rgba(0, 0, 0, 0.76);
white-space: nowrap;
}
.tb-search-pagination {
@ -154,7 +189,7 @@
margin-top: -20px;
}
// Empty state — Design: centered, 100px icon, 20px title, 14px subtitle
// Empty state
.tb-search-empty {
display: flex;
flex-direction: column;
@ -189,15 +224,25 @@
margin: 0;
}
// Divider before categories
.tb-search-divider {
background: rgba(0, 0, 0, 0.12);
height: 1px;
margin-bottom: -20px;
margin-top: -20px;
}
// Categories / sections
.tb-search-categories {
display: flex;
flex-direction: column;
gap: 48px;
margin-top: -20px;
overflow: auto;
padding-top: 20px;
}
// Section header — Design: 18px Medium, clickable, right chevron
// Section header
.tb-search-section-header {
font-size: 18px;
font-weight: 500;
@ -213,7 +258,7 @@
}
}
// Card grid — base: ≥1920 5-col big, 3-col compact
// Card grid
.tb-search-card-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
@ -224,7 +269,7 @@
}
}
// Responsive — same columns as iot-hub home
// Responsive
@media #{$mat-gt-xxl} {
.tb-search-card-grid {
@ -257,14 +302,6 @@
}
@media #{$mat-lt-lg} {
.tb-search-page {
padding: 24px;
}
.tb-search-input-field {
width: 400px;
}
.tb-search-pagination-row {
flex-direction: column;
align-items: flex-start;
@ -290,24 +327,31 @@
}
@media #{$mat-lt-md} {
.tb-search-input-field {
width: 100%;
flex: 1;
.tb-search-title-row {
margin-bottom: -24px;
}
.tb-search-results-count-mobile {
display: inline;
}
.tb-search-pagination-row .tb-search-results-count {
display: none;
}
}
@media #{$mat-lt-sm} {
.tb-search-page {
padding: 16px;
.tb-search-input-field {
width: 100%;
flex: 1;
}
.tb-search-toolbar {
flex-wrap: wrap;
.tb-search-sort-btn-full {
display: none;
}
.tb-search-sort-btn {
width: 100%;
justify-content: flex-start;
.tb-search-sort-btn-icon {
display: inline-flex;
}
.tb-search-card-grid {

44
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.ts → ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts

@ -14,8 +14,8 @@
/// limitations under the License.
///
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { forkJoin, Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
@ -25,7 +25,7 @@ import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/io
import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { IotHubActionsService } from './iot-hub-actions.service';
interface SearchResultGroup {
type: ItemType;
@ -53,7 +53,11 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
readonly ItemType = ItemType;
searchText = '';
@Input() searchText = '';
@Input() creatorId: string;
@Input() showCreator = true;
@Output() searchTextChange = new EventEmitter<string>();
resultGroups: SearchResultGroup[] = [];
totalElements = 0;
isLoading = false;
@ -76,7 +80,6 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
private searchSubscription: Subscription;
constructor(
private route: ActivatedRoute,
private router: Router,
private translate: TranslateService,
private iotHubApiService: IotHubApiService,
@ -96,11 +99,6 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
).subscribe(result => {
this.applyResults(result.data, result.totalElements);
});
const search = this.route.snapshot.queryParamMap.get('search');
if (search) {
this.searchText = search;
}
this.loadResults();
}
@ -108,16 +106,14 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
this.searchSubscription?.unsubscribe();
}
navigateBack(): void {
this.router.navigate(['/iot-hub']);
}
onSearchInput(): void {
this.searchTextChange.emit(this.searchText);
this.searchSubject.next(this.searchText || '');
}
clearSearch(): void {
this.searchText = '';
this.searchTextChange.emit(this.searchText);
this.loadResults();
}
@ -142,13 +138,17 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
return Array.from({length: total}, (_, i) => i);
}
const pages: number[] = [];
let start = Math.max(0, this.pageIndex - 2);
let end = Math.min(total - 1, start + 4);
const start = Math.max(0, this.pageIndex - 2);
const end = Math.min(total - 1, start + 4);
if (end - start < 4) {
start = Math.max(0, end - 4);
}
for (let i = start; i <= end; i++) {
pages.push(i);
const adjustedStart = Math.max(0, end - 4);
for (let i = adjustedStart; i <= end; i++) {
pages.push(i);
}
} else {
for (let i = start; i <= end; i++) {
pages.push(i);
}
}
return pages;
}
@ -207,7 +207,7 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
// Dialogs
openItemDetail(item: MpItemVersionView): void {
this.iotHubActions.openItemDetail(item, this.getInstalledItem(item)).subscribe(result => {
this.iotHubActions.openItemDetail(item, this.getInstalledItem(item), undefined, this.showCreator).subscribe(result => {
if (result === 'installed' || result === 'deleted' || result === 'updated') {
this.reloadInstalledItems();
}
@ -256,7 +256,7 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
const sort = this.sortOptions[this.selectedSortIndex];
const sortOrder: SortOrder = { property: sort.value, direction: sort.direction };
const pageLink = new PageLink(this.pageSize, this.pageIndex, text.trim() || null, sortOrder);
const query = new MpItemVersionQuery(pageLink);
const query = new MpItemVersionQuery(pageLink, undefined, undefined, this.creatorId || undefined);
return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true });
}

115
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html

@ -15,71 +15,70 @@
limitations under the License.
-->
<div class="tb-iot-hub-creator-page">
<!-- Back link -->
<button mat-button color="primary" class="tb-iot-hub-back-btn" (click)="goBack()">
<mat-icon>chevron_left</mat-icon>
{{ 'iot-hub.back-to-iot-hub' | translate }}
</button>
<div class="tb-creator-page">
@if (creator) {
<!-- Creator profile -->
<div class="tb-iot-hub-creator-profile">
<div class="tb-iot-hub-creator-header">
<div class="tb-iot-hub-creator-avatar">
@if (getAvatarUrl(); as avatarUrl) {
<img [src]="avatarUrl" alt="">
} @else {
<mat-icon>person</mat-icon>
<!-- Left: creator info -->
<div class="tb-creator-info">
<!-- Back link -->
<button mat-button color="primary" class="tb-creator-back-btn" (click)="goBack()">
<mat-icon>chevron_left</mat-icon>
{{ 'iot-hub.back-to-iot-hub' | translate }}
</button>
<div class="tb-creator-details">
<div class="tb-creator-avatar">
@if (getAvatarUrl(); as avatarUrl) {
<img [src]="avatarUrl" alt="">
} @else {
<mat-icon>person</mat-icon>
}
</div>
<h2 class="tb-creator-name">{{ creator.displayName }}</h2>
@if (creator.description) {
<p class="tb-creator-desc">{{ creator.description }}</p>
}
<div class="tb-creator-links">
@if (creator.website) {
<a class="tb-creator-link tb-creator-link-primary" [href]="creator.website" target="_blank">
<mat-icon>language</mat-icon>
<span>{{ getWebsiteLabel() }}</span>
</a>
}
</div>
<div class="tb-iot-hub-creator-name-block">
<h2 class="tb-iot-hub-creator-name">{{ creator.displayName }}</h2>
@if (creator.contactEmail) {
<span class="tb-iot-hub-creator-email">{{ creator.contactEmail }}</span>
<span class="tb-creator-link">
<tb-icon>mdi:email-outline</tb-icon>
<span>{{ creator.contactEmail }}</span>
</span>
}
@if (creator.githubUrl) {
<a class="tb-creator-link" [href]="creator.githubUrl" target="_blank">
<tb-icon>mdi:github</tb-icon>
<span>GitHub</span>
</a>
}
@if (creator.linkedinUrl) {
<a class="tb-creator-link" [href]="creator.linkedinUrl" target="_blank">
<tb-icon>mdi:linkedin</tb-icon>
<span>LinkedIn</span>
</a>
}
@if (creator.twitterUrl) {
<a class="tb-creator-link" [href]="creator.twitterUrl" target="_blank">
<tb-icon>mdi:twitter</tb-icon>
<span>X</span>
</a>
}
</div>
</div>
</div>
@if (creator.description) {
<p class="tb-iot-hub-creator-desc">{{ creator.description }}</p>
}
<div class="tb-iot-hub-creator-links">
@if (creator.website) {
<a mat-stroked-button color="primary" [href]="creator.website" target="_blank" class="tb-iot-hub-creator-website">
<mat-icon>language</mat-icon>
{{ getWebsiteLabel() }}
</a>
}
@if (creator.githubUrl) {
<a [href]="creator.githubUrl" target="_blank" mat-icon-button>
<mat-icon svgIcon="mdi:github"></mat-icon>
</a>
}
@if (creator.linkedinUrl) {
<a [href]="creator.linkedinUrl" target="_blank" mat-icon-button>
<mat-icon svgIcon="mdi:linkedin"></mat-icon>
</a>
}
@if (creator.twitterUrl) {
<a [href]="creator.twitterUrl" target="_blank" mat-icon-button>
<mat-icon svgIcon="mdi:twitter"></mat-icon>
</a>
}
</div>
</div>
<!-- Published items heading -->
<h3 class="tb-iot-hub-published-heading">
{{ 'iot-hub.published-items' | translate }} ({{ creator.publishedCount }})
</h3>
<!-- Divider -->
<div class="tb-creator-divider"></div>
<!-- Embedded browse -->
<tb-iot-hub-browse
#browseComponent
[creatorId]="creatorId"
[embedded]="true">
</tb-iot-hub-browse>
<!-- Right: published items -->
<tb-iot-hub-search class="tb-creator-items" [creatorId]="creatorId" [showCreator]="false">
<h3 class="tb-creator-items-heading">
{{ 'iot-hub.published-items' | translate }} ({{ creator.publishedCount }})
</h3>
</tb-iot-hub-search>
}
</div>

312
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss

@ -19,37 +19,44 @@
:host {
display: block;
padding: 16px;
height: 100%;
}
.tb-iot-hub-creator-page {
.tb-creator-page {
background: white;
border-radius: 8px;
padding: 40px 40px 48px;
padding-left: 123px;
overflow: hidden;
display: flex;
height: 100%;
}
// Back button
.tb-iot-hub-back-btn {
margin-bottom: 24px;
.tb-creator-back-btn {
align-self: flex-start;
}
// Creator profile section
.tb-iot-hub-creator-profile {
margin-bottom: 32px;
// Left column: creator info
.tb-creator-info {
width: 360px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 48px;
padding: 40px 24px 0 0;
}
// Header: avatar + name + email
.tb-iot-hub-creator-header {
.tb-creator-details {
display: flex;
align-items: center;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
.tb-iot-hub-creator-avatar {
width: 64px;
height: 64px;
min-width: 64px;
// Avatar
.tb-creator-avatar {
width: 140px;
height: 140px;
min-width: 140px;
border-radius: 50%;
overflow: hidden;
display: flex;
@ -64,81 +71,264 @@
}
mat-icon {
font-size: 40px;
width: 40px;
height: 40px;
font-size: 48px;
width: 48px;
height: 48px;
color: rgba(0, 0, 0, 0.3);
}
}
.tb-iot-hub-creator-name-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.tb-iot-hub-creator-name {
font-size: 24px;
// Name
.tb-creator-name {
font-size: 28px;
font-weight: 500;
line-height: 32px;
line-height: 36px;
letter-spacing: 0.07px;
color: rgba(0, 0, 0, 0.87);
margin: 0;
}
.tb-iot-hub-creator-email {
font-size: 14px;
color: rgba(0, 0, 0, 0.54);
}
// Description
.tb-iot-hub-creator-desc {
font-size: 14px;
line-height: 20px;
.tb-creator-desc {
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0.15px;
color: rgba(0, 0, 0, 0.76);
margin: 0 0 16px;
max-width: 600px;
margin: 0;
max-width: 800px;
}
// Links: website button + social icons
.tb-iot-hub-creator-links {
// Links
.tb-creator-links {
display: flex;
flex-direction: column;
gap: 4px;
}
.tb-creator-link {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
height: 32px;
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.54);
text-decoration: none;
border-bottom: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
border-bottom: none;
}
.mat-mdc-icon-button {
width: 40px;
height: 40px;
padding: 8px;
mat-icon, tb-icon {
font-size: 24px;
width: 24px;
height: 24px;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.54);
--mdc-icon-button-state-layer-size: 40px;
--mdc-icon-button-icon-color: rgba(0, 0, 0, 0.54);
}
&.tb-creator-link-primary {
color: $tb-primary-color;
mat-icon {
color: $tb-primary-color;
}
}
}
.tb-iot-hub-creator-website {
a.tb-creator-link:hover span {
color: $tb-primary-color;
font-size: 14px;
font-weight: 500;
text-decoration: none;
height: 40px;
text-decoration: underline;
}
&.mat-primary {
--mat-button-outlined-outline-color: #{$tb-primary-color};
}
// Vertical divider (desktop)
.tb-creator-divider {
width: 1px;
background: rgba(0, 0, 0, 0.12);
flex-shrink: 0;
align-self: stretch;
}
// Browse component — stretch to full width and bottom, ignore parent padding
tb-iot-hub-browse {
margin-left: -40px;
margin-right: -40px;
margin-bottom: 0;
// Right column: published items
.tb-creator-items {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding-top: 40px;
padding-left: 24px;
padding-right: 123px;
::ng-deep {
.tb-search-divider {
margin-left: -24px;
margin-right: -123px;
}
.tb-search-categories {
padding-right: 123px;
padding-bottom: 40px;
margin-right: -123px;
}
.tb-search-card-grid {
grid-template-columns: repeat(5, 1fr);
&.tb-search-card-grid-compact {
grid-template-columns: repeat(3, 1fr);
}
}
}
}
// Published items heading
.tb-iot-hub-published-heading {
.tb-creator-items-heading {
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.1px;
color: rgba(0, 0, 0, 0.87);
margin: 0 0 16px;
margin: 0 0 24px;
}
// Responsive
@media #{$mat-lt-xxl} {
.tb-creator-page {
padding-left: 40px;
}
.tb-creator-items {
padding-right: 40px;
::ng-deep {
.tb-search-divider {
margin-right: -40px;
}
.tb-search-categories {
padding-right: 40px;
margin-right: -40px;
}
.tb-search-card-grid {
grid-template-columns: repeat(4, 1fr);
}
}
}
}
@media #{$mat-lt-xl} {
.tb-creator-info {
width: 300px;
}
.tb-creator-items {
::ng-deep {
.tb-search-card-grid {
grid-template-columns: repeat(3, 1fr);
&.tb-search-card-grid-compact {
grid-template-columns: repeat(2, 1fr);
}
}
}
}
}
@media #{$mat-lt-xmd} {
.tb-creator-items {
::ng-deep {
.tb-search-card-grid {
grid-template-columns: repeat(2, 1fr);
&.tb-search-card-grid-compact {
grid-template-columns: repeat(1, 1fr);
}
}
}
}
}
@media #{$mat-lt-lg} {
:host {
height: auto;
}
.tb-creator-page {
flex-direction: column;
padding: 0;
gap: 40px;
}
.tb-creator-info {
width: 100%;
padding: 40px 24px 0 24px;
}
.tb-creator-items {
padding-top: 0;
padding-right: 24px;
::ng-deep {
.tb-search-divider {
margin-right: -24px;
}
.tb-search-categories {
padding-right: 24px;
margin-right: -24px;
}
}
}
.tb-creator-items-heading {
margin-bottom: 0;
}
.tb-creator-divider {
width: 100%;
height: 1px;
align-self: auto;
}
}
@media #{$mat-lt-sm} {
.tb-creator-page {
gap: 24px;
}
.tb-creator-info {
padding: 16px 16px 0 16px;
gap: 24px;
}
.tb-creator-items {
padding-left: 16px;
padding-right: 16px;
::ng-deep {
.tb-search-divider {
margin-left: -16px;
margin-right: -16px;
}
.tb-search-categories {
padding-right: 16px;
margin-right: -16px;
}
.tb-search-card-grid {
grid-template-columns: repeat(1, 1fr);
}
}
}
}

4
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts

@ -22,7 +22,7 @@ import { TbIotHubHomeComponent } from './iot-hub-home.component';
import { TbIotHubItemsPageComponent } from './iot-hub-items-page.component';
import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component';
import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component';
import { TbIotHubSearchComponent } from './iot-hub-search.component';
import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component';
const routes: Routes = [
{
@ -105,7 +105,7 @@ const routes: Routes = [
},
{
path: 'search',
component: TbIotHubSearchComponent,
component: TbIotHubSearchPageComponent,
data: {
auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.search-results',

36
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.html

@ -0,0 +1,36 @@
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-search-page">
<!-- Back -->
<button mat-button color="primary" class="tb-search-back-btn" (click)="navigateBack()">
<mat-icon>chevron_left</mat-icon>
{{ 'iot-hub.back-to-iot-hub' | translate }}
</button>
<!-- Search component -->
<tb-iot-hub-search [(searchText)]="searchText">
<!-- Title -->
<h1 class="tb-search-title">
@if (searchText?.trim()) {
{{ 'iot-hub.search-results-for' | translate }}<span class="tb-search-title-query">'{{ searchText.trim() }}'</span>
} @else {
{{ 'iot-hub.search-results' | translate }}
}
</h1>
</tb-iot-hub-search>
</div>

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

@ -0,0 +1,155 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import "../../../../../scss/constants";
:host {
display: block;
padding: 16px;
height: 100%;
}
.tb-search-page {
background: white;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 40px;
height: 100%;
}
// Back button
.tb-search-back-btn {
align-self: flex-start;
margin-top: 40px;
margin-left: 123px;
}
// Title
.tb-search-title {
font-size: 28px;
font-weight: 500;
line-height: 36px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
margin: 0;
}
.tb-search-title-query {
color: rgba(0, 0, 0, 0.54);
}
tb-iot-hub-search {
flex: 1;
min-height: 0;
padding-left: 123px;
padding-right: 123px;
::ng-deep {
.tb-search-divider {
margin-left: -123px;
margin-right: -123px;
}
.tb-search-categories {
padding-right: 123px;
padding-bottom: 40px;
margin-right: -123px;
}
}
}
// Responsive
@media #{$mat-lt-xxl} {
.tb-search-back-btn {
margin-left: 40px;
}
tb-iot-hub-search {
padding-left: 40px;
padding-right: 40px;
::ng-deep {
.tb-search-divider {
margin-left: -40px;
margin-right: -40px;
}
.tb-search-categories {
padding-right: 40px;
margin-right: -40px;
}
}
}
}
@media #{$mat-lt-lg} {
.tb-search-page {
gap: 24px;
}
.tb-search-back-btn {
margin-top: 24px;
margin-left: 24px;
}
tb-iot-hub-search {
padding-left: 24px;
padding-right: 24px;
::ng-deep {
.tb-search-divider {
margin-left: -24px;
margin-right: -24px;
}
.tb-search-categories {
padding-right: 24px;
padding-bottom: 24px;
margin-right: -24px;
}
}
}
}
@media #{$mat-lt-sm} {
.tb-search-page {
gap: 16px;
}
.tb-search-back-btn {
margin-top: 16px;
margin-left: 16px;
}
tb-iot-hub-search {
padding-left: 16px;
padding-right: 16px;
::ng-deep {
.tb-search-divider {
margin-left: -16px;
margin-right: -16px;
}
.tb-search-categories {
padding-right: 16px;
padding-bottom: 16px;
margin-right: -16px;
}
}
}
}

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

@ -0,0 +1,45 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'tb-iot-hub-search-page',
standalone: false,
templateUrl: './iot-hub-search-page.component.html',
styleUrls: ['./iot-hub-search-page.component.scss']
})
export class TbIotHubSearchPageComponent implements OnInit {
searchText = '';
constructor(
private route: ActivatedRoute,
private router: Router
) {}
ngOnInit(): void {
const search = this.route.snapshot.queryParamMap.get('search');
if (search) {
this.searchText = search;
}
}
navigateBack(): void {
this.router.navigate(['/iot-hub']);
}
}

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

@ -1,148 +0,0 @@
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-search-page">
<!-- Back -->
<button mat-button color="primary" class="tb-search-back-btn" (click)="navigateBack()">
<mat-icon>chevron_left</mat-icon>
{{ 'iot-hub.back-to-iot-hub' | translate }}
</button>
<!-- Title -->
<h1 class="tb-search-title">
@if (searchText?.trim()) {
{{ 'iot-hub.search-results-for' | translate }}<span class="tb-search-title-query">'{{ searchText.trim() }}'</span>
} @else {
{{ 'iot-hub.search-results' | translate }}
}
</h1>
<!-- Search + Sort row -->
<div class="tb-search-toolbar">
<mat-form-field appearance="outline" class="tb-search-input-field" subscriptSizing="dynamic">
<input matInput
[(ngModel)]="searchText"
(input)="onSearchInput()"
(keyup.enter)="onSearchEnter()">
@if (searchText) {
<button matSuffix mat-icon-button (click)="clearSearch()">
<mat-icon>close</mat-icon>
</button>
}
<button matSuffix mat-icon-button (click)="$event.stopPropagation(); onSearchEnter()">
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
<button mat-button color="primary" class="tb-search-sort-btn" [matMenuTriggerFor]="sortMenu">
<mat-icon>filter_list</mat-icon>
{{ sortOptions[selectedSortIndex].label | translate }}
</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>
<!-- Results count + Pagination (shown when results exist) -->
@if (!isLoading && totalElements > 0) {
<div class="tb-search-pagination-row">
<span class="tb-search-results-count">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</span>
<div class="tb-search-pagination">
<div class="tb-search-pagination-pages">
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)">
<mat-icon>chevron_left</mat-icon>
</button>
@for (p of getPageNumbers(); track p) {
<button class="tb-search-page-btn" [class.active]="p === pageIndex" (click)="goToPage(p)">
{{ p + 1 }}
</button>
}
<button mat-icon-button [disabled]="pageIndex >= totalPages - 1" (click)="goToPage(pageIndex + 1)">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<div class="tb-search-page-size">
<span>{{ 'iot-hub.items-per-page' | translate }}:</span>
<mat-select [value]="pageSize" (selectionChange)="onPageSizeChange($event.value)" class="tb-search-page-size-select">
@for (size of pageSizeOptions; track size) {
<mat-option [value]="size">{{ size }}</mat-option>
}
</mat-select>
</div>
</div>
</div>
}
<!-- No results count when 0 -->
@if (!isLoading && totalElements === 0 && searchText?.trim()) {
<div class="tb-search-pagination-row">
<span class="tb-search-results-count">
{{ 'iot-hub.results-count' | translate:{ count: 0 } }}
</span>
</div>
}
<!-- Loading -->
@if (isLoading) {
<div class="tb-search-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
<!-- No results -->
@if (!isLoading && totalElements === 0 && searchText?.trim()) {
<div class="tb-search-empty">
<mat-icon class="tb-search-empty-icon">search_off</mat-icon>
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
<p>{{ 'iot-hub.try-adjusting-search' | translate }}</p>
</div>
}
<!-- Results grouped by type -->
@if (!isLoading && resultGroups.length > 0) {
<div class="tb-search-categories">
@for (group of resultGroups; track group.type) {
<div class="tb-search-section">
<!-- Section header -->
<button mat-button class="tb-search-section-header" (click)="navigateToType(group.type)">
{{ getTypeLabel(group.type) }}
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Card grid -->
<div class="tb-search-card-grid" [class.tb-search-card-grid-compact]="isCompactType(group.type)">
@for (item of group.items; track item.id) {
<tb-iot-hub-item-card
[item]="item"
[installedItem]="getInstalledItem(item)"
[showTypeChip]="false"
[showSubtype]="false"
(cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"
(updateClick)="updateItem($event)"
(deleteClick)="deleteInstalledItem($event)">
</tb-iot-hub-item-card>
}
</div>
</div>
}
</div>
}
</div>

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

@ -23,7 +23,7 @@ import { TbIotHubHomeComponent } from './iot-hub-home.component';
import { TbIotHubItemsPageComponent } from './iot-hub-items-page.component';
import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component';
import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component';
import { TbIotHubSearchComponent } from './iot-hub-search.component';
import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component';
@NgModule({
declarations: [
@ -31,7 +31,7 @@ import { TbIotHubSearchComponent } from './iot-hub-search.component';
TbIotHubItemsPageComponent,
TbIotHubCreatorProfileComponent,
TbIotHubInstalledItemsComponent,
TbIotHubSearchComponent
TbIotHubSearchPageComponent
],
imports: [
CommonModule,

Loading…
Cancel
Save