From a2c613c38f7c691ecd636a2ec08880d7f3db2aa4 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Sat, 23 Mar 2024 22:35:47 +0100 Subject: [PATCH] cropper & imageservice --- bizmatch-server/src/file/file.service.ts | 6 +- bizmatch/package.json | 4 +- .../components/cropper/cropper.component.html | 16 + .../components/cropper/cropper.component.scss | 356 ++++++++++++++++++ .../components/cropper/cropper.component.ts | 179 +++++++++ .../app/interceptors/loading.interceptor.ts | 15 - .../pages/listings/listings.component.html | 2 +- .../edit-listing/edit-listing.component.html | 37 +- .../edit-listing/edit-listing.component.ts | 56 ++- bizmatch/src/app/services/image.service.ts | 23 ++ bizmatch/src/app/services/loading.service.ts | 6 +- 11 files changed, 651 insertions(+), 49 deletions(-) create mode 100644 bizmatch/src/app/components/cropper/cropper.component.html create mode 100644 bizmatch/src/app/components/cropper/cropper.component.scss create mode 100644 bizmatch/src/app/components/cropper/cropper.component.ts create mode 100644 bizmatch/src/app/services/image.service.ts diff --git a/bizmatch-server/src/file/file.service.ts b/bizmatch-server/src/file/file.service.ts index 36433b0..609e7f8 100644 --- a/bizmatch-server/src/file/file.service.ts +++ b/bizmatch-server/src/file/file.service.ts @@ -96,7 +96,7 @@ export class FileService { let quality = 50; // AVIF kann mit niedrigeren Qualitätsstufen gute Ergebnisse erzielen let output; let start = Date.now(); - do { + // do { output = await sharp(buffer) .resize({ width: 1500 }) .avif({ quality }) // Verwende AVIF @@ -106,10 +106,10 @@ export class FileService { if (output.byteLength > maxSize) { quality -= 5; // Justiere Qualität in feineren Schritten } - } while (output.byteLength > maxSize && quality > 0); + // } while (output.byteLength > maxSize && quality > 0); + await sharp(output).toFile(`${directory}/${imageName}.avif`); // Ersetze Dateierweiterung let timeTaken = Date.now() - start; this.logger.info(`Quality: ${quality} - Time: ${timeTaken} milliseconds`) - await sharp(output).toFile(`${directory}/${imageName}.avif`); // Ersetze Dateierweiterung } } diff --git a/bizmatch/package.json b/bizmatch/package.json index 891f92e..625da45 100644 --- a/bizmatch/package.json +++ b/bizmatch/package.json @@ -28,7 +28,9 @@ "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@types/uuid": "^9.0.8", + "angular-cropperjs": "^14.0.1", "browser-bunyan": "^1.8.0", + "cropperjs": "^1.6.1", "express": "^4.18.2", "jwt-decode": "^4.0.0", "keycloak-js": "^23.0.7", @@ -58,4 +60,4 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.3.3" } -} \ No newline at end of file +} diff --git a/bizmatch/src/app/components/cropper/cropper.component.html b/bizmatch/src/app/components/cropper/cropper.component.html new file mode 100644 index 0000000..61d420e --- /dev/null +++ b/bizmatch/src/app/components/cropper/cropper.component.html @@ -0,0 +1,16 @@ + +
+ + +
+
+
+ + +
{{ loadImageErrorText }}
+ + +
+ image +
+
\ No newline at end of file diff --git a/bizmatch/src/app/components/cropper/cropper.component.scss b/bizmatch/src/app/components/cropper/cropper.component.scss new file mode 100644 index 0000000..18ea4aa --- /dev/null +++ b/bizmatch/src/app/components/cropper/cropper.component.scss @@ -0,0 +1,356 @@ +:host { + display: block +} + +.cropper img { + max-width: 100%; + max-height: 100%; + height: auto; +} + +.cropper-wrapper { + position: relative; + min-height: 80px +} + +.cropper-wrapper .loading-block { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100% +} + +.cropper-wrapper .loading-block .spinner { + width: 31px; + height: 31px; + margin: 0 auto; + border: 2px solid rgba(97, 100, 193, .98); + border-radius: 50%; + border-left-color: transparent; + border-right-color: transparent; + -webkit-animation: cssload-spin 425ms infinite linear; + position: absolute; + top: calc(50% - 15px); + left: calc(50% - 15px); + animation: cssload-spin 425ms infinite linear +} + +@-webkit-keyframes cssload-spin { + to { + -webkit-transform: rotate(360deg); + transform: rotate(360deg) + } +} + +@keyframes cssload-spin { + to { + -webkit-transform: rotate(360deg); + transform: rotate(360deg) + } +} + +/*! + * Cropper.js v1.4.1 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2018-07-15T09:54:43.167Z + */ + +.cropper-container { + direction: ltr; + font-size: 0; + line-height: 0; + position: relative; + -ms-touch-action: none; + touch-action: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.cropper-container img { + display: block; + height: 100%; + image-orientation: 0deg; + max-height: none !important; + max-width: none !important; + min-height: 0 !important; + min-width: 0 !important; + width: 100%; +} + +.cropper-wrap-box, +.cropper-canvas, +.cropper-drag-box, +.cropper-crop-box, +.cropper-modal { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.cropper-wrap-box, +.cropper-canvas { + overflow: hidden; +} + +.cropper-drag-box { + background-color: #fff; + opacity: 0; +} + +.cropper-modal { + background-color: #000; + opacity: .5; +} + +.cropper-view-box { + display: block; + height: 100%; + outline-color: rgba(51, 153, 255, 0.75); + outline: 1px solid #39f; + overflow: hidden; + width: 100%; +} + +.cropper-dashed { + border: 0 dashed #eee; + display: block; + opacity: .5; + position: absolute; +} + +.cropper-dashed.dashed-h { + border-bottom-width: 1px; + border-top-width: 1px; + height: calc(100% / 3); + left: 0; + top: calc(100% / 3); + width: 100%; +} + +.cropper-dashed.dashed-v { + border-left-width: 1px; + border-right-width: 1px; + height: 100%; + left: calc(100% / 3); + top: 0; + width: calc(100% / 3); +} + +.cropper-center { + display: block; + height: 0; + left: 50%; + opacity: .75; + position: absolute; + top: 50%; + width: 0; +} + +.cropper-center:before, +.cropper-center:after { + background-color: #eee; + content: ' '; + display: block; + position: absolute; +} + +.cropper-center:before { + height: 1px; + left: -3px; + top: 0; + width: 7px; +} + +.cropper-center:after { + height: 7px; + left: 0; + top: -3px; + width: 1px; +} + +.cropper-face, +.cropper-line, +.cropper-point { + display: block; + height: 100%; + opacity: .1; + position: absolute; + width: 100%; +} + +.cropper-face { + background-color: #fff; + left: 0; + top: 0; +} + +.cropper-line { + background-color: #39f; +} + +.cropper-line.line-e { + cursor: ew-resize; + right: -3px; + top: 0; + width: 5px; +} + +.cropper-line.line-n { + cursor: ns-resize; + height: 5px; + left: 0; + top: -3px; +} + +.cropper-line.line-w { + cursor: ew-resize; + left: -3px; + top: 0; + width: 5px; +} + +.cropper-line.line-s { + bottom: -3px; + cursor: ns-resize; + height: 5px; + left: 0; +} + +.cropper-point { + background-color: #39f; + height: 5px; + opacity: .75; + width: 5px; +} + +.cropper-point.point-e { + cursor: ew-resize; + margin-top: -3px; + right: -3px; + top: 50%; +} + +.cropper-point.point-n { + cursor: ns-resize; + left: 50%; + margin-left: -3px; + top: -3px; +} + +.cropper-point.point-w { + cursor: ew-resize; + left: -3px; + margin-top: -3px; + top: 50%; +} + +.cropper-point.point-s { + bottom: -3px; + cursor: s-resize; + left: 50%; + margin-left: -3px; +} + +.cropper-point.point-ne { + cursor: nesw-resize; + right: -3px; + top: -3px; +} + +.cropper-point.point-nw { + cursor: nwse-resize; + left: -3px; + top: -3px; +} + +.cropper-point.point-sw { + bottom: -3px; + cursor: nesw-resize; + left: -3px; +} + +.cropper-point.point-se { + bottom: -3px; + cursor: nwse-resize; + height: 20px; + opacity: 1; + right: -3px; + width: 20px; +} + +@media (min-width: 768px) { + .cropper-point.point-se { + height: 15px; + width: 15px; + } +} + +@media (min-width: 992px) { + .cropper-point.point-se { + height: 10px; + width: 10px; + } +} + +@media (min-width: 1200px) { + .cropper-point.point-se { + height: 5px; + opacity: .75; + width: 5px; + } +} + +.cropper-point.point-se:before { + background-color: #39f; + bottom: -50%; + content: ' '; + display: block; + height: 200%; + opacity: 0; + position: absolute; + right: -50%; + width: 200%; +} + +.cropper-invisible { + opacity: 0; +} + +.cropper-bg { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC'); +} + +.cropper-hide { + display: block; + height: 0; + position: absolute; + width: 0; +} + +.cropper-hidden { + display: none !important; +} + +.cropper-move { + cursor: move; +} + +.cropper-crop { + cursor: crosshair; +} + +.cropper-disabled .cropper-drag-box, +.cropper-disabled .cropper-face, +.cropper-disabled .cropper-line, +.cropper-disabled .cropper-point { + cursor: not-allowed; +} \ No newline at end of file diff --git a/bizmatch/src/app/components/cropper/cropper.component.ts b/bizmatch/src/app/components/cropper/cropper.component.ts new file mode 100644 index 0000000..034f6c0 --- /dev/null +++ b/bizmatch/src/app/components/cropper/cropper.component.ts @@ -0,0 +1,179 @@ +import { CommonModule } from "@angular/common"; +import { + Component, + OnInit, + ViewEncapsulation, + ElementRef, + ViewChild, + Input, + EventEmitter, + Output, + OnDestroy, +} from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { BrowserModule } from "@angular/platform-browser"; +import Cropper from "cropperjs"; + +export interface ImageCropperSetting { + width: number; + height: number; +} + +export interface ImageCropperResult { + imageData: Cropper.ImageData; + cropData: Cropper.CropBoxData; + blob?: Blob; + dataUrl?: string; +} + +@Component({ + selector: 'app-cropper', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './cropper.component.html', + styleUrl: './cropper.component.scss' +}) +export class SCropperComponent implements OnInit, OnDestroy { + @ViewChild("image", { static: true }) image: ElementRef; + + @Input() imageUrl: any; + @Input() settings: ImageCropperSetting; + @Input() cropbox: Cropper.CropBoxData; + @Input() loadImageErrorText: string; + @Input() cropperOptions: any = {}; + + @Output() export = new EventEmitter(); + @Output() ready = new EventEmitter(); + + public isLoading: boolean = true; + public cropper: Cropper; + public imageElement: HTMLImageElement; + public loadError: any; + + constructor() {} + + ngOnInit() {} + + ngOnDestroy() { + if (this.cropper) { + this.cropper.destroy(); + this.cropper = null; + } + } + + /** + * Image loaded + * @param ev + */ + imageLoaded(ev: Event) { + // + // Unset load error state + this.loadError = false; + + // + // Setup image element + const image = ev.target as HTMLImageElement; + this.imageElement = image; + + // + // Add crossOrigin? + if (this.cropperOptions.checkCrossOrigin) + image.crossOrigin = "anonymous"; + + // + // Image on ready event + image.addEventListener("ready", () => { + // + // Emit ready + this.ready.emit(true); + + // + // Unset loading state + this.isLoading = false; + + // + // Validate cropbox existance + if (this.cropbox) { + // + // Set cropbox data + this.cropper.setCropBoxData(this.cropbox); + } + }); + + // + // Setup aspect ratio according to settings + let aspectRatio = NaN; + this.settings={width:300,height:100} + if (this.settings) { + const { width, height } = this.settings; + aspectRatio = width / height; + } + + // + // Set crop options + // extend default with custom config + this.cropperOptions = { + ...{ + aspectRatio, + checkCrossOrigin: true, + }, + ...this.cropperOptions, + }; + + // + // Set cropperjs + if (this.cropper) { + this.cropper.destroy(); + this.cropper = undefined; + } + this.cropper = new Cropper(image, this.cropperOptions); + } + + /** + * Image load error + * @param event + */ + imageLoadError(event: any) { + // + // Set load error state + this.loadError = true; + + // + // Unset loading state + this.isLoading = false; + } + + /** + * Export canvas + * @param base64 + */ + exportCanvas(base64?: any) { + // + // Get and set image, crop and canvas data + const imageData = this.cropper.getImageData(); + const cropData = this.cropper.getCropBoxData(); + const canvas = this.cropper.getCroppedCanvas(); + const data = { imageData, cropData }; + + // + // Create promise to resolve canvas data + const promise = new Promise((resolve) => { + // + // Validate base64 + if (base64) { + // + // Resolve promise with dataUrl + return resolve({ + dataUrl: canvas.toDataURL("image/png"), + }); + } + canvas.toBlob((blob) => resolve({ blob })); + }); + + // + // Emit export data when promise is ready + promise.then((res: any) => { + this.export.emit({ ...data, ...res }); + }); + } +} diff --git a/bizmatch/src/app/interceptors/loading.interceptor.ts b/bizmatch/src/app/interceptors/loading.interceptor.ts index bbf87e3..a59f504 100644 --- a/bizmatch/src/app/interceptors/loading.interceptor.ts +++ b/bizmatch/src/app/interceptors/loading.interceptor.ts @@ -4,21 +4,6 @@ import { Observable, tap } from 'rxjs'; import { v4 } from 'uuid'; import { LoadingService } from '../services/loading.service'; -// export const loadingInterceptor: HttpInterceptorFn = (req, next) => { -// const loadingService = inject(LoadingService); - -// const requestId = `HTTP-${v4()}`; - -// loadingService.startLoading(requestId); - -// return next(req).pipe( -// tap({ -// finalize: () => loadingService.stopLoading(requestId), -// error: () => loadingService.stopLoading(requestId), -// complete: () => loadingService.stopLoading(requestId), -// }) -// ); -// }; @Injectable() export class LoadingInterceptor implements HttpInterceptor { constructor(private loadingService:LoadingService) { } diff --git a/bizmatch/src/app/pages/listings/listings.component.html b/bizmatch/src/app/pages/listings/listings.component.html index 06172ef..a1401f2 100644 --- a/bizmatch/src/app/pages/listings/listings.component.html +++ b/bizmatch/src/app/pages/listings/listings.component.html @@ -90,7 +90,7 @@
- Image + Image

{{selectOptions.getState(listing.state)}}

diff --git a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.html b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.html index 0a7d9b1..440e631 100644 --- a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.html +++ b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.html @@ -1,9 +1,8 @@ -
-
+
{{mode==='create'?'New':'Edit'}} Listing
@@ -80,7 +79,15 @@
Property Picture - + +
@@ -88,17 +95,14 @@
- +
- +
@@ -190,4 +194,13 @@
-
\ No newline at end of file + + + + + + + + + + diff --git a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.ts b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.ts index 4a77fe3..7bf9edd 100644 --- a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.ts +++ b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, ViewChild } from '@angular/core'; import { ButtonModule } from 'primeng/button'; import { CheckboxModule } from 'primeng/checkbox'; import { InputTextModule } from 'primeng/inputtext'; @@ -29,19 +29,25 @@ import { AutoCompleteCompleteEvent, BusinessListing, CommercialPropertyListing, import { GeoResult, GeoService } from '../../../services/geo.service'; import { InputNumberComponent, InputNumberModule } from '../../../components/inputnumber/inputnumber.component'; import { environment } from '../../../../environments/environment'; -import { FileUploadModule } from 'primeng/fileupload'; +import { FileUpload, FileUploadModule } from 'primeng/fileupload'; import { CarouselModule } from 'primeng/carousel'; import { v4 as uuidv4 } from 'uuid'; - +import { DialogModule } from 'primeng/dialog'; +import { AngularCropperjsModule, CropperComponent } from 'angular-cropperjs'; +import { HttpClient, HttpEventType } from '@angular/common/http'; +import {ImageService} from '../../../services/image.service' +import { LoadingService } from '../../../services/loading.service'; @Component({ selector: 'create-listing', standalone: true, - imports: [SharedModule,ArrayToStringPipe, InputNumberModule,FileUploadModule,CarouselModule], + imports: [SharedModule,ArrayToStringPipe, InputNumberModule,CarouselModule,DialogModule,AngularCropperjsModule,FileUploadModule], providers:[MessageService], templateUrl: './edit-listing.component.html', styleUrl: './edit-listing.component.scss' }) export class EditListingComponent { + @ViewChild(CropperComponent) public angularCropper: CropperComponent; + @ViewChild(FileUpload) public fileUpload: FileUpload; listingCategory:'Business'|'Commercial Property'; category:string; location:string; @@ -50,7 +56,7 @@ export class EditListingComponent { listing:ListingType private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined; user:User; - maxFileSize=1000000; + maxFileSize=3000000; uploadUrl:string; environment=environment; propertyImages:ImageProperty[] @@ -70,14 +76,18 @@ export class EditListingComponent { numVisible: 1, numScroll: 1 } -]; + ]; + imageUrl + config={aspectRatio: 16 / 9} constructor(public selectOptions:SelectOptionsService, private router: Router, private activatedRoute: ActivatedRoute, private listingsService:ListingsService, public userService: UserService, private messageService: MessageService, - private geoService:GeoService){ + private geoService:GeoService, + private imageUploadService: ImageService, + private loadingService:LoadingService){ this.user=this.userService.getUser(); // Abonniere Router-Events, um den aktiven Link zu ermitteln this.router.events.subscribe(event => { @@ -102,6 +112,7 @@ export class EditListingComponent { this.uploadUrl = `${environment.apiBaseUrl}/bizmatch/image/uploadPropertyPicture/${this.listing.id}`; this.propertyImages=await this.listingsService.getPropertyImages(this.listing.id) } + async save(){ sessionStorage.removeItem('uuid') await this.listingsService.save(this.listing,this.listing.listingsCategory); @@ -115,13 +126,30 @@ export class EditListingComponent { this.suggestions = result.map(r=>r.city).slice(0,5); } - setImageToFallback(event: Event) { - (event.target as HTMLImageElement).src = `/assets/images/placeholder.png`; // Pfad zum Platzhalterbild + select(event:any){ + this.imageUrl = URL.createObjectURL(event.files[0]); } - async onUploadPropertyPicture(event:any){ - // (this.listing).images=[]; - // (this.listing).images.push(this.listing.id); - // await this.listingsService.save(this.listing) - this.propertyImages=await this.listingsService.getPropertyImages(this.listing.id) + sendImage(){ + this.imageUrl=null + this.loadingService.startLoading('uploadImage'); + this.angularCropper.cropper.getCroppedCanvas().toBlob(async(blob) => { + + this.imageUploadService.uploadImage(blob).subscribe(async(event) => { + if (event.type === HttpEventType.UploadProgress) { + // Berechne und zeige den Fortschritt basierend auf event.loaded und event.total + const progress = event.total ? event.loaded / event.total : 0; + console.log(`Upload-Fortschritt: ${progress * 100}%`); + // Hier könntest du beispielsweise eine Fortschrittsanzeige aktualisieren + } else if (event.type === HttpEventType.Response) { + console.log('Upload abgeschlossen', event.body); + // Hier könntest du die Ladeanimation ausblenden + this.propertyImages=await this.listingsService.getPropertyImages(this.listing.id) + this.fileUpload.clear(); + this.loadingService.stopLoading('uploadImage'); + } + }, error => console.error('Fehler beim Upload:', error)); + + // this.fileUpload.upload(); + }, 'image/png'); } } diff --git a/bizmatch/src/app/services/image.service.ts b/bizmatch/src/app/services/image.service.ts new file mode 100644 index 0000000..2bf4817 --- /dev/null +++ b/bizmatch/src/app/services/image.service.ts @@ -0,0 +1,23 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class ImageService { + + private uploadUrl = 'http://localhost:3000/bizmatch/image/uploadPropertyPicture/1a4b800e-793c-4c47-b987-7bf634060a4e'; + + constructor(private http: HttpClient) { } + + uploadImage(imageBlob: Blob) { + const formData = new FormData(); + formData.append('file', imageBlob, 'image.png'); + + return this.http.post(this.uploadUrl, formData,{ + // headers: this.headers, + reportProgress: true, + observe: 'events', + }); + } +} diff --git a/bizmatch/src/app/services/loading.service.ts b/bizmatch/src/app/services/loading.service.ts index 0c8190e..6c5d762 100644 --- a/bizmatch/src/app/services/loading.service.ts +++ b/bizmatch/src/app/services/loading.service.ts @@ -12,15 +12,15 @@ export class LoadingService { public isLoading$ = this.loading$.asObservable().pipe( map((loading) => loading.length > 0), - debounceTime(200), + debounceTime(100), distinctUntilChanged(), shareReplay(1) ); - public startLoading(type: string,request:string): void { + public startLoading(type: string,request?:string): void { if (!this.loading$.value.includes(type)) { this.loading$.next(this.loading$.value.concat(type)); - if (request.includes('uploadPropertyPicture')) { + if (type==='uploadImage' || request?.includes('uploadPropertyPicture')) { this.loadingTextSubject.next("Please wait - we're processing your image..."); } else { this.loadingTextSubject.next(null);