Merge branch 'feature/image-resources' of github.com:thingsboard/thingsboard into feature/image-resources
This commit is contained in:
commit
92d5af320c
@ -85,14 +85,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());
|
||||
if (StringUtils.isNotEmpty(title)) {
|
||||
image.setTitle(title);
|
||||
} else {
|
||||
image.setTitle(file.getOriginalFilename());
|
||||
}
|
||||
image.setResourceType(ResourceType.IMAGE);
|
||||
ImageDescriptor descriptor = new ImageDescriptor();
|
||||
descriptor.setMediaType(file.getContentType());
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -41,12 +41,13 @@ export class ImageService {
|
||||
) {
|
||||
}
|
||||
|
||||
public uploadImage(file: File, config?: RequestConfig): Observable<ImageResourceInfo> {
|
||||
public uploadImage(file: File, title: string, config?: RequestConfig): Observable<ImageResourceInfo> {
|
||||
if (!config) {
|
||||
config = {};
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('title', title);
|
||||
return this.http.post<ImageResourceInfo>('/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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -108,4 +108,8 @@ export class ScrollGridComponent<T, F> implements OnInit, AfterViewInit, OnChang
|
||||
trackByItem(index: number, item: T): T {
|
||||
return item;
|
||||
}
|
||||
|
||||
public update() {
|
||||
this.dataSource.update();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -90,6 +90,10 @@ export class ScrollGridDatasource<T, F> 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();
|
||||
|
||||
@ -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<string> {
|
||||
@ -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: {
|
||||
|
||||
@ -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<string>();
|
||||
|
||||
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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user