From 941e0deb0ac23ee8d3d5e305ad32ef915dad2401 Mon Sep 17 00:00:00 2001 From: rubikscraft Date: Sat, 10 Sep 2022 17:24:18 +0200 Subject: [PATCH] make sure expired images dissapear in the frontend --- backend/src/managers/image/image.module.ts | 2 +- .../app/routes/images/images.component.html | 2 + .../src/app/routes/images/images.component.ts | 57 +++- .../view-speeddial.component.html | 30 ++ .../view-speeddial.component.scss | 1 + .../view-speeddial.component.ts | 179 +++++++++++ .../src/app/routes/view/view.component.html | 45 +-- .../src/app/routes/view/view.component.ts | 291 +++++++----------- frontend/src/app/routes/view/view.module.ts | 8 +- 9 files changed, 390 insertions(+), 225 deletions(-) create mode 100644 frontend/src/app/routes/view/view-speeddial/view-speeddial.component.html create mode 100644 frontend/src/app/routes/view/view-speeddial/view-speeddial.component.scss create mode 100644 frontend/src/app/routes/view/view-speeddial/view-speeddial.component.ts diff --git a/backend/src/managers/image/image.module.ts b/backend/src/managers/image/image.module.ts index fafb256..8060597 100644 --- a/backend/src/managers/image/image.module.ts +++ b/backend/src/managers/image/image.module.ts @@ -34,7 +34,7 @@ export class ImageManagerModule implements OnModuleInit, OnModuleDestroy { this.interval = setInterval( // Run demoManagerService.execute() every interval this.imageManagerCron.bind(this), - 1000 * 60 * 60, + 1000 * 60, ); await this.imageManagerCron(); } diff --git a/frontend/src/app/routes/images/images.component.html b/frontend/src/app/routes/images/images.component.html index d638db2..ebb294b 100644 --- a/frontend/src/app/routes/images/images.component.html +++ b/frontend/src/app/routes/images/images.component.html @@ -5,6 +5,8 @@ +

Your Images

+
diff --git a/frontend/src/app/routes/images/images.component.ts b/frontend/src/app/routes/images/images.component.ts index 765c1d9..a11e29b 100644 --- a/frontend/src/app/routes/images/images.component.ts +++ b/frontend/src/app/routes/images/images.component.ts @@ -4,6 +4,15 @@ import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; import { ImageFileType } from 'picsur-shared/dist/dto/mimes.dto'; import { EImage } from 'picsur-shared/dist/entities/image.entity'; import { HasFailed } from 'picsur-shared/dist/types/failable'; +import { + BehaviorSubject, + filter, + map, + merge, + Observable, + switchMap, + timer +} from 'rxjs'; import { ImageService } from 'src/app/services/api/image.service'; import { UserService } from 'src/app/services/api/user.service'; import { Logger } from 'src/app/services/logger/logger.service'; @@ -18,9 +27,18 @@ import { ErrorService } from 'src/app/util/error-manager/error.service'; export class ImagesComponent implements OnInit { private readonly logger: Logger = new Logger(ImagesComponent.name); - images: EImage[] | null = null; + imagesSub = new BehaviorSubject(null); columns = 1; + public get images() { + const value = this.imagesSub.value; + return ( + value?.filter( + (i) => i.expires_at === null || i.expires_at > new Date(), + ) ?? null + ); + } + page: number = 1; pages: number = 1; @@ -47,6 +65,7 @@ export class ImagesComponent implements OnInit { this.subscribeMobile(); this.subscribeUser(); + this.subscribeImages(); } @AutoUnsubscribe() @@ -64,14 +83,42 @@ export class ImagesComponent implements OnInit { @AutoUnsubscribe() private subscribeUser() { - return this.userService.live.subscribe(async () => { + return this.userService.live.subscribe(async (user) => { + if (user === null) return; + const list = await this.imageService.ListMyImages(24, this.page - 1); if (HasFailed(list)) { return this.logger.error(list.getReason()); } this.pages = list.pages; - this.images = list.results; + this.imagesSub.next(list.results); + }); + } + + @AutoUnsubscribe() + private subscribeImages() { + // Make sure we only get populated images + const filteredImagesSub: Observable = this.imagesSub.pipe( + filter((images) => images !== null), + ) as Observable; + + const mappedImagesSub: Observable = filteredImagesSub.pipe( + // Everytime we get a new array, we want merge a mapping of that array + // In this mapping, each image will emit itself on the expire date + switchMap((images: EImage[]) => + merge( + ...images + .filter((i) => i.expires_at !== null) + .map((i) => timer(i.expires_at!).pipe(map(() => i))), + ), + ), + ) as Observable; + + return mappedImagesSub.subscribe((image) => { + this.imagesSub.next( + this.images?.filter((i) => i.id !== image.id) ?? null, + ); }); } @@ -109,7 +156,9 @@ export class ImagesComponent implements OnInit { return this.errorService.showFailure(result, this.logger); this.errorService.success('Image deleted'); - this.images = this.images?.filter((i) => i.id !== image.id) ?? null; + this.imagesSub.next( + this.images?.filter((i) => i.id !== image.id) ?? null, + ); } } diff --git a/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.html b/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.html new file mode 100644 index 0000000..4a02afc --- /dev/null +++ b/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.html @@ -0,0 +1,30 @@ + + + + + + diff --git a/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.scss b/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.scss new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.scss @@ -0,0 +1 @@ + diff --git a/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.ts b/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.ts new file mode 100644 index 0000000..cd6125b --- /dev/null +++ b/frontend/src/app/routes/view/view-speeddial/view-speeddial.component.ts @@ -0,0 +1,179 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Router } from '@angular/router'; +import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; +import { ImageMetaResponse } from 'picsur-shared/dist/dto/api/image.dto'; +import { ImageFileType } from 'picsur-shared/dist/dto/mimes.dto'; +import { Permission } from 'picsur-shared/dist/dto/permissions.enum'; +import { EImage } from 'picsur-shared/dist/entities/image.entity'; +import { EUser } from 'picsur-shared/dist/entities/user.entity'; +import { HasFailed } from 'picsur-shared/dist/types'; +import { ImageService } from 'src/app/services/api/image.service'; +import { PermissionService } from 'src/app/services/api/permission.service'; +import { UserService } from 'src/app/services/api/user.service'; +import { Logger } from 'src/app/services/logger/logger.service'; +import { DialogService } from 'src/app/util/dialog-manager/dialog.service'; +import { DownloadService } from 'src/app/util/download-manager/download.service'; +import { ErrorService } from 'src/app/util/error-manager/error.service'; +import { UtilService } from 'src/app/util/util.service'; +import { + CustomizeDialogComponent, + CustomizeDialogData +} from '../customize-dialog/customize-dialog.component'; +import { + EditDialogComponent, + EditDialogData +} from '../edit-dialog/edit-dialog.component'; + +@Component({ + selector: 'view-speeddial', + templateUrl: './view-speeddial.component.html', + styleUrls: ['./view-speeddial.component.scss'], +}) +export class ViewSpeeddialComponent implements OnInit { + private readonly logger = new Logger(ViewSpeeddialComponent.name); + + public canManage: boolean = false; + + @Input() public metadata: ImageMetaResponse | null = null; + @Output() public metadataChange = new EventEmitter(); + + @Input() public selectedFormat: string = ImageFileType.JPEG; + + public get image(): EImage | null { + return this.metadata?.image ?? null; + } + + public get user(): EUser | null { + return this.metadata?.user ?? null; + } + + constructor( + private readonly permissionService: PermissionService, + private readonly downloadService: DownloadService, + private readonly errorService: ErrorService, + private readonly dialogService: DialogService, + private readonly imageService: ImageService, + private readonly utilService: UtilService, + private readonly userService: UserService, + private readonly router: Router, + ) {} + + ngOnInit(): void { + this.subscribePermissions(); + } + + @AutoUnsubscribe() + private subscribePermissions() { + this.updatePermissions(this.permissionService.snapshot); + return this.permissionService.live.subscribe( + this.updatePermissions.bind(this), + ); + } + + private updatePermissions(permissions: string[]) { + if (permissions.includes(Permission.ImageAdmin)) { + this.canManage = true; + return; + } + + if (this.user === null) return; + + if ( + permissions.includes(Permission.ImageManage) && + this.user.id === this.userService.snapshot?.id + ) { + this.canManage = true; + return; + } + + this.canManage = false; + } + + download() { + if (this.image === null) return; + + this.downloadService.downloadFile( + this.imageService.CreateImageLinksFromID( + this.image?.id, + this.selectedFormat, + ).source, + ); + } + + share() { + if (this.image === null) return; + + this.downloadService.shareFile( + this.imageService.CreateImageLinksFromID( + this.image?.id, + this.selectedFormat, + ).source, + ); + } + + async deleteImage() { + if (this.image === null) return; + + const pressedButton = await this.dialogService.showDialog({ + title: `Are you sure you want to delete the image?`, + description: 'This action cannot be undone.', + buttons: [ + { + name: 'cancel', + text: 'Cancel', + }, + { + color: 'warn', + name: 'delete', + text: 'Delete', + }, + ], + }); + + if (pressedButton === 'delete') { + const result = await this.imageService.DeleteImage(this.image.id); + if (HasFailed(result)) + return this.errorService.showFailure(result, this.logger); + + this.errorService.success('Image deleted'); + + this.router.navigate(['/']); + } + } + + async customize() { + if (this.image === null) return; + + const options: CustomizeDialogData = { + imageID: this.image.id, + selectedFormat: this.selectedFormat, + formatOptions: this.utilService.getBaseFormatOptions(), + }; + + if (options.selectedFormat === 'original') { + options.selectedFormat = ImageFileType.JPEG; + } + + await this.dialogService.showCustomDialog( + CustomizeDialogComponent, + options, + ); + } + + async editImage() { + if (this.image === null) return; + + const options: EditDialogData = { + image: { ...this.image }, + }; + + const res: EImage | null = await this.dialogService.showCustomDialog( + EditDialogComponent, + options, + ); + + if (res && this.metadata !== null) { + this.metadataChange.emit({ ...this.metadata, image: res }); + } + } +} diff --git a/frontend/src/app/routes/view/view.component.html b/frontend/src/app/routes/view/view.component.html index 029561f..a9a1b16 100644 --- a/frontend/src/app/routes/view/view.component.html +++ b/frontend/src/app/routes/view/view.component.html @@ -2,14 +2,13 @@

- {{ image?.file_name ?? 'image' | truncate }} uploaded by - {{ imageUser?.username }} + {{ image?.file_name ?? 'image' | truncate }}

- Uploaded {{ image.created | amTimeAgo }} + Uploaded {{ image.created | amTimeAgo }} by {{ user?.username }} {{ image.expires_at === null ? '' @@ -29,10 +28,7 @@
Image Format - + {{ format.value }} @@ -59,33 +55,8 @@

- - - - - - + diff --git a/frontend/src/app/routes/view/view.component.ts b/frontend/src/app/routes/view/view.component.ts index a023803..fa884ec 100644 --- a/frontend/src/app/routes/view/view.component.ts +++ b/frontend/src/app/routes/view/view.component.ts @@ -1,249 +1,166 @@ -import { Component, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + OnInit +} from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; +import { ImageMetaResponse } from 'picsur-shared/dist/dto/api/image.dto'; import { ImageLinks } from 'picsur-shared/dist/dto/image-links.class'; import { AnimFileType, - FileType, ImageFileType, SupportedFileTypeCategory } from 'picsur-shared/dist/dto/mimes.dto'; -import { Permission } from 'picsur-shared/dist/dto/permissions.enum'; - import { EImage } from 'picsur-shared/dist/entities/image.entity'; import { EUser } from 'picsur-shared/dist/entities/user.entity'; -import { HasFailed, HasSuccess } from 'picsur-shared/dist/types'; + +import { HasFailed } from 'picsur-shared/dist/types'; import { UUIDRegex } from 'picsur-shared/dist/util/common-regex'; import { ParseFileType } from 'picsur-shared/dist/util/parse-mime'; +import { Subscription, timer } from 'rxjs'; import { ImageService } from 'src/app/services/api/image.service'; -import { PermissionService } from 'src/app/services/api/permission.service'; -import { UserService } from 'src/app/services/api/user.service'; import { Logger } from 'src/app/services/logger/logger.service'; -import { DialogService } from 'src/app/util/dialog-manager/dialog.service'; -import { DownloadService } from 'src/app/util/download-manager/download.service'; import { ErrorService } from 'src/app/util/error-manager/error.service'; import { UtilService } from 'src/app/util/util.service'; -import { - CustomizeDialogComponent, - CustomizeDialogData -} from './customize-dialog/customize-dialog.component'; -import { - EditDialogComponent, - EditDialogData -} from './edit-dialog/edit-dialog.component'; - @Component({ templateUrl: './view.component.html', styleUrls: ['./view.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ViewComponent implements OnInit { +export class ViewComponent implements OnInit, OnDestroy { private readonly logger = new Logger(ViewComponent.name); + private expires_timeout: Subscription | null = null; + constructor( private readonly route: ActivatedRoute, private readonly router: Router, private readonly imageService: ImageService, - private readonly permissionService: PermissionService, - private readonly userService: UserService, - private readonly errorService: ErrorService, - private readonly downloadService: DownloadService, - private readonly dialogService: DialogService, private readonly utilService: UtilService, + private readonly changeDetector: ChangeDetectorRef, ) {} - private id: string; - private hasOriginal: boolean = false; - private masterFileType: FileType = { - identifier: ImageFileType.JPEG, - category: SupportedFileTypeCategory.Image, - }; - private currentSelectedFormat: string = ImageFileType.JPEG; + private id: string = ''; + public metadata: ImageMetaResponse | null = null; + public set OnMetadata(metadata: ImageMetaResponse) { + this.metadata = metadata; + this.subscribeTimeout(metadata.image.expires_at); + + this.changeDetector.markForCheck(); + } public formatOptions: { value: string; key: string; }[] = []; - public setSelectedFormat: string = ImageFileType.JPEG; + public selectedFormat: string = ImageFileType.JPEG; - public previewLink = ''; - public imageLinks = new ImageLinks(); + public get image(): EImage | null { + return this.metadata?.image ?? null; + } - public image: EImage | null = null; - public imageUser: EUser | null = null; + public get user(): EUser | null { + return this.metadata?.user ?? null; + } - public canManage: boolean = false; + public get hasOriginal(): boolean { + if (this.metadata === null) return false; + return this.metadata.fileTypes.original !== undefined; + } - async ngOnInit() { - this.subscribePermissions(); - - // Extract and verify params - const params = this.route.snapshot.paramMap; - - this.id = params.get('id') ?? ''; - if (!UUIDRegex.test(this.id)) { - return this.errorService.quitError('Invalid image link', this.logger); - } - - // Get metadata - const metadata = await this.imageService.GetImageMeta(this.id); - if (HasFailed(metadata)) - return this.errorService.quitFailure(metadata, this.logger); + public get previewLink(): string { + if (this.metadata === null) return ''; // Get width of screen in pixels const width = window.innerWidth * window.devicePixelRatio; - // Populate fields with metadata - this.previewLink = - this.imageService.GetImageURL(this.id, metadata.fileTypes.master) + - (width > 1 ? `?width=${width}&shrinkonly=yes` : ''); + return ( + this.imageService.GetImageURL(this.id, this.metadata.fileTypes.master) + + (width > 1 ? `?width=${width}&shrinkonly=yes` : '') + ); + } - this.hasOriginal = metadata.fileTypes.original !== undefined; + public get imageLinks(): ImageLinks { + const format = this.selectedFormat; + return this.imageService.CreateImageLinksFromID( + this.id, + format === 'original' ? null : format, + ); + } - this.imageUser = metadata.user; - this.image = metadata.image; + async ngOnInit() { + // Extract and verify params + { + const params = this.route.snapshot.paramMap; + + this.id = params.get('id') ?? ''; + if (!UUIDRegex.test(this.id)) { + return this.errorService.quitError('Invalid image link', this.logger); + } + } + + // Get metadata + { + const metadata = await this.imageService.GetImageMeta(this.id); + if (HasFailed(metadata)) + return this.errorService.quitFailure(metadata, this.logger); + + if (metadata.image.expires_at !== null) { + if (metadata.image.expires_at <= new Date()) + return this.errorService.quitWarn('Image not found', this.logger); + + this.subscribeTimeout(metadata.image.expires_at); + } + + this.metadata = metadata; + } // Populate default selected format - const masterFiletype = ParseFileType(metadata.fileTypes.master); - if (HasSuccess(masterFiletype)) { - this.masterFileType = masterFiletype; + { + let masterFiletype = ParseFileType(this.metadata.fileTypes.master); + if (HasFailed(masterFiletype)) { + masterFiletype = { + identifier: ImageFileType.JPEG, + category: SupportedFileTypeCategory.Image, + }; + } + + switch (masterFiletype.category) { + case SupportedFileTypeCategory.Image: + this.selectedFormat = ImageFileType.JPEG; + break; + case SupportedFileTypeCategory.Animation: + this.selectedFormat = AnimFileType.GIF; + break; + default: + this.selectedFormat = this.metadata.fileTypes.master; + break; + } } - if (this.masterFileType.category === SupportedFileTypeCategory.Image) { - this.setSelectedFormat = ImageFileType.JPEG; - } else if ( - this.masterFileType.category === SupportedFileTypeCategory.Animation - ) { - this.setSelectedFormat = AnimFileType.GIF; - } else { - this.setSelectedFormat = metadata.fileTypes.master; - } - - this.selectedFormat(this.setSelectedFormat); this.updateFormatOptions(); - this.updatePermissions(); + this.changeDetector.markForCheck(); } - selectedFormat(format: string) { - this.currentSelectedFormat = format; - if (format === 'original') { - this.imageLinks = this.imageService.CreateImageLinksFromID(this.id, null); - } else { - this.imageLinks = this.imageService.CreateImageLinksFromID( - this.id, - format, - ); - } - } - - download() { - this.downloadService.downloadFile(this.imageLinks.source); - } - - share() { - this.downloadService.shareFile(this.imageLinks.source); + ngOnDestroy() { + if (this.expires_timeout !== null) this.expires_timeout.unsubscribe(); } goBackHome() { this.router.navigate(['/']); } - async deleteImage() { - const pressedButton = await this.dialogService.showDialog({ - title: `Are you sure you want to delete the image?`, - description: 'This action cannot be undone.', - buttons: [ - { - name: 'cancel', - text: 'Cancel', - }, - { - color: 'warn', - name: 'delete', - text: 'Delete', - }, - ], - }); - - if (pressedButton === 'delete') { - const result = await this.imageService.DeleteImage(this.id); - if (HasFailed(result)) - return this.errorService.showFailure(result, this.logger); - - this.errorService.success('Image deleted'); - - this.router.navigate(['/']); - } - } - - async customize() { - const options: CustomizeDialogData = { - imageID: this.id, - selectedFormat: this.currentSelectedFormat, - formatOptions: this.utilService.getBaseFormatOptions(), - }; - - if (options.selectedFormat === 'original') { - options.selectedFormat = this.masterFileType.identifier; - } - - await this.dialogService.showCustomDialog( - CustomizeDialogComponent, - options, - ); - } - - async editImage() { - if (this.image === null) return; - - const options: EditDialogData = { - image: { ...this.image }, - }; - - const res: EImage | null = await this.dialogService.showCustomDialog( - EditDialogComponent, - options, - ); - - if (res) { - this.image = res; - } - } - - @AutoUnsubscribe() - private subscribePermissions() { - return this.permissionService.live.subscribe( - this.updatePermissions.bind(this), - ); - } - - private updatePermissions() { - const permissions = this.permissionService.snapshot; - if (permissions.includes(Permission.ImageAdmin)) { - this.canManage = true; - return; - } - - if (this.imageUser === null) return; - - if ( - permissions.includes(Permission.ImageManage) && - this.imageUser.id === this.userService.snapshot?.id - ) { - this.canManage = true; - return; - } - - this.canManage = false; - } - private updateFormatOptions() { let newOptions: { value: string; key: string; }[] = []; + if (this.hasOriginal) { newOptions.push({ value: 'Original', @@ -255,4 +172,14 @@ export class ViewComponent implements OnInit { this.formatOptions = newOptions; } + + private subscribeTimeout(expires_at: Date | null) { + if (this.expires_timeout !== null) this.expires_timeout.unsubscribe(); + + if (expires_at === null) return; + + this.expires_timeout = timer(expires_at).subscribe(() => { + this.errorService.quitWarn('Image expired', this.logger); + }); + } } diff --git a/frontend/src/app/routes/view/view.module.ts b/frontend/src/app/routes/view/view.module.ts index 9c2f82b..ec148b9 100644 --- a/frontend/src/app/routes/view/view.module.ts +++ b/frontend/src/app/routes/view/view.module.ts @@ -18,11 +18,17 @@ import { DownloadManagerModule } from 'src/app/util/download-manager/dialog-mana import { ErrorManagerModule } from 'src/app/util/error-manager/error-manager.module'; import { CustomizeDialogComponent } from './customize-dialog/customize-dialog.component'; import { EditDialogComponent } from './edit-dialog/edit-dialog.component'; +import { ViewSpeeddialComponent } from './view-speeddial/view-speeddial.component'; import { ViewComponent } from './view.component'; import { ViewRoutingModule } from './view.routing.module'; @NgModule({ - declarations: [ViewComponent, CustomizeDialogComponent, EditDialogComponent], + declarations: [ + ViewComponent, + ViewSpeeddialComponent, + CustomizeDialogComponent, + EditDialogComponent, + ], imports: [ CommonModule, ErrorManagerModule,