15 changed files with 1310 additions and 18 deletions
@ -0,0 +1,269 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2023 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="mat-padding tb-images tb-absolute-fill" [ngClass]="{'mat-padding': pageMode}"> |
|||
<div fxFlex fxLayout="column" class="tb-images-content" [ngClass]="{'tb-outlined-border': pageMode}"> |
|||
<mat-toolbar class="mat-mdc-table-toolbar" [fxShow]="!textSearchMode && dataSource.selection.isEmpty()"> |
|||
<div class="mat-toolbar-tools"> |
|||
<span class="tb-images-title" translate>image.gallery</span> |
|||
<div class="tb-images-view-type-toolbar" |
|||
fxLayout="row" |
|||
fxLayoutAlign="start center"> |
|||
<div class="tb-toolbar-button" [ngClass]="{'tb-selected' : mode === 'list'}"> |
|||
<button mat-icon-button |
|||
(click)="setMode('list')" |
|||
matTooltip="{{'image.list-mode' | translate }}" |
|||
matTooltipPosition="below"> |
|||
<mat-icon>view_list</mat-icon> |
|||
</button> |
|||
</div> |
|||
<div class="tb-toolbar-button" [ngClass]="{'tb-selected' : mode === 'grid'}"> |
|||
<button mat-icon-button |
|||
(click)="setMode('grid');" |
|||
matTooltip="{{'image.grid-mode' | translate }}" |
|||
matTooltipPosition="below"> |
|||
<tb-icon>mdi:view-grid</tb-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<section fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="16px"> |
|||
<span fxFlex></span> |
|||
<section fxLayout="row"> |
|||
<button [disabled]="isLoading$ | async" |
|||
mat-icon-button |
|||
(click)="enterFilterMode()" |
|||
matTooltip="{{'action.search' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>search</mat-icon> |
|||
</button> |
|||
<button [disabled]="isLoading$ | async" |
|||
mat-icon-button |
|||
(click)="updateData()" |
|||
matTooltip="{{'action.refresh' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>refresh</mat-icon> |
|||
</button> |
|||
<button [disabled]="isLoading$ | async" |
|||
mat-icon-button |
|||
(click)="importImage()" |
|||
matTooltip="{{'image.import-image' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>upload</mat-icon> |
|||
</button> |
|||
</section> |
|||
<button [disabled]="isLoading$ | async" |
|||
mat-flat-button |
|||
color="primary" |
|||
(click)="uploadImage()"> |
|||
{{ 'image.upload-image' | translate }} |
|||
</button> |
|||
</section> |
|||
</mat-toolbar> |
|||
<mat-toolbar class="mat-mdc-table-toolbar" [fxShow]="textSearchMode && dataSource.selection.isEmpty()"> |
|||
<div class="mat-toolbar-tools"> |
|||
<button mat-icon-button |
|||
matTooltip="{{ 'image.search' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>search</mat-icon> |
|||
</button> |
|||
<mat-form-field fxFlex> |
|||
<mat-label> </mat-label> |
|||
<input #searchInput matInput |
|||
[formControl]="textSearch" |
|||
placeholder="{{ 'image.search' | translate }}"/> |
|||
</mat-form-field> |
|||
<button mat-icon-button (click)="exitFilterMode()" |
|||
matTooltip="{{ 'action.close' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
</div> |
|||
</mat-toolbar> |
|||
<mat-toolbar class="mat-mdc-table-toolbar" color="primary" [fxShow]="!dataSource.selection.isEmpty()"> |
|||
<div class="mat-toolbar-tools"> |
|||
<span> |
|||
{{ translate.get('image.selected-images', {count: dataSource.selection.selected.length}) | async }} |
|||
</span> |
|||
<span fxFlex></span> |
|||
<button mat-icon-button [disabled]="isLoading$ | async" |
|||
matTooltip="{{ 'action.delete' | translate }}" |
|||
matTooltipPosition="above" |
|||
(click)="deleteImages($event)"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
</mat-toolbar> |
|||
<div fxFlex *ngIf="mode === 'list'" fxLayout="column"> |
|||
<div fxFlex class="table-container"> |
|||
<table mat-table [dataSource]="dataSource" [trackBy]="trackByEntityId" |
|||
matSort [matSortActive]="pageLink.sortOrder.property" [matSortDirection]="pageLink.sortDirection()" matSortDisableClear> |
|||
<ng-container matColumnDef="select" sticky> |
|||
<mat-header-cell *matHeaderCellDef style="width: 30px;"> |
|||
<mat-checkbox (change)="$event ? dataSource.masterToggle() : null" |
|||
[checked]="dataSource.selection.hasValue() && (dataSource.isAllSelected() | async)" |
|||
[indeterminate]="dataSource.selection.hasValue() && (dataSource.isAllSelected() | async) === false"> |
|||
</mat-checkbox> |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let image"> |
|||
<mat-checkbox (click)="$event.stopPropagation()" |
|||
(change)="$event ? dataSource.selection.toggle(image) : null" |
|||
[checked]="dataSource.selection.isSelected(image)"> |
|||
</mat-checkbox> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container matColumnDef="preview"> |
|||
<mat-header-cell *matHeaderCellDef style="width: 50px; min-width: 50px;"></mat-header-cell> |
|||
<mat-cell *matCellDef="let image"> |
|||
<img class="tb-image-preview" [src]="image.link | image: {preview: true} | async" alt="{{ image.title }}"> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container matColumnDef="title"> |
|||
<mat-header-cell *matHeaderCellDef mat-sort-header style="width: 100%;"> {{ 'image.name' | translate }} </mat-header-cell> |
|||
<mat-cell *matCellDef="let image"> |
|||
{{ image.title }} |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container matColumnDef="createdTime"> |
|||
<mat-header-cell *matHeaderCellDef mat-sort-header style="width: 200px; min-width: 200px;"> {{ 'image.created-time' | translate }} </mat-header-cell> |
|||
<mat-cell *matCellDef="let image"> |
|||
{{ image.createdTime | date:'yyyy-MM-dd HH:mm:ss' }} |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container matColumnDef="resolution"> |
|||
<mat-header-cell *matHeaderCellDef style="width: 160px; min-width: 160px;"> {{ 'image.resolution' | translate }} </mat-header-cell> |
|||
<mat-cell *matCellDef="let image"> |
|||
{{ image.descriptor.width }}x{{ image.descriptor.height }} |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container matColumnDef="size"> |
|||
<mat-header-cell *matHeaderCellDef style="width: 160px; min-width: 160px;"> {{ 'image.size' | translate }} </mat-header-cell> |
|||
<mat-cell *matCellDef="let image"> |
|||
{{ image.descriptor.size | fileSize }} |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container matColumnDef="system"> |
|||
<mat-header-cell *matHeaderCellDef style="width: 60px; min-width: 60px;"> {{ 'image.system' | translate }} </mat-header-cell> |
|||
<mat-cell *matCellDef="let image"> |
|||
<mat-icon class="material-icons mat-icon">{{isSystem(image) ? 'check_box' : 'check_box_outline_blank'}}</mat-icon> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container matColumnDef="actions" stickyEnd> |
|||
<mat-header-cell *matHeaderCellDef style="min-width: 192px; max-width: 192px; width: 192px"> |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let image"> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="end"> |
|||
<button mat-icon-button [disabled]="isLoading$ | async" |
|||
matTooltip="{{ 'image.download-image' | translate }}" |
|||
matTooltipPosition="above" |
|||
(click)="downloadImage($event, image)"> |
|||
<mat-icon>file_download</mat-icon> |
|||
</button> |
|||
<button mat-icon-button [disabled]="isLoading$ | async" |
|||
matTooltip="{{ 'image.export-image' | translate }}" |
|||
matTooltipPosition="above" |
|||
(click)="exportImage($event, image)"> |
|||
<tb-icon>mdi:file-export</tb-icon> |
|||
</button> |
|||
<button mat-icon-button [disabled]="isLoading$ | async" |
|||
matTooltip="{{ 'image.edit-image' | translate }}" |
|||
matTooltipPosition="above" |
|||
(click)="editImage($event, image)"> |
|||
<mat-icon>edit</mat-icon> |
|||
</button> |
|||
<button mat-icon-button [disabled]="(isLoading$ | async) || !deleteEnabled(image)" |
|||
matTooltip="{{ 'image.delete-image' | translate }}" |
|||
matTooltipPosition="above" |
|||
(click)="deleteImage($event, image)"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<mat-header-row [ngClass]="{'mat-row-select': true}" *matHeaderRowDef="displayedColumns; sticky: true"></mat-header-row> |
|||
<mat-row [ngClass]="{'mat-row-select': true, |
|||
'mat-selected': dataSource.selection.isSelected(image)}" |
|||
*matRowDef="let image; columns: displayedColumns;" (click)="dataSource.selection.toggle(image)"></mat-row> |
|||
</table> |
|||
<ng-container *ngIf="(dataSource.isEmpty() | async) && !dataSource.dataLoading"> |
|||
<ng-container *ngTemplateOutlet="noImages"></ng-container> |
|||
</ng-container> |
|||
<span [fxShow]="dataSource.dataLoading" |
|||
fxLayoutAlign="center center" |
|||
class="no-data-found">{{ 'common.loading' | translate }}</span> |
|||
</div> |
|||
<mat-divider></mat-divider> |
|||
<mat-paginator [length]="dataSource.total() | async" |
|||
[pageIndex]="pageLink.page" |
|||
[pageSize]="pageLink.pageSize" |
|||
[pageSizeOptions]="pageSizeOptions" |
|||
[hidePageSize]="hidePageSize" |
|||
showFirstLastButtons></mat-paginator> |
|||
</div> |
|||
<div fxFlex class="mat-padding" *ngIf="mode === 'grid'" fxLayout="column"> |
|||
<tb-scroll-grid fxFlex |
|||
[columns]="gridColumns" |
|||
[itemSize]="150" |
|||
[fetchFunction]="gridImagesFetchFunction" |
|||
[filter]="gridImagesFilter" |
|||
[itemCard]="imageCard" |
|||
[loadingCell]="imageLoadingCard" |
|||
[dataLoading]="loadingImages" |
|||
[noData]="noImages"> |
|||
</tb-scroll-grid> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<ng-template #loadingImages> |
|||
<div fxLayout="column" |
|||
fxLayoutAlign="center center" class="tb-absolute-fill"> |
|||
<span class="mat-headline-5" style="padding-bottom: 20px;"> |
|||
{{ 'common.loading' | translate }} |
|||
</span> |
|||
<mat-spinner color="accent" strokeWidth="5"></mat-spinner> |
|||
</div> |
|||
</ng-template> |
|||
<ng-template #noImages> |
|||
<div class="tb-no-images"> |
|||
<div class="tb-no-data-bg"></div> |
|||
<div class="tb-no-data-text" translate>image.no-images</div> |
|||
</div> |
|||
</ng-template> |
|||
<ng-template #imageCard let-item="item"> |
|||
<div class="tb-image-card"> |
|||
<div class="tb-image-preview-container"> |
|||
<div class="tb-image-preview-spacer"></div> |
|||
<img class="tb-image-preview" [src]="item.link | image: {preview: true} | async" alt="{{ item.title }}"> |
|||
</div> |
|||
<div class="tb-image-details"> |
|||
<div class="tb-image-title-container"> |
|||
<div class="tb-image-title"> |
|||
{{ item.title }} |
|||
</div> |
|||
<div *ngIf="isSystem(item)" class="tb-image-sys">sys</div> |
|||
</div> |
|||
<div class="tb-image-info-container"> |
|||
<div>{{ item.descriptor.width }}x{{ item.descriptor.height }}</div> |
|||
<mat-divider vertical></mat-divider> |
|||
<div>{{ item.descriptor.size | fileSize }}</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</ng-template> |
|||
<ng-template #imageLoadingCard> |
|||
<div> TODO </div> |
|||
</ng-template> |
|||
@ -0,0 +1,182 @@ |
|||
/** |
|||
* Copyright © 2016-2023 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. |
|||
*/ |
|||
$tb-button-selected-color: rgb(255, 110, 64) !default; |
|||
|
|||
.tb-images { |
|||
.tb-images-content { |
|||
width: 100%; |
|||
height: 100%; |
|||
background: #fff; |
|||
overflow: hidden; |
|||
|
|||
&.tb-outlined-border { |
|||
box-shadow: 0 0 0 0 rgb(0 0 0 / 20%), 0 0 0 0 rgb(0 0 0 / 14%), 0 0 0 0 rgb(0 0 0 / 12%); |
|||
border: solid 1px #e0e0e0; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.tb-images-title { |
|||
padding-right: 20px; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.tb-images-view-type-toolbar { |
|||
height: 55px; |
|||
min-height: 55px; |
|||
padding-right: 16px; |
|||
background-color: transparent; |
|||
box-shadow: none; |
|||
|
|||
.tb-toolbar-button { |
|||
height: 48px; |
|||
button.mat-mdc-icon-button { |
|||
margin: 0; |
|||
} |
|||
&.tb-selected { |
|||
background-color: rgba(255, 255, 255, .15); |
|||
border-bottom: $tb-button-selected-color solid 4px; |
|||
|
|||
button.mat-mdc-icon-button { |
|||
margin-bottom: -4px; |
|||
|
|||
.mat-icon { |
|||
color: $tb-button-selected-color; |
|||
fill: $tb-button-selected-color; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.table-container, tb-scroll-grid { |
|||
position: relative; |
|||
} |
|||
|
|||
.tb-no-images { |
|||
position: absolute; |
|||
inset: 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.tb-image-card { |
|||
height: 100%; |
|||
border-radius: 8px; |
|||
padding: 8px; |
|||
display: flex; |
|||
gap: 8px; |
|||
flex-direction: column; |
|||
cursor: pointer; |
|||
transition: box-shadow 0.2s; |
|||
|
|||
.tb-image-preview-container { |
|||
position: relative; |
|||
.tb-image-preview-spacer { |
|||
margin-top: 100%; |
|||
} |
|||
.tb-image-preview { |
|||
position: absolute; |
|||
inset: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
} |
|||
|
|||
.tb-image-details { |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: flex-start; |
|||
justify-content: space-between; |
|||
gap: 4px; |
|||
align-self: stretch; |
|||
.tb-image-title-container { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
align-self: stretch; |
|||
.tb-image-title { |
|||
flex: 1; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
-webkit-line-clamp: 2; |
|||
display: -webkit-box; |
|||
-webkit-box-orient: vertical; |
|||
color: rgba(0, 0, 0, 0.76); |
|||
font-size: 12px; |
|||
font-style: normal; |
|||
font-weight: 500; |
|||
line-height: 16px; |
|||
} |
|||
.tb-image-sys { |
|||
padding: 1px 4px; |
|||
border-radius: 4px; |
|||
background: rgba(236, 236, 236, 0.64); |
|||
color: rgba(0, 0, 0, 0.54); |
|||
font-size: 11px; |
|||
font-style: normal; |
|||
font-weight: 500; |
|||
line-height: 16px; |
|||
letter-spacing: 0.017px; |
|||
} |
|||
} |
|||
.tb-image-info-container { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
align-self: stretch; |
|||
color: rgba(0, 0, 0, 0.38); |
|||
font-size: 10px; |
|||
font-style: normal; |
|||
font-weight: 400; |
|||
line-height: 16px; |
|||
.mat-divider.mat-divider-vertical { |
|||
height: 16px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
&:hover { |
|||
box-shadow: 0 0 28px 0 rgba(0, 0, 0, 0.24); |
|||
} |
|||
} |
|||
|
|||
.table-container { |
|||
overflow: auto; |
|||
|
|||
.mat-sort-header-sorted .mat-sort-header-arrow { |
|||
opacity: 1 !important; |
|||
} |
|||
.mat-mdc-cell.mat-column-preview { |
|||
width: 50px; |
|||
height: 50px; |
|||
padding: 2px 12px; |
|||
display: block; |
|||
} |
|||
} |
|||
|
|||
.tb-image-preview { |
|||
width: 50px; |
|||
height: 50px; |
|||
object-fit: contain; |
|||
border-radius: 4px; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,545 @@ |
|||
///
|
|||
/// Copyright © 2016-2023 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 { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections'; |
|||
import { ImageResourceInfo, imageResourceType } from '@shared/models/resource.models'; |
|||
import { BehaviorSubject, forkJoin, merge, Observable, of, ReplaySubject, Subject, Subscription } from 'rxjs'; |
|||
import { emptyPageData, PageData } from '@shared/models/page/page-data'; |
|||
import { ImageService } from '@core/http/image.service'; |
|||
import { TranslateService } from '@ngx-translate/core'; |
|||
import { PageLink, PageQueryParam } from '@shared/models/page/page-link'; |
|||
import { catchError, debounceTime, distinctUntilChanged, map, skip, takeUntil, tap } from 'rxjs/operators'; |
|||
import { |
|||
AfterViewInit, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
ElementRef, |
|||
Input, |
|||
OnDestroy, |
|||
OnInit, |
|||
ViewChild, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { PageComponent } from '@shared/components/page.component'; |
|||
import { MatPaginator } from '@angular/material/paginator'; |
|||
import { MatSort, SortDirection } from '@angular/material/sort'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { DialogService } from '@core/services/dialog.service'; |
|||
import { FormBuilder } from '@angular/forms'; |
|||
import { Direction, SortOrder } from '@shared/models/page/sort-order'; |
|||
import { ResizeObserver } from '@juggle/resize-observer'; |
|||
import { hidePageSizePixelValue } from '@shared/models/constants'; |
|||
import { coerceBoolean } from '@shared/decorators/coercion'; |
|||
import { ActivatedRoute, QueryParamsHandling, Router } from '@angular/router'; |
|||
import { isEqual, isNotEmptyStr } from '@core/utils'; |
|||
import { BaseData, HasId } from '@shared/models/base-data'; |
|||
import { NULL_UUID } from '@shared/models/id/has-uuid'; |
|||
import { getCurrentAuthUser } from '@core/auth/auth.selectors'; |
|||
import { Authority } from '@shared/models/authority.enum'; |
|||
import { GridEntitiesFetchFunction, ScrollGridColumns } from '@home/models/datasource/scroll-grid-datasource'; |
|||
import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; |
|||
import { ScrollGridComponent } from '@home/components/grid/scroll-grid.component'; |
|||
import { |
|||
AddWidgetDialogComponent, |
|||
AddWidgetDialogData |
|||
} from '@home/components/dashboard-page/add-widget-dialog.component'; |
|||
import { Widget } from '@shared/models/widget.models'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { UploadImageDialogComponent } from '@home/components/image/upload-image-dialog.component'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-image-gallery', |
|||
templateUrl: './image-gallery.component.html', |
|||
styleUrls: ['./image-gallery.component.scss'], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class ImageGalleryComponent extends PageComponent implements OnInit, OnDestroy, AfterViewInit { |
|||
|
|||
@Input() |
|||
@coerceBoolean() |
|||
pageMode = true; |
|||
|
|||
@Input() |
|||
mode: 'list' | 'grid' = 'list'; |
|||
|
|||
@ViewChild('searchInput') searchInputField: ElementRef; |
|||
|
|||
@ViewChild(MatPaginator) paginator: MatPaginator; |
|||
@ViewChild(MatSort) sort: MatSort; |
|||
|
|||
@ViewChild(ScrollGridComponent) gridComponent: ScrollGridComponent<ImageResourceInfo, string>; |
|||
|
|||
defaultPageSize = 10; |
|||
defaultSortOrder: SortOrder = { property: 'createdTime', direction: Direction.DESC }; |
|||
hidePageSize = false; |
|||
|
|||
displayedColumns: string[]; |
|||
pageSizeOptions: number[]; |
|||
pageLink: PageLink; |
|||
|
|||
textSearchMode = false; |
|||
|
|||
dataSource: ImagesDatasource; |
|||
|
|||
textSearch = this.fb.control('', {nonNullable: true}); |
|||
|
|||
gridColumns: ScrollGridColumns = { |
|||
columns: 2, |
|||
breakpoints: { |
|||
'screen and (min-width: 2320px)': 10, |
|||
'screen and (min-width: 2000px)': 8, |
|||
'gt-lg': 7, |
|||
'screen and (min-width: 1600px)': 6, |
|||
'gt-md': 5, |
|||
'screen and (min-width: 1120px)': 4, |
|||
'gt-xs': 3 |
|||
} |
|||
}; |
|||
|
|||
gridImagesFetchFunction: GridEntitiesFetchFunction<ImageResourceInfo, string>; |
|||
gridImagesFilter = ''; |
|||
|
|||
authUser = getCurrentAuthUser(this.store); |
|||
|
|||
private updateDataSubscription: Subscription; |
|||
|
|||
private widgetResize$: ResizeObserver; |
|||
private destroy$ = new Subject<void>(); |
|||
private destroyListMode$: Subject<void>; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
private route: ActivatedRoute, |
|||
private router: Router, |
|||
private dialog: MatDialog, |
|||
public translate: TranslateService, |
|||
private imageService: ImageService, |
|||
private dialogService: DialogService, |
|||
private elementRef: ElementRef, |
|||
private cd: ChangeDetectorRef, |
|||
private fb: FormBuilder) { |
|||
super(store); |
|||
|
|||
this.gridImagesFetchFunction = (pageSize, page, filter) => { |
|||
const pageLink = new PageLink(pageSize, page, filter, { |
|||
property: 'createdTime', |
|||
direction: Direction.DESC |
|||
}); |
|||
return this.imageService.getImages(pageLink); |
|||
}; |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.displayedColumns = ['select', 'preview', 'title', 'createdTime', 'resolution', 'size', 'system', 'actions']; |
|||
let sortOrder: SortOrder = this.defaultSortOrder; |
|||
this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; |
|||
const routerQueryParams: PageQueryParam = this.route.snapshot.queryParams; |
|||
if (this.pageMode) { |
|||
if (routerQueryParams.hasOwnProperty('direction') |
|||
|| routerQueryParams.hasOwnProperty('property')) { |
|||
sortOrder = { |
|||
property: routerQueryParams?.property || this.defaultSortOrder.property, |
|||
direction: routerQueryParams?.direction || this.defaultSortOrder.direction |
|||
}; |
|||
} |
|||
} |
|||
this.pageLink = new PageLink(this.defaultPageSize, 0, null, sortOrder); |
|||
if (this.pageMode) { |
|||
if (routerQueryParams.hasOwnProperty('page')) { |
|||
this.pageLink.page = Number(routerQueryParams.page); |
|||
} |
|||
if (routerQueryParams.hasOwnProperty('pageSize')) { |
|||
this.pageLink.pageSize = Number(routerQueryParams.pageSize); |
|||
} |
|||
const textSearchParam = routerQueryParams.textSearch; |
|||
if (isNotEmptyStr(textSearchParam)) { |
|||
const decodedTextSearch = decodeURI(textSearchParam); |
|||
this.textSearchMode = true; |
|||
this.pageLink.textSearch = decodedTextSearch.trim(); |
|||
this.textSearch.setValue(decodedTextSearch, {emitEvent: false}); |
|||
} |
|||
} |
|||
if (this.mode === 'list') { |
|||
this.dataSource = new ImagesDatasource(this.imageService); |
|||
} |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
if (this.widgetResize$) { |
|||
this.widgetResize$.disconnect(); |
|||
} |
|||
if (this.destroyListMode$) { |
|||
this.destroyListMode$.next(); |
|||
this.destroyListMode$.complete(); |
|||
} |
|||
this.destroy$.next(); |
|||
this.destroy$.complete(); |
|||
} |
|||
|
|||
ngAfterViewInit() { |
|||
this.textSearch.valueChanges.pipe( |
|||
debounceTime(150), |
|||
distinctUntilChanged((prev, current) => |
|||
((this.mode === 'list' ? this.pageLink.textSearch : this.gridImagesFilter) ?? '') === current.trim()), |
|||
takeUntil(this.destroy$) |
|||
).subscribe(value => { |
|||
if (this.mode === 'list') { |
|||
if (this.pageMode) { |
|||
const queryParams: PageQueryParam = { |
|||
textSearch: isNotEmptyStr(value) ? encodeURI(value) : null, |
|||
page: null |
|||
}; |
|||
this.updatedRouterParamsAndData(queryParams); |
|||
} else { |
|||
this.pageLink.textSearch = isNotEmptyStr(value) ? value.trim() : null; |
|||
this.paginator.pageIndex = 0; |
|||
this.updateData(); |
|||
} |
|||
} else { |
|||
this.gridImagesFilter = isNotEmptyStr(value) ? value.trim() : null; |
|||
this.cd.markForCheck(); |
|||
} |
|||
}); |
|||
this.updateMode(); |
|||
} |
|||
|
|||
public setMode(targetMode: 'list' | 'grid') { |
|||
if (this.mode !== targetMode) { |
|||
if (this.widgetResize$) { |
|||
this.widgetResize$.disconnect(); |
|||
this.widgetResize$ = null; |
|||
} |
|||
if (this.destroyListMode$) { |
|||
this.destroyListMode$.next(); |
|||
this.destroyListMode$.complete(); |
|||
this.destroyListMode$ = null; |
|||
} |
|||
this.mode = targetMode; |
|||
if (this.mode === 'list') { |
|||
this.dataSource = new ImagesDatasource(this.imageService); |
|||
} |
|||
setTimeout(() => { |
|||
this.updateMode(); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
private updateMode() { |
|||
if (this.mode === 'list') { |
|||
this.initListMode(); |
|||
} else { |
|||
this.initGridMode(); |
|||
} |
|||
} |
|||
|
|||
private initListMode() { |
|||
this.destroyListMode$ = new Subject<void>(); |
|||
this.widgetResize$ = new ResizeObserver(() => { |
|||
const showHidePageSize = this.elementRef.nativeElement.offsetWidth < hidePageSizePixelValue; |
|||
if (showHidePageSize !== this.hidePageSize) { |
|||
this.hidePageSize = showHidePageSize; |
|||
this.cd.markForCheck(); |
|||
} |
|||
}); |
|||
this.widgetResize$.observe(this.elementRef.nativeElement); |
|||
if (this.pageMode) { |
|||
this.route.queryParams.pipe( |
|||
skip(1), |
|||
takeUntil(this.destroyListMode$) |
|||
).subscribe((params: PageQueryParam) => { |
|||
this.paginator.pageIndex = Number(params.page) || 0; |
|||
this.paginator.pageSize = Number(params.pageSize) || this.defaultPageSize; |
|||
this.sort.active = params.property || this.defaultSortOrder.property; |
|||
this.sort.direction = (params.direction || this.defaultSortOrder.direction).toLowerCase() as SortDirection; |
|||
const textSearchParam = params.textSearch; |
|||
if (isNotEmptyStr(textSearchParam)) { |
|||
const decodedTextSearch = decodeURI(textSearchParam); |
|||
this.textSearchMode = true; |
|||
this.pageLink.textSearch = decodedTextSearch.trim(); |
|||
this.textSearch.setValue(decodedTextSearch, {emitEvent: false}); |
|||
} else { |
|||
this.pageLink.textSearch = null; |
|||
this.textSearch.reset('', {emitEvent: false}); |
|||
} |
|||
this.updateData(); |
|||
}); |
|||
} |
|||
this.updatePaginationSubscriptions(); |
|||
this.updateData(); |
|||
} |
|||
|
|||
private initGridMode() { |
|||
|
|||
} |
|||
|
|||
private updatePaginationSubscriptions() { |
|||
if (this.updateDataSubscription) { |
|||
this.updateDataSubscription.unsubscribe(); |
|||
this.updateDataSubscription = null; |
|||
} |
|||
const sortSubscription$: Observable<object> = this.sort.sortChange.asObservable().pipe( |
|||
map((data) => { |
|||
const direction = data.direction.toUpperCase(); |
|||
const queryParams: PageQueryParam = { |
|||
direction: (this.defaultSortOrder.direction === direction ? null : direction) as Direction, |
|||
property: this.defaultSortOrder.property === data.active ? null : data.active |
|||
}; |
|||
queryParams.page = null; |
|||
this.paginator.pageIndex = 0; |
|||
return queryParams; |
|||
}) |
|||
); |
|||
const paginatorSubscription$ = this.paginator.page.asObservable().pipe( |
|||
map((data) => ({ |
|||
page: data.pageIndex === 0 ? null : data.pageIndex, |
|||
pageSize: data.pageSize === this.defaultPageSize ? null : data.pageSize |
|||
})) |
|||
); |
|||
this.updateDataSubscription = (merge(sortSubscription$, paginatorSubscription$) as Observable<PageQueryParam>).pipe( |
|||
takeUntil(this.destroyListMode$) |
|||
).subscribe(queryParams => this.updatedRouterParamsAndData(queryParams)); |
|||
} |
|||
|
|||
clearSelection() { |
|||
this.dataSource.selection.clear(); |
|||
this.cd.detectChanges(); |
|||
} |
|||
|
|||
updateData() { |
|||
if (this.mode === 'list') { |
|||
this.pageLink.page = this.paginator.pageIndex; |
|||
this.pageLink.pageSize = this.paginator.pageSize; |
|||
if (this.sort.active) { |
|||
this.pageLink.sortOrder = { |
|||
property: this.sort.active, |
|||
direction: Direction[this.sort.direction.toUpperCase()] |
|||
}; |
|||
} else { |
|||
this.pageLink.sortOrder = null; |
|||
} |
|||
this.dataSource.loadEntities(this.pageLink); |
|||
} else { |
|||
this.gridComponent.update(); |
|||
} |
|||
} |
|||
|
|||
enterFilterMode() { |
|||
this.textSearchMode = true; |
|||
setTimeout(() => { |
|||
this.searchInputField.nativeElement.focus(); |
|||
this.searchInputField.nativeElement.setSelectionRange(0, 0); |
|||
}, 10); |
|||
} |
|||
|
|||
exitFilterMode() { |
|||
this.textSearchMode = false; |
|||
this.textSearch.reset(); |
|||
} |
|||
|
|||
trackByEntityId(index: number, entity: BaseData<HasId>) { |
|||
return entity.id.id; |
|||
} |
|||
|
|||
isSystem(image?: ImageResourceInfo): boolean { |
|||
return image?.tenantId?.id === NULL_UUID; |
|||
} |
|||
|
|||
deleteEnabled(image?: ImageResourceInfo): boolean { |
|||
return this.authUser.authority === Authority.SYS_ADMIN || !this.isSystem(image); |
|||
} |
|||
|
|||
deleteImage($event: Event, image: ImageResourceInfo) { |
|||
if ($event) { |
|||
$event.stopPropagation(); |
|||
} |
|||
const title = this.translate.instant('image.delete-image-title', {imageTitle: image.title}); |
|||
const content = this.translate.instant('image.delete-image-text'); |
|||
this.dialogService.confirm(title, content, |
|||
this.translate.instant('action.no'), |
|||
this.translate.instant('action.yes')).subscribe((result) => { |
|||
if (result) { |
|||
this.imageService.deleteImage(imageResourceType(image), image.resourceKey).subscribe( |
|||
() => { |
|||
this.updateData(); |
|||
} |
|||
); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
deleteImages($event: Event) { |
|||
if ($event) { |
|||
$event.stopPropagation(); |
|||
} |
|||
const selectedImages = this.dataSource.selection.selected; |
|||
if (selectedImages && selectedImages.length) { |
|||
const title = this.translate.instant('image.delete-images-title', {count: selectedImages.length}); |
|||
const content = this.translate.instant('image.delete-images-text'); |
|||
this.dialogService.confirm(title, content, |
|||
this.translate.instant('action.no'), |
|||
this.translate.instant('action.yes')).subscribe((result) => { |
|||
if (result) { |
|||
const tasks = selectedImages.map((image) => |
|||
this.imageService.deleteImage(imageResourceType(image), image.resourceKey)); |
|||
forkJoin(tasks).subscribe( |
|||
() => { |
|||
this.updateData(); |
|||
} |
|||
); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
downloadImage($event, image: ImageResourceInfo) { |
|||
if ($event) { |
|||
$event.stopPropagation(); |
|||
} |
|||
this.imageService.downloadImage(imageResourceType(image), image.resourceKey).subscribe(); |
|||
} |
|||
|
|||
exportImage($event, image: ImageResourceInfo) { |
|||
if ($event) { |
|||
$event.stopPropagation(); |
|||
} |
|||
// TODO:
|
|||
} |
|||
|
|||
importImage(): void { |
|||
// TODO:
|
|||
} |
|||
|
|||
uploadImage(): void { |
|||
this.dialog.open<UploadImageDialogComponent, any, |
|||
boolean>(UploadImageDialogComponent, { |
|||
disableClose: true, |
|||
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] |
|||
}).afterClosed().subscribe((result) => { |
|||
if (result) { |
|||
this.updateData(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
editImage($event, image: ImageResourceInfo) { |
|||
if ($event) { |
|||
$event.stopPropagation(); |
|||
} |
|||
// TODO:
|
|||
} |
|||
|
|||
protected updatedRouterParamsAndData(queryParams: object, queryParamsHandling: QueryParamsHandling = 'merge') { |
|||
if (this.pageMode) { |
|||
this.router.navigate([], { |
|||
relativeTo: this.route, |
|||
queryParams, |
|||
queryParamsHandling |
|||
}); |
|||
if (queryParamsHandling === '' && isEqual(this.route.snapshot.queryParams, queryParams)) { |
|||
this.updateData(); |
|||
} |
|||
} else { |
|||
this.updateData(); |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
class ImagesDatasource implements DataSource<ImageResourceInfo> { |
|||
private entitiesSubject = new BehaviorSubject<ImageResourceInfo[]>([]); |
|||
private pageDataSubject = new BehaviorSubject<PageData<ImageResourceInfo>>(emptyPageData<ImageResourceInfo>()); |
|||
|
|||
public pageData$ = this.pageDataSubject.asObservable(); |
|||
|
|||
public selection = new SelectionModel<ImageResourceInfo>(true, []); |
|||
|
|||
public dataLoading = true; |
|||
|
|||
constructor(private imageService: ImageService) { |
|||
} |
|||
|
|||
connect(collectionViewer: CollectionViewer): |
|||
Observable<ImageResourceInfo[] | ReadonlyArray<ImageResourceInfo>> { |
|||
return this.entitiesSubject.asObservable(); |
|||
} |
|||
|
|||
disconnect(collectionViewer: CollectionViewer): void { |
|||
this.entitiesSubject.complete(); |
|||
this.pageDataSubject.complete(); |
|||
} |
|||
|
|||
reset() { |
|||
const pageData = emptyPageData<ImageResourceInfo>(); |
|||
this.entitiesSubject.next(pageData.data); |
|||
this.pageDataSubject.next(pageData); |
|||
} |
|||
|
|||
loadEntities(pageLink: PageLink): Observable<PageData<ImageResourceInfo>> { |
|||
this.dataLoading = true; |
|||
const result = new ReplaySubject<PageData<ImageResourceInfo>>(); |
|||
this.fetchEntities(pageLink).pipe( |
|||
tap(() => { |
|||
this.selection.clear(); |
|||
}), |
|||
catchError(() => of(emptyPageData<ImageResourceInfo>())), |
|||
).subscribe( |
|||
(pageData) => { |
|||
this.entitiesSubject.next(pageData.data); |
|||
this.pageDataSubject.next(pageData); |
|||
result.next(pageData); |
|||
this.dataLoading = false; |
|||
} |
|||
); |
|||
return result; |
|||
} |
|||
|
|||
fetchEntities(pageLink: PageLink): Observable<PageData<ImageResourceInfo>> { |
|||
return this.imageService.getImages(pageLink); |
|||
} |
|||
|
|||
isAllSelected(): Observable<boolean> { |
|||
const numSelected = this.selection.selected.length; |
|||
return this.entitiesSubject.pipe( |
|||
map((entities) => numSelected === entities.length) |
|||
); |
|||
} |
|||
|
|||
isEmpty(): Observable<boolean> { |
|||
return this.entitiesSubject.pipe( |
|||
map((entities) => !entities.length) |
|||
); |
|||
} |
|||
|
|||
total(): Observable<number> { |
|||
return this.pageDataSubject.pipe( |
|||
map((pageData) => pageData.totalElements) |
|||
); |
|||
} |
|||
|
|||
masterToggle() { |
|||
const entities = this.entitiesSubject.getValue(); |
|||
const numSelected = this.selection.selected.length; |
|||
if (numSelected === entities.length) { |
|||
this.selection.clear(); |
|||
} else { |
|||
entities.forEach(row => { |
|||
this.selection.select(row); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2023 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. |
|||
|
|||
--> |
|||
<form [formGroup]="uploadImageFormGroup" (ngSubmit)="upload()" style="width: 560px;"> |
|||
<mat-toolbar color="primary"> |
|||
<h2>{{ 'image.upload-image' | translate }}</h2> |
|||
<span fxFlex></span> |
|||
<button mat-icon-button |
|||
(click)="cancel()" |
|||
type="button"> |
|||
<mat-icon class="material-icons">close</mat-icon> |
|||
</button> |
|||
</mat-toolbar> |
|||
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async"> |
|||
</mat-progress-bar> |
|||
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div> |
|||
<div mat-dialog-content> |
|||
<fieldset [disabled]="isLoading$ | async"> |
|||
<tb-image-input resultAsFile |
|||
label="{{'image.image-preview' | translate}}" |
|||
formControlName="file" |
|||
(fileNameChanged)="imageFileNameChanged($event)"> |
|||
</tb-image-input> |
|||
<mat-form-field *ngIf="uploadImageFormGroup.get('file').value" class="mat-block"> |
|||
<mat-label translate>image.name</mat-label> |
|||
<input matInput formControlName="title" required> |
|||
<mat-error *ngIf="uploadImageFormGroup.get('title').hasError('required')"> |
|||
{{ 'image.name-required' | translate }} |
|||
</mat-error> |
|||
</mat-form-field> |
|||
</fieldset> |
|||
</div> |
|||
<div mat-dialog-actions fxLayout="row" fxLayoutAlign="end center"> |
|||
<button mat-button color="primary" |
|||
type="button" |
|||
[disabled]="(isLoading$ | async)" |
|||
(click)="cancel()" cdkFocusInitial> |
|||
{{ 'action.cancel' | translate }} |
|||
</button> |
|||
<button mat-raised-button color="primary" |
|||
type="submit" |
|||
[disabled]="(isLoading$ | async) || uploadImageFormGroup.invalid |
|||
|| !uploadImageFormGroup.dirty"> |
|||
{{ 'action.upload' | translate }} |
|||
</button> |
|||
</div> |
|||
</form> |
|||
@ -0,0 +1,90 @@ |
|||
///
|
|||
/// Copyright © 2016-2023 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, SkipSelf } from '@angular/core'; |
|||
import { ErrorStateMatcher } from '@angular/material/core'; |
|||
import { MatDialogRef } from '@angular/material/dialog'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { |
|||
FormGroupDirective, |
|||
NgForm, |
|||
UntypedFormBuilder, |
|||
UntypedFormControl, |
|||
UntypedFormGroup, |
|||
Validators |
|||
} from '@angular/forms'; |
|||
import { DialogComponent } from '@shared/components/dialog.component'; |
|||
import { Router } from '@angular/router'; |
|||
import { ImageService } from '@core/http/image.service'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-upload-image-dialog', |
|||
templateUrl: './upload-image-dialog.component.html', |
|||
providers: [{provide: ErrorStateMatcher, useExisting: UploadImageDialogComponent}], |
|||
styleUrls: [] |
|||
}) |
|||
export class UploadImageDialogComponent extends |
|||
DialogComponent<UploadImageDialogComponent, boolean> implements OnInit, ErrorStateMatcher { |
|||
|
|||
uploadImageFormGroup: UntypedFormGroup; |
|||
|
|||
submitted = false; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
protected router: Router, |
|||
private imageService: ImageService, |
|||
@SkipSelf() private errorStateMatcher: ErrorStateMatcher, |
|||
public dialogRef: MatDialogRef<UploadImageDialogComponent, boolean>, |
|||
public fb: UntypedFormBuilder) { |
|||
super(store, router, dialogRef); |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.uploadImageFormGroup = this.fb.group({ |
|||
file: [null, [Validators.required]], |
|||
title: [null, [Validators.required]] |
|||
}); |
|||
} |
|||
|
|||
imageFileNameChanged(fileName: string) { |
|||
const titleControl = this.uploadImageFormGroup.get('title'); |
|||
if (!titleControl.value || !titleControl.touched) { |
|||
titleControl.setValue(fileName); |
|||
} |
|||
} |
|||
|
|||
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { |
|||
const originalErrorState = this.errorStateMatcher.isErrorState(control, form); |
|||
const customErrorState = !!(control && control.invalid && this.submitted); |
|||
return originalErrorState || customErrorState; |
|||
} |
|||
|
|||
cancel(): void { |
|||
this.dialogRef.close(false); |
|||
} |
|||
|
|||
upload(): void { |
|||
this.submitted = true; |
|||
const file: File = this.uploadImageFormGroup.get('file').value; |
|||
const title: string = this.uploadImageFormGroup.get('title').value; |
|||
this.imageService.uploadImage(file, title).subscribe( |
|||
() => { |
|||
this.dialogRef.close(true); |
|||
} |
|||
); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue