From 276debbd5d9e11b6b70e9e9c6ce818f4fbbda3f7 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 21 Nov 2023 11:27:37 +0200 Subject: [PATCH] UI: Image Gallery component. --- .../server/controller/ImageController.java | 12 +- .../server/dao/util/ImageUtils.java | 53 +- ui-ngx/src/app/core/http/image.service.ts | 5 +- ui-ngx/src/app/core/services/menu.service.ts | 7 + .../components/grid/scroll-grid.component.ts | 4 + .../home/components/home-components.module.ts | 10 +- .../image/image-gallery.component.html | 269 +++++++++ .../image/image-gallery.component.scss | 182 ++++++ .../image/image-gallery.component.ts | 545 ++++++++++++++++++ .../image/upload-image-dialog.component.html | 61 ++ .../image/upload-image-dialog.component.ts | 90 +++ .../datasource/scroll-grid-datasource.ts | 4 + .../home/pages/admin/admin-routing.module.ts | 20 + .../components/image-input.component.ts | 38 +- .../assets/locale/locale.constant-en_US.json | 28 +- 15 files changed, 1310 insertions(+), 18 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/image/image-gallery.component.html create mode 100644 ui-ngx/src/app/modules/home/components/image/image-gallery.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/image/image-gallery.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.ts diff --git a/application/src/main/java/org/thingsboard/server/controller/ImageController.java b/application/src/main/java/org/thingsboard/server/controller/ImageController.java index a027e77b2e..0d5630c2c1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java @@ -54,9 +54,6 @@ import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; -import java.time.Duration; -import java.util.function.Supplier; - import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_SORT_PROPERTY_ALLOWABLE_VALUES; @@ -80,14 +77,19 @@ public class ImageController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PostMapping("/api/image") - public TbResourceInfo uploadImage(@RequestPart MultipartFile file) throws Exception { + public TbResourceInfo uploadImage(@RequestPart MultipartFile file, + @RequestPart(required = false) String title) throws Exception { SecurityUser user = getCurrentUser(); TbResource image = new TbResource(); image.setTenantId(user.getTenantId()); accessControlService.checkPermission(user, Resource.TB_RESOURCE, Operation.CREATE, null, image); image.setFileName(file.getOriginalFilename()); - image.setTitle(file.getOriginalFilename()); + if (StringUtils.isNotEmpty(title)) { + image.setTitle(title); + } else { + image.setTitle(file.getOriginalFilename()); + } image.setResourceType(ResourceType.IMAGE); ImageDescriptor descriptor = new ImageDescriptor(); descriptor.setMediaType(file.getContentType()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java b/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java index 6f312b3b72..ee10a403fd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java @@ -18,11 +18,21 @@ package org.thingsboard.server.dao.util; import lombok.AccessLevel; import lombok.Data; import lombok.NoArgsConstructor; +import org.apache.batik.anim.dom.SAXSVGDocumentFactory; +import org.apache.batik.bridge.BridgeContext; +import org.apache.batik.bridge.DocumentLoader; +import org.apache.batik.bridge.GVTBuilder; +import org.apache.batik.bridge.UserAgent; +import org.apache.batik.bridge.UserAgentAdapter; +import org.apache.batik.gvt.GraphicsNode; import org.apache.batik.transcoder.TranscoderInput; import org.apache.batik.transcoder.TranscoderOutput; import org.apache.batik.transcoder.image.PNGTranscoder; +import org.apache.batik.util.XMLResourceDescriptor; import org.springframework.util.MimeType; import org.springframework.util.MimeTypeUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.w3c.dom.Document; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; @@ -52,7 +62,7 @@ public class ImageUtils { public static ProcessedImage processImage(byte[] data, String mediaType, int thumbnailMaxDimension) throws Exception { if (mediaTypeToFileExtension(mediaType).equals("svg")) { - return processSvgImage(data, thumbnailMaxDimension); + return processSvgImage(data, mediaType, thumbnailMaxDimension); } BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(data)); @@ -90,10 +100,45 @@ public class ImageUtils { return image; } - public static ProcessedImage processSvgImage(byte[] data, int thumbnailMaxDimension) throws Exception { + public static ProcessedImage processSvgImage(byte[] data, String mediaType, int thumbnailMaxDimension) throws Exception { + SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory( + XMLResourceDescriptor.getXMLParserClassName()); + Document document = factory.createDocument( + null, new ByteArrayInputStream(data)); + Integer width = null; + Integer height = null; + String strWidth = document.getDocumentElement().getAttribute("width"); + String strHeight = document.getDocumentElement().getAttribute("height"); + if (StringUtils.isNotEmpty(strWidth) && StringUtils.isNotEmpty(strHeight)) { + width = Integer.parseInt(strWidth); + height = Integer.parseInt(strHeight); + } else { + String viewBox = document.getDocumentElement().getAttribute("viewBox"); + if (StringUtils.isNotEmpty(viewBox)) { + String[] viewBoxValues = viewBox.split(" "); + if (viewBoxValues.length > 3) { + width = Integer.parseInt(viewBoxValues[2]); + height = Integer.parseInt(viewBoxValues[3]); + } + } + } + if (width == null) { + UserAgent agent = new UserAgentAdapter(); + DocumentLoader loader= new DocumentLoader(agent); + BridgeContext context = new BridgeContext(agent, loader); + context.setDynamic(true); + GVTBuilder builder= new GVTBuilder(); + GraphicsNode root = builder.build(context, document); + var bounds = root.getPrimitiveBounds(); + if (bounds != null) { + width = (int) bounds.getWidth(); + height = (int) bounds.getHeight(); + } + } ProcessedImage image = new ProcessedImage(); - image.setWidth(0); - image.setHeight(0); + image.setMediaType(mediaType); + image.setWidth(width == null ? 0 : width); + image.setHeight(height == null ? 0 : height); image.setData(data); image.setSize(data.length); diff --git a/ui-ngx/src/app/core/http/image.service.ts b/ui-ngx/src/app/core/http/image.service.ts index f52f55eb27..699582eb38 100644 --- a/ui-ngx/src/app/core/http/image.service.ts +++ b/ui-ngx/src/app/core/http/image.service.ts @@ -41,12 +41,13 @@ export class ImageService { ) { } - public uploadImage(file: File, config?: RequestConfig): Observable { + public uploadImage(file: File, title: string, config?: RequestConfig): Observable { if (!config) { config = {}; } const formData = new FormData(); formData.append('file', file); + formData.append('title', title); return this.http.post('/api/image', formData, defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest)); } @@ -133,7 +134,7 @@ export class ImageService { } public deleteImage(type: ImageResourceType, key: string, config?: RequestConfig) { - return this.http.delete(`${IMAGES_URL_PREFIX}${type}/${encodeURIComponent}`, defaultHttpOptionsFromConfig(config)); + return this.http.delete(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`, defaultHttpOptionsFromConfig(config)); } } diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts index d48b0619ca..aa622af51b 100644 --- a/ui-ngx/src/app/core/services/menu.service.ts +++ b/ui-ngx/src/app/core/services/menu.service.ts @@ -143,6 +143,13 @@ export class MenuService { } ] }, + { + id: 'images', + name: 'image.gallery', + type: 'link', + path: '/resources/images', + icon: 'filter' + }, { id: 'resources_library', name: 'resource.resources-library', diff --git a/ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.ts b/ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.ts index 7044fc040e..b2b6e79fe9 100644 --- a/ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.ts +++ b/ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.ts @@ -108,4 +108,8 @@ export class ScrollGridComponent implements OnInit, AfterViewInit, OnChang trackByItem(index: number, item: T): T { return item; } + + public update() { + this.dataSource.update(); + } } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 46e9f15275..4e3a52e9f1 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -182,6 +182,8 @@ import { ExportWidgetsBundleDialogComponent } from '@home/components/import-export/export-widgets-bundle-dialog.component'; import { ScrollGridComponent } from '@home/components/grid/scroll-grid.component'; +import { ImageGalleryComponent } from '@home/components/image/image-gallery.component'; +import { UploadImageDialogComponent } from '@home/components/image/upload-image-dialog.component'; @NgModule({ declarations: @@ -327,7 +329,9 @@ import { ScrollGridComponent } from '@home/components/grid/scroll-grid.component RateLimitsTextComponent, RateLimitsDetailsDialogComponent, SendNotificationButtonComponent, - ScrollGridComponent + ScrollGridComponent, + ImageGalleryComponent, + UploadImageDialogComponent ], imports: [ CommonModule, @@ -466,7 +470,9 @@ import { ScrollGridComponent } from '@home/components/grid/scroll-grid.component RateLimitsTextComponent, RateLimitsDetailsDialogComponent, SendNotificationButtonComponent, - ScrollGridComponent + ScrollGridComponent, + ImageGalleryComponent, + UploadImageDialogComponent ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/components/image/image-gallery.component.html b/ui-ngx/src/app/modules/home/components/image/image-gallery.component.html new file mode 100644 index 0000000000..ebb4a40507 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/image-gallery.component.html @@ -0,0 +1,269 @@ + +
+
+ +
+ image.gallery +
+
+ +
+
+ +
+
+
+
+ +
+ + + +
+ +
+
+ +
+ + +   + + + +
+
+ +
+ + {{ translate.get('image.selected-images', {count: dataSource.selection.selected.length}) | async }} + + + +
+
+
+
+ + + + + + + + + + + + + + + {{ image.title }} + + + + {{ 'image.name' | translate }} + + {{ image.title }} + + + + {{ 'image.created-time' | translate }} + + {{ image.createdTime | date:'yyyy-MM-dd HH:mm:ss' }} + + + + {{ 'image.resolution' | translate }} + + {{ image.descriptor.width }}x{{ image.descriptor.height }} + + + + {{ 'image.size' | translate }} + + {{ image.descriptor.size | fileSize }} + + + + {{ 'image.system' | translate }} + + {{isSystem(image) ? 'check_box' : 'check_box_outline_blank'}} + + + + + + +
+ + + + +
+
+
+ + +
+ + + + {{ 'common.loading' | translate }} +
+ + +
+
+ + +
+
+
+ +
+ + {{ 'common.loading' | translate }} + + +
+
+ +
+
+
image.no-images
+
+
+ +
+
+
+ {{ item.title }} +
+
+
+
+ {{ item.title }} +
+
sys
+
+
+
{{ item.descriptor.width }}x{{ item.descriptor.height }}
+ +
{{ item.descriptor.size | fileSize }}
+
+
+
+
+ +
TODO
+
diff --git a/ui-ngx/src/app/modules/home/components/image/image-gallery.component.scss b/ui-ngx/src/app/modules/home/components/image/image-gallery.component.scss new file mode 100644 index 0000000000..fe48ba197f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/image-gallery.component.scss @@ -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; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/image/image-gallery.component.ts b/ui-ngx/src/app/modules/home/components/image/image-gallery.component.ts new file mode 100644 index 0000000000..6b1b931d7a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/image-gallery.component.ts @@ -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; + + 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; + gridImagesFilter = ''; + + 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 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(); + 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); + } 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) { + 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, { + 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 { + private entitiesSubject = new BehaviorSubject([]); + private pageDataSubject = new BehaviorSubject>(emptyPageData()); + + public pageData$ = this.pageDataSubject.asObservable(); + + public selection = new SelectionModel(true, []); + + public dataLoading = true; + + constructor(private imageService: ImageService) { + } + + connect(collectionViewer: CollectionViewer): + Observable> { + return this.entitiesSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.entitiesSubject.complete(); + this.pageDataSubject.complete(); + } + + reset() { + const pageData = emptyPageData(); + this.entitiesSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + } + + loadEntities(pageLink: PageLink): Observable> { + this.dataLoading = true; + const result = new ReplaySubject>(); + this.fetchEntities(pageLink).pipe( + tap(() => { + this.selection.clear(); + }), + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.entitiesSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + result.next(pageData); + this.dataLoading = false; + } + ); + return result; + } + + fetchEntities(pageLink: PageLink): Observable> { + return this.imageService.getImages(pageLink); + } + + isAllSelected(): Observable { + const numSelected = this.selection.selected.length; + return this.entitiesSubject.pipe( + map((entities) => numSelected === entities.length) + ); + } + + isEmpty(): Observable { + return this.entitiesSubject.pipe( + map((entities) => !entities.length) + ); + } + + total(): Observable { + 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); + }); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.html b/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.html new file mode 100644 index 0000000000..91a668daab --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.html @@ -0,0 +1,61 @@ + +
+ +

{{ 'image.upload-image' | translate }}

+ + +
+ + +
+
+
+ + + + image.name + + + {{ 'image.name-required' | translate }} + + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.ts b/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.ts new file mode 100644 index 0000000000..46a2843f7a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.ts @@ -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 implements OnInit, ErrorStateMatcher { + + uploadImageFormGroup: UntypedFormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + private imageService: ImageService, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + 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); + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/models/datasource/scroll-grid-datasource.ts b/ui-ngx/src/app/modules/home/models/datasource/scroll-grid-datasource.ts index c653ed0121..83087eadc0 100644 --- a/ui-ngx/src/app/modules/home/models/datasource/scroll-grid-datasource.ts +++ b/ui-ngx/src/app/modules/home/models/datasource/scroll-grid-datasource.ts @@ -90,6 +90,10 @@ export class ScrollGridDatasource extends DataSource<(T | GridCellType)[]> public updateFilter(filter: F) { this.filter = filter; + this.update(); + } + + public update() { if (this.active) { const prevLength = this._rows.length; this._reset(); diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index 5d2727533c..7be45724e8 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -39,6 +39,7 @@ import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-aut import { widgetsLibraryRoutes } from '@home/pages/widget/widget-library-routing.module'; import { RouterTabsComponent } from '@home/components/router-tabs.component'; import { auditLogsRoutes } from '@home/pages/audit-log/audit-log-routing.module'; +import { ImageGalleryComponent } from '@home/components/image/image-gallery.component'; @Injectable() export class OAuth2LoginProcessingUrlResolver implements Resolve { @@ -71,6 +72,25 @@ const routes: Routes = [ } }, ...widgetsLibraryRoutes, + { + path: 'images', + data: { + breadcrumb: { + label: 'image.gallery', + icon: 'filter' + } + }, + children: [ + { + path: '', + component: ImageGalleryComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'image.gallery', + }, + } + ] + }, { path: 'resources-library', data: { diff --git a/ui-ngx/src/app/shared/components/image-input.component.ts b/ui-ngx/src/app/shared/components/image-input.component.ts index 026b64a413..8d41532405 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.ts +++ b/ui-ngx/src/app/shared/components/image-input.component.ts @@ -14,7 +14,16 @@ /// limitations under the License. /// -import { AfterViewInit, ChangeDetectorRef, Component, forwardRef, Input, OnDestroy, ViewChild } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, EventEmitter, + forwardRef, + Input, + OnDestroy, + Output, + ViewChild +} from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -27,6 +36,7 @@ import { UtilsService } from '@core/services/utils.service'; import { DialogService } from '@core/services/dialog.service'; import { TranslateService } from '@ngx-translate/core'; import { FileSizePipe } from '@shared/pipe/file-size.pipe'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-image-input', @@ -74,7 +84,16 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, @Input() inputId = this.utils.guid(); + @Input() + @coerceBoolean() + resultAsFile = false; + + @Output() + fileNameChanged = new EventEmitter(); + imageUrl: string; + file: File; + fileName: string; safeImageUrl: SafeUrl; @ViewChild('flow', {static: true}) @@ -97,7 +116,9 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, ngAfterViewInit() { this.autoUploadSubscription = this.flow.events$.subscribe(event => { if (event.type === 'fileAdded') { - const file = (event.event[0] as flowjs.FlowFile).file; + const flowFile = event.event[0] as flowjs.FlowFile; + const file = flowFile.file; + const fileName = flowFile.name; if (this.maxSizeByte && this.maxSizeByte < file.size) { this.dialog.alert( this.translate.instant('dashboard.cannot-upload-file'), @@ -108,10 +129,12 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, return false; } const reader = new FileReader(); - reader.onload = (loadEvent) => { + reader.onload = (_loadEvent) => { if (typeof reader.result === 'string' && reader.result.startsWith('data:image/')) { this.imageUrl = reader.result; this.safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(this.imageUrl); + this.file = file; + this.fileName = fileName; this.updateModel(); } }; @@ -146,12 +169,19 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, private updateModel() { this.cd.markForCheck(); - this.propagateChange(this.imageUrl); + if (this.resultAsFile) { + this.propagateChange(this.file); + } else { + this.propagateChange(this.imageUrl); + } + this.fileNameChanged.emit(this.fileName); } clearImage() { this.imageUrl = null; this.safeImageUrl = null; + this.file = null; + this.fileName = null; this.updateModel(); } } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index d043b7d86f..98e7334e58 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -77,7 +77,8 @@ "show-more": "Show more", "dont-show-again": "Do not show again", "see-documentation": "See documentation", - "clear": "Clear" + "clear": "Clear", + "upload": "Upload" }, "aggregation": { "aggregation": "Aggregation", @@ -2942,6 +2943,31 @@ "browse-file": "Browse file", "browse-files": "Browse files" }, + "image": { + "gallery": "Image gallery", + "search": "Search image", + "selected-images": "{ count, plural, =1 {1 image} other {# images} } selected", + "created-time": "Created time", + "name": "Name", + "name-required": "Name is required.", + "resolution": "Resolution", + "size": "Size", + "system": "System", + "download-image": "Download image", + "export-image": "Export image", + "import-image": "Import image", + "upload-image": "Upload image", + "edit-image": "Edit image", + "no-images": "No images found", + "delete-image": "Delete image", + "delete-image-title": "Are you sure you want to delete image '{{imageTitle}}'?", + "delete-image-text": "Be careful, after the confirmation image will become unrecoverable.", + "delete-images-title": "Are you sure you want to delete { count, plural, =1 {1 image} other {# images} }?", + "delete-images-text": "Be careful, after the confirmation all selected images will be removed and all related data will become unrecoverable.", + "list-mode": "List view", + "grid-mode": "Grid view", + "image-preview": "Image preview" + }, "image-input": { "drop-images-or": "Drag and drop an images or", "drag-and-drop": "Drag & Drop",