/// /// 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 { ImageResourceInfo, ImageResourceInfoWithReferences, imageResourceType, toImageDeleteResult } from '@shared/models/resource.models'; import { forkJoin, merge, Observable, of, Subject, Subscription } from 'rxjs'; 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 } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, 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 { 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, parseHttpErrorMessage } 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 { ItemSizeStrategy, ScrollGridComponent } from '@home/components/grid/scroll-grid.component'; import { MatDialog } from '@angular/material/dialog'; import { UploadImageDialogComponent, UploadImageDialogData } from '@home/components/image/upload-image-dialog.component'; import { ImageDialogComponent, ImageDialogData } from '@home/components/image/image-dialog.component'; import { ImportExportService } from '@home/components/import-export/import-export.service'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { ImagesInUseDialogComponent, ImagesInUseDialogData } from '@home/components/image/images-in-use-dialog.component'; import { ImagesDatasource } from '@home/components/image/images-datasource'; interface GridImagesFilter { search: string; includeSystemImages: boolean; } const pageGridColumns: 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 } }; const popoverGridColumns: ScrollGridColumns = { columns: 2, breakpoints: { 'gt-lg': 5, 'screen and (min-width: 1600px)': 4, 'gt-md': 3 } }; @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() @coerceBoolean() popoverMode = false; @Input() mode: 'list' | 'grid' = 'list'; @Input() selectionMode = false; @Output() imageSelected = new EventEmitter(); @ViewChild('searchInput') searchInputField: ElementRef; @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; @ViewChild(ScrollGridComponent) gridComponent: ScrollGridComponent; 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}); includeSystemImages = this.fb.control(false); gridColumns: ScrollGridColumns; gridImagesFetchFunction: GridEntitiesFetchFunction; gridImagesFilter: GridImagesFilter = { search: '', includeSystemImages: false }; gridImagesItemSizeStrategy: ItemSizeStrategy = { defaultItemSize: 200, itemSizeFunction: itemWidth => itemWidth + 72 }; authUser = getCurrentAuthUser(this.store); private updateDataSubscription: Subscription; private widgetResize$: ResizeObserver; private destroy$ = new Subject(); private destroyListMode$: Subject; constructor(protected store: Store, private route: ActivatedRoute, private router: Router, private dialog: MatDialog, public translate: TranslateService, private imageService: ImageService, private dialogService: DialogService, private importExportService: ImportExportService, private elementRef: ElementRef, private cd: ChangeDetectorRef, private fb: FormBuilder) { super(store); this.gridImagesFetchFunction = (pageSize, page, filter) => { const pageLink = new PageLink(pageSize, page, filter.search, { property: 'createdTime', direction: Direction.DESC }); return this.imageService.getImages(pageLink, filter.includeSystemImages); }; } ngOnInit(): void { this.gridColumns = this.popoverMode ? popoverGridColumns : pageGridColumns; this.displayedColumns = this.computeDisplayedColumns(); 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, null, entity => this.deleteEnabled(entity)); } } 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.search) ?? '') === 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 = { search: isNotEmptyStr(value) ? encodeURI(value) : null, includeSystemImages: this.includeSystemImages.value }; this.cd.markForCheck(); } }); this.includeSystemImages.valueChanges.pipe( takeUntil(this.destroy$) ).subscribe(value => { this.displayedColumns = this.computeDisplayedColumns(); this.gridImagesFilter = { search: this.gridImagesFilter.search, includeSystemImages: value }; if (this.mode === 'list') { this.paginator.pageIndex = 0; this.updateData(); } else { 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, null, entity => this.deleteEnabled(entity)); } setTimeout(() => { this.updateMode(); }); } } public get isSysAdmin(): boolean { return this.authUser.authority === Authority.SYS_ADMIN; } private computeDisplayedColumns(): string[] { if (this.selectionMode) { return ['preview', 'title', 'imageSelect']; } const columns = ['select', 'preview', 'title', 'createdTime', 'resolution', 'size']; if (!this.isSysAdmin && this.includeSystemImages.value) { columns.push('system'); } columns.push('actions'); return columns; } private updateMode() { if (this.mode === 'list') { this.initListMode(); } else { this.initGridMode(); } } private initListMode() { this.destroyListMode$ = new Subject(); 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 = 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).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, this.includeSystemImages.value); } else { this.gridComponent.update(); } } private imageUpdated(image: ImageResourceInfo, index = -1) { if (this.mode === 'list') { this.updateData(); } else { this.gridComponent.updateItem(index, image); } } private imageDeleted(index = -1) { if (this.mode === 'list') { this.updateData(); } else { this.gridComponent.deleteItem(index); } } enterFilterMode() { this.textSearchMode = true; setTimeout(() => { this.searchInputField.nativeElement.focus(); this.searchInputField.nativeElement.setSelectionRange(0, 0); }, 10); } exitFilterMode() { this.textSearchMode = false; this.textSearch.reset(); } trackByEntity(index: number, entity: BaseData) { return entity; } isSystem(image?: ImageResourceInfo): boolean { return !this.isSysAdmin && image?.tenantId?.id === NULL_UUID; } readonly(image?: ImageResourceInfo): boolean { return this.authUser.authority !== Authority.SYS_ADMIN && this.isSystem(image); } deleteEnabled(image?: ImageResourceInfo): boolean { return this.authUser.authority === Authority.SYS_ADMIN || !this.isSystem(image); } deleteImage($event: Event, image: ImageResourceInfo, itemIndex = -1) { 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, false, {ignoreErrors: true}).pipe( map(() => toImageDeleteResult(image)), catchError((err) => of(toImageDeleteResult(image, err))) ).subscribe( (deleteResult) => { if (deleteResult.success) { this.imageDeleted(itemIndex); } else if (deleteResult.imageIsReferencedError) { this.dialog.open(ImagesInUseDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { multiple: false, images: [{...image, ...{references: deleteResult.references}}] } }).afterClosed().subscribe((images) => { if (images) { this.imageService.deleteImage(imageResourceType(image), image.resourceKey, true).subscribe( () => { this.imageDeleted(itemIndex); } ); } }); } else { const errorMessageWithTimeout = parseHttpErrorMessage(deleteResult.error, this.translate); setTimeout(() => { this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'})); }, errorMessageWithTimeout.timeout); } }); } }); } 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, false, {ignoreErrors: true}).pipe( map(() => toImageDeleteResult(image)), catchError((err) => of(toImageDeleteResult(image, err))) ) ); forkJoin(tasks).subscribe( (deleteResults) => { const anySuccess = deleteResults.some(res => res.success); const referenceErrors = deleteResults.filter(res => res.imageIsReferencedError); const otherError = deleteResults.find(res => !res.success); if (anySuccess) { this.updateData(); } if (referenceErrors?.length) { const imagesWithReferences: ImageResourceInfoWithReferences[] = referenceErrors.map(ref => ({...ref.image, ...{references: ref.references}})); this.dialog.open(ImagesInUseDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { multiple: true, images: imagesWithReferences } }).afterClosed().subscribe((forceDeleteImages) => { if (forceDeleteImages && forceDeleteImages.length) { const forceDeleteTasks = forceDeleteImages.map((image) => this.imageService.deleteImage(imageResourceType(image), image.resourceKey, true) ); forkJoin(forceDeleteTasks).subscribe( () => { this.updateData(); } ); } }); } else if (otherError) { const errorMessageWithTimeout = parseHttpErrorMessage(otherError.error, this.translate); setTimeout(() => { this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'})); }, errorMessageWithTimeout.timeout); } } ); } }); } } downloadImage($event, image: ImageResourceInfo) { if ($event) { $event.stopPropagation(); } this.imageService.downloadImage(imageResourceType(image), image.resourceKey).subscribe(); } exportImage($event, image: ImageResourceInfo) { if ($event) { $event.stopPropagation(); } this.importExportService.exportImage(imageResourceType(image), image.resourceKey); } importImage(): void { this.importExportService.importImage().subscribe((image) => { if (image) { this.updateData(); } }); } selectImage($event, image: ImageResourceInfo) { if ($event) { $event.stopPropagation(); } this.imageSelected.next(image); } uploadImage(): void { this.dialog.open(UploadImageDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: {} }).afterClosed().subscribe((result) => { if (result) { this.updateData(); } }); } editImage($event: Event, image: ImageResourceInfo, itemIndex = -1) { if ($event) { $event.stopPropagation(); } this.dialog.open(ImageDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { image, readonly: this.readonly(image) } }).afterClosed().subscribe((result) => { if (result) { this.imageUpdated(result, itemIndex); } }); } 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(); } } }