Merge branch 'feature/image-resources' of github.com:thingsboard/thingsboard into feature/image-resources

This commit is contained in:
Andrii Shvaika 2023-11-21 12:03:15 +02:00
commit 92d5af320c
15 changed files with 1310 additions and 15 deletions

View File

@ -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());

View File

@ -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);

View File

@ -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));
}
}

View File

@ -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',

View File

@ -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();
}
}

View File

@ -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,

View File

@ -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>&nbsp;</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>

View File

@ -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;
}
}
}

View File

@ -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);
});
}
}
}

View File

@ -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>

View File

@ -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);
}
);
}
}

View File

@ -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();

View File

@ -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: {

View File

@ -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();
}
}

View File

@ -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",