diff --git a/application/src/main/java/org/thingsboard/server/controller/ImageController.java b/application/src/main/java/org/thingsboard/server/controller/ImageController.java index bfaad1c38d..42881c2cbe 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java @@ -113,6 +113,7 @@ public class ImageController extends BaseController { TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE); TbResource image = new TbResource(imageInfo); image.setData(file.getBytes()); + image.setFileName(file.getOriginalFilename()); image.updateDescriptor(ImageDescriptor.class, descriptor -> { descriptor.setMediaType(file.getContentType()); return descriptor; diff --git a/ui-ngx/src/app/core/http/image.service.ts b/ui-ngx/src/app/core/http/image.service.ts index 699582eb38..e13acb0b3e 100644 --- a/ui-ngx/src/app/core/http/image.service.ts +++ b/ui-ngx/src/app/core/http/image.service.ts @@ -58,7 +58,7 @@ export class ImageService { } const formData = new FormData(); formData.append('file', file); - return this.http.post(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`, formData, + return this.http.put(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`, formData, defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest)); } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 4e3a52e9f1..bb6c47c924 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -184,6 +184,7 @@ import { 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'; +import { ImageDialogComponent } from '@home/components/image/image-dialog.component'; @NgModule({ declarations: @@ -331,7 +332,8 @@ import { UploadImageDialogComponent } from '@home/components/image/upload-image- SendNotificationButtonComponent, ScrollGridComponent, ImageGalleryComponent, - UploadImageDialogComponent + UploadImageDialogComponent, + ImageDialogComponent ], imports: [ CommonModule, @@ -472,7 +474,8 @@ import { UploadImageDialogComponent } from '@home/components/image/upload-image- SendNotificationButtonComponent, ScrollGridComponent, ImageGalleryComponent, - UploadImageDialogComponent + UploadImageDialogComponent, + ImageDialogComponent ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/components/image/image-dialog.component.html b/ui-ngx/src/app/modules/home/components/image/image-dialog.component.html new file mode 100644 index 0000000000..23ef4dcb2a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/image-dialog.component.html @@ -0,0 +1,94 @@ + +
+ +

{{ (readonly ? 'image.image-details' : 'image.edit-image') | translate }}

+ + +
+ + +
+
+
+ + image.name + + + {{ 'image.name-required' | translate }} + + +
+
+
image.image-preview
+
+
+ +
+
+
{{ image.descriptor.width }}x{{ image.descriptor.height }}
+ +
{{ image.descriptor.size | fileSize }}
+
+
+ + + +
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/image/image-dialog.component.scss b/ui-ngx/src/app/modules/home/components/image/image-dialog.component.scss new file mode 100644 index 0000000000..813974e541 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/image-dialog.component.scss @@ -0,0 +1,83 @@ +/** + * 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. + */ + +:host { + .tb-image-container { + width: fit-content; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.05); + padding: 12px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + + .tb-image-content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + .tb-image-preview-title { + color: rgba(0, 0, 0, 0.54); + font-size: 13px; + font-style: normal; + font-weight: 500; + line-height: 16px; + } + .tb-image-preview-container { + position: relative; + width: 100%; + height: 100%; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + .tb-image-preview-spacer { + margin-top: 100%; + } + .tb-image-preview { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + } + } + .tb-image-preview-details { + display: flex; + align-items: center; + gap: 8px; + color: rgba(0, 0, 0, 0.38); + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + .mat-divider.mat-divider-vertical { + height: 20px; + } + } + .tb-image-actions { + display: flex; + align-items: center; + gap: 8px; + color: rgba(0,0,0,0.54); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/image/image-dialog.component.ts b/ui-ngx/src/app/modules/home/components/image/image-dialog.component.ts new file mode 100644 index 0000000000..72b2bbf85c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/image-dialog.component.ts @@ -0,0 +1,147 @@ +/// +/// 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 { ChangeDetectorRef, Component, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialog, 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'; +import { ImageResourceInfo, imageResourceType } from '@shared/models/resource.models'; +import { + UploadImageDialogComponent, + UploadImageDialogData +} from '@home/components/image/upload-image-dialog.component'; +import { UrlHolder } from '@shared/pipe/image.pipe'; + +export interface ImageDialogData { + readonly: boolean; + image: ImageResourceInfo; +} + +@Component({ + selector: 'tb-image-dialog', + templateUrl: './image-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: ImageDialogComponent}], + styleUrls: ['./image-dialog.component.scss'] +}) +export class ImageDialogComponent extends + DialogComponent implements OnInit, ErrorStateMatcher { + + image: ImageResourceInfo; + + readonly: boolean; + + imageFormGroup: UntypedFormGroup; + + submitted = false; + + imageChanged = false; + + imagePreviewData: UrlHolder; + + constructor(protected store: Store, + protected router: Router, + private imageService: ImageService, + private dialog: MatDialog, + @Inject(MAT_DIALOG_DATA) private data: ImageDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: UntypedFormBuilder) { + super(store, router, dialogRef); + this.image = data.image; + this.readonly = data.readonly; + this.imagePreviewData = { + url: this.image.link + }; + } + + ngOnInit(): void { + this.imageFormGroup = this.fb.group({ + title: [this.image.title, [Validators.required]] + }); + if (this.data.readonly) { + this.imageFormGroup.disable(); + } + } + + 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(this.imageChanged); + } + + downloadImage($event) { + if ($event) { + $event.stopPropagation(); + } + this.imageService.downloadImage(imageResourceType(this.image), this.image.resourceKey).subscribe(); + } + + exportImage($event) { + if ($event) { + $event.stopPropagation(); + } + // TODO: + } + + updateImage($event): void { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(UploadImageDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + image: this.image + } + }).afterClosed().subscribe((result) => { + if (result) { + this.imageChanged = true; + this.image = result; + this.imagePreviewData = { + url: this.image.link + }; + } + }); + } + + save(): void { + this.submitted = true; + const title: string = this.imageFormGroup.get('title').value; + const image = {...this.image, ...{title}}; + this.imageService.updateImageInfo(image).subscribe( + () => { + this.dialogRef.close(true); + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/image/image-gallery.component.html b/ui-ngx/src/app/modules/home/components/image/image-gallery.component.html index ebb4a40507..44bf4a4fd5 100644 --- a/ui-ngx/src/app/modules/home/components/image/image-gallery.component.html +++ b/ui-ngx/src/app/modules/home/components/image/image-gallery.component.html @@ -110,7 +110,7 @@
- @@ -185,7 +185,7 @@ (click)="editImage($event, image)"> edit - diff --git a/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.ts b/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.ts index 46a2843f7a..841f64c883 100644 --- a/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/image/upload-image-dialog.component.ts @@ -14,9 +14,9 @@ /// limitations under the License. /// -import { Component, OnInit, SkipSelf } from '@angular/core'; +import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; import { ErrorStateMatcher } from '@angular/material/core'; -import { MatDialogRef } from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { @@ -30,6 +30,11 @@ import { import { DialogComponent } from '@shared/components/dialog.component'; import { Router } from '@angular/router'; import { ImageService } from '@core/http/image.service'; +import { ImageResourceInfo, imageResourceType } from '@shared/models/resource.models'; + +export interface UploadImageDialogData { + image?: ImageResourceInfo; +} @Component({ selector: 'tb-upload-image-dialog', @@ -38,32 +43,40 @@ import { ImageService } from '@core/http/image.service'; styleUrls: [] }) export class UploadImageDialogComponent extends - DialogComponent implements OnInit, ErrorStateMatcher { + DialogComponent implements OnInit, ErrorStateMatcher { uploadImageFormGroup: UntypedFormGroup; + uploadImage = true; + submitted = false; constructor(protected store: Store, protected router: Router, private imageService: ImageService, + @Inject(MAT_DIALOG_DATA) public data: UploadImageDialogData, @SkipSelf() private errorStateMatcher: ErrorStateMatcher, - public dialogRef: MatDialogRef, + public dialogRef: MatDialogRef, public fb: UntypedFormBuilder) { super(store, router, dialogRef); } ngOnInit(): void { + this.uploadImage = !this.data?.image; this.uploadImageFormGroup = this.fb.group({ - file: [null, [Validators.required]], - title: [null, [Validators.required]] + file: [this.data?.image?.link, [Validators.required]] }); + if (this.uploadImage) { + this.uploadImageFormGroup.addControl('title', this.fb.control(null, [Validators.required])); + } } imageFileNameChanged(fileName: string) { - const titleControl = this.uploadImageFormGroup.get('title'); - if (!titleControl.value || !titleControl.touched) { - titleControl.setValue(fileName); + if (this.uploadImage) { + const titleControl = this.uploadImageFormGroup.get('title'); + if (!titleControl.value || !titleControl.touched) { + titleControl.setValue(fileName); + } } } @@ -74,17 +87,26 @@ export class UploadImageDialogComponent extends } cancel(): void { - this.dialogRef.close(false); + this.dialogRef.close(null); } 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); - } - ); + if (this.uploadImage) { + const title: string = this.uploadImageFormGroup.get('title').value; + this.imageService.uploadImage(file, title).subscribe( + (res) => { + this.dialogRef.close(res); + } + ); + } else { + const image = this.data.image; + this.imageService.updateImage(imageResourceType(image), image.resourceKey, file).subscribe( + (res) => { + this.dialogRef.close(res); + } + ); + } } } diff --git a/ui-ngx/src/app/shared/components/image-input.component.html b/ui-ngx/src/app/shared/components/image-input.component.html index 2ed771d8b2..039611bbf3 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.html +++ b/ui-ngx/src/app/shared/components/image-input.component.html @@ -63,5 +63,8 @@ -
dashboard.maximum-upload-file-size
+
+
{{ fileName }}
+
dashboard.maximum-upload-file-size
+
diff --git a/ui-ngx/src/app/shared/components/image-input.component.scss b/ui-ngx/src/app/shared/components/image-input.component.scss index ee4f6451c4..36f4c59557 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.scss +++ b/ui-ngx/src/app/shared/components/image-input.component.scss @@ -23,9 +23,12 @@ $previewSize: 78px !default; .tb-container { margin-top: 0; padding: 0 0 16px; + display: flex; + flex-direction: column; + gap: 8px; label.tb-title { display: block; - padding-bottom: 8px; + padding-bottom: 0px; } } @@ -72,6 +75,30 @@ $previewSize: 78px !default; max-height: $previewSize - 2px; } + .tb-image-info-container { + display: flex; + gap: 12px; + font-size: 13px; + font-style: normal; + line-height: 16px; + letter-spacing: normal; + } + + .tb-image-file-name { + width: 136px; + color: rgba(0, 0, 0, 0.54); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + .tb-image-file-hint { + color: rgba(0, 0, 0, 0.38); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + .file-input { display: none; } diff --git a/ui-ngx/src/app/shared/components/image-input.component.ts b/ui-ngx/src/app/shared/components/image-input.component.ts index 8d41532405..21722159d4 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.ts +++ b/ui-ngx/src/app/shared/components/image-input.component.ts @@ -37,6 +37,7 @@ 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'; +import { ImagePipe } from '@shared/pipe/image.pipe'; @Component({ selector: 'tb-image-input', @@ -84,16 +85,27 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, @Input() inputId = this.utils.guid(); + @Input() + @coerceBoolean() + processImageApiLink = false; + @Input() @coerceBoolean() resultAsFile = false; + @Input() + @coerceBoolean() + showFileName = false; + + @Input() + fileName: string; + @Output() fileNameChanged = new EventEmitter(); imageUrl: string; file: File; - fileName: string; + safeImageUrl: SafeUrl; @ViewChild('flow', {static: true}) @@ -106,6 +118,7 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, constructor(protected store: Store, private utils: UtilsService, private sanitizer: DomSanitizer, + private imagePipe: ImagePipe, private dialog: DialogService, private translate: TranslateService, private fileSize: FileSizePipe, @@ -161,7 +174,15 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, writeValue(value: string): void { this.imageUrl = value; if (this.imageUrl) { - this.safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(this.imageUrl); + if (this.processImageApiLink) { + this.imagePipe.transform(this.imageUrl, {preview: true, ignoreLoadingImage: true}).subscribe( + (res) => { + this.safeImageUrl = res; + } + ); + } else { + this.safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(this.imageUrl); + } } else { this.safeImageUrl = null; } diff --git a/ui-ngx/src/app/shared/pipe/image.pipe.ts b/ui-ngx/src/app/shared/pipe/image.pipe.ts index 24f91c500d..6cad747676 100644 --- a/ui-ngx/src/app/shared/pipe/image.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/image.pipe.ts @@ -25,6 +25,10 @@ const LOADING_IMAGE_DATA_URI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS 'LTgiPz4KPHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAw' + 'IDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPgo='; +export interface UrlHolder { + url?: string; +} + @Pipe({ name: 'image' }) @@ -33,10 +37,11 @@ export class ImagePipe implements PipeTransform { constructor(private imageService: ImageService, private sanitizer: DomSanitizer) { } - transform(url: string, args?: any): Observable { + transform(urlData: string | UrlHolder, args?: any): Observable { const ignoreLoadingImage = !!args?.ignoreLoadingImage; const asString = !!args?.asString; const image$ = ignoreLoadingImage ? new Subject() : new BehaviorSubject(LOADING_IMAGE_DATA_URI); + const url = (typeof urlData === 'string') ? urlData : urlData?.url; if (isDefinedAndNotNull(url)) { const preview = !!args?.preview; this.imageService.resolveImageUrl(url, preview, asString).subscribe((imageUrl) => { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 98e7334e58..14e0beaff1 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2958,6 +2958,7 @@ "import-image": "Import image", "upload-image": "Upload image", "edit-image": "Edit image", + "image-details": "Image details", "no-images": "No images found", "delete-image": "Delete image", "delete-image-title": "Are you sure you want to delete image '{{imageTitle}}'?", @@ -2966,7 +2967,8 @@ "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-preview": "Image preview", + "update-image": "Update image" }, "image-input": { "drop-images-or": "Drag and drop an images or",