Browse Source
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 layoutspull/15508/head
16 changed files with 838 additions and 359 deletions
@ -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> |
||||
|
} |
||||
@ -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> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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']); |
||||
|
} |
||||
|
} |
||||
@ -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> |
|
||||
Loading…
Reference in new issue