diff --git a/booklore-ui/src/app/book/components/additional-file-uploader/additional-file-uploader.component.html b/booklore-ui/src/app/book/components/additional-file-uploader/additional-file-uploader.component.html new file mode 100644 index 000000000..519b600b9 --- /dev/null +++ b/booklore-ui/src/app/book/components/additional-file-uploader/additional-file-uploader.component.html @@ -0,0 +1,129 @@ +
+ +
+

{{ book.metadata?.title || 'Unknown Title' }}

+

{{ book.filePath }}

+
+ + + + + +
+ + +
+ + + + +
+
+ + + +
+
+
+ +
+ @if (files?.length > 0) { +
+
+
+ @for (uploadFile of this.files; track uploadFile; let i = $index) { +
+
+ + + {{ uploadFile.file.name }} + +
{{ formatSize(uploadFile.file.size) }}
+
+ @switch (uploadFile.status) { + @case ('Pending') { + + } + @case ('Uploading') { + + + } + @case ('Uploaded') { + + + } + @case ('Failed') { + + + } + } +
+ } +
+
+
+ } +
+
+ + +
+ +

Drag and drop a file here to upload.

+

+ Upload an additional file for {{ book.metadata?.title || 'this book' }}. +

+
+
+
+
\ No newline at end of file diff --git a/booklore-ui/src/app/book/components/additional-file-uploader/additional-file-uploader.component.scss b/booklore-ui/src/app/book/components/additional-file-uploader/additional-file-uploader.component.scss new file mode 100644 index 000000000..0e77a63f8 --- /dev/null +++ b/booklore-ui/src/app/book/components/additional-file-uploader/additional-file-uploader.component.scss @@ -0,0 +1,3 @@ +:host ::ng-deep .p-fileupload-content p-progressbar { + display: none !important; +} \ No newline at end of file diff --git a/booklore-ui/src/app/book/components/additional-file-uploader/additional-file-uploader.component.ts b/booklore-ui/src/app/book/components/additional-file-uploader/additional-file-uploader.component.ts new file mode 100644 index 000000000..e309dffb1 --- /dev/null +++ b/booklore-ui/src/app/book/components/additional-file-uploader/additional-file-uploader.component.ts @@ -0,0 +1,189 @@ +import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { Select } from 'primeng/select'; +import { Button } from 'primeng/button'; +import { FileSelectEvent, FileUpload, FileUploadHandlerEvent } from 'primeng/fileupload'; +import { Badge } from 'primeng/badge'; +import { Tooltip } from 'primeng/tooltip'; +import { Subject, takeUntil } from 'rxjs'; +import { BookService } from '../../service/book.service'; +import { AppSettingsService } from '../../../core/service/app-settings.service'; +import { Book, AdditionalFileType } from '../../model/book.model'; +import { MessageService } from 'primeng/api'; +import { filter, take } from 'rxjs/operators'; + +interface FileTypeOption { + label: string; + value: AdditionalFileType; +} + +interface UploadingFile { + file: File; + status: 'Pending' | 'Uploading' | 'Uploaded' | 'Failed'; + errorMessage?: string; +} + +@Component({ + selector: 'app-additional-file-uploader', + standalone: true, + imports: [ + CommonModule, + FormsModule, + Select, + Button, + FileUpload, + Badge, + Tooltip + ], + templateUrl: './additional-file-uploader.component.html', + styleUrls: ['./additional-file-uploader.component.scss'] +}) +export class AdditionalFileUploaderComponent implements OnInit, OnDestroy { + book!: Book; + files: UploadingFile[] = []; + fileType: AdditionalFileType = AdditionalFileType.ALTERNATIVE_FORMAT; + description: string = ''; + isUploading = false; + maxFileSizeBytes?: number; + + fileTypeOptions: FileTypeOption[] = [ + { label: 'Alternative Format', value: AdditionalFileType.ALTERNATIVE_FORMAT }, + { label: 'Supplementary File', value: AdditionalFileType.SUPPLEMENTARY } + ]; + + private destroy$ = new Subject(); + + constructor( + private dialogRef: DynamicDialogRef, + private config: DynamicDialogConfig, + private bookService: BookService, + private appSettingsService: AppSettingsService, + private messageService: MessageService, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.book = this.config.data.book; + this.appSettingsService.appSettings$ + .pipe( + filter(settings => settings != null), + take(1) + ) + .subscribe(settings => { + if (settings) { + this.maxFileSizeBytes = (settings.maxFileUploadSizeInMb || 100) * 1024 * 1024; + } + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + hasPendingFiles(): boolean { + return this.files.some(f => f.status === 'Pending'); + } + + filesPresent(): boolean { + return this.files.length > 0; + } + + choose(_event: any, chooseCallback: () => void): void { + chooseCallback(); + } + + onClear(clearCallback: () => void): void { + clearCallback(); + this.files = []; + } + + onFilesSelect(event: FileSelectEvent): void { + const newFiles = event.currentFiles; + // Only take the first file for single file upload + if (newFiles.length > 0) { + const file = newFiles[0]; + this.files = [{ + file, + status: 'Pending' + }]; + } + } + + onRemoveTemplatingFile(_event: any, _file: File, removeFileCallback: (event: any, index: number) => void, index: number): void { + removeFileCallback(_event, index); + } + + uploadEvent(uploadCallback: () => void): void { + uploadCallback(); + } + + uploadFiles(event: FileUploadHandlerEvent): void { + const filesToUpload = this.files.filter(f => f.status === 'Pending'); + + if (filesToUpload.length === 0) return; + + this.isUploading = true; + let pending = filesToUpload.length; + + for (const uploadFile of filesToUpload) { + uploadFile.status = 'Uploading'; + + this.bookService.uploadAdditionalFile( + this.book.id, + uploadFile.file, + this.fileType, + this.description || undefined + ).subscribe({ + next: () => { + uploadFile.status = 'Uploaded'; + if (--pending === 0) { + this.isUploading = false; + this.dialogRef.close({ success: true }); + } + }, + error: (err) => { + uploadFile.status = 'Failed'; + uploadFile.errorMessage = err?.error?.message || 'Upload failed due to unknown error.'; + console.error('Upload failed for', uploadFile.file.name, err); + if (--pending === 0) { + this.isUploading = false; + } + } + }); + } + } + + isChooseDisabled(): boolean { + return this.isUploading; + } + + isUploadDisabled(): boolean { + return this.isChooseDisabled() || !this.filesPresent() || !this.hasPendingFiles(); + } + + formatSize(bytes: number): string { + const k = 1024; + const dm = 2; + if (bytes < k) return `${bytes} B`; + if (bytes < k * k) return `${(bytes / k).toFixed(dm)} KB`; + return `${(bytes / (k * k)).toFixed(dm)} MB`; + } + + getBadgeSeverity(status: UploadingFile['status']): 'info' | 'warn' | 'success' | 'danger' { + switch (status) { + case 'Pending': + return 'warn'; + case 'Uploading': + return 'info'; + case 'Uploaded': + return 'success'; + case 'Failed': + return 'danger'; + default: + return 'info'; + } + } +} \ No newline at end of file diff --git a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html index b178473f7..8e9255e1b 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html @@ -78,7 +78,7 @@ class="custom-button-padding" size="small" [text]="true" - (click)="menu.toggle($event)" + (click)="onMenuToggle($event, menu)" icon="pi pi-ellipsis-v"> diff --git a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts index b8df3510c..e438b2538 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts @@ -1,6 +1,6 @@ -import {Component, ElementRef, EventEmitter, inject, Input, OnDestroy, OnInit, Output, ViewChild} from '@angular/core'; +import {Component, ElementRef, EventEmitter, inject, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild} from '@angular/core'; import {TooltipModule} from "primeng/tooltip"; -import {Book, ReadStatus} from '../../../model/book.model'; +import {Book, ReadStatus, AdditionalFile} from '../../../model/book.model'; import {Button} from 'primeng/button'; import {MenuModule} from 'primeng/menu'; import {ConfirmationService, MenuItem, MessageService} from 'primeng/api'; @@ -33,7 +33,7 @@ import {ResetProgressTypes} from '../../../../shared/constants/reset-progress-ty imports: [Button, MenuModule, CheckboxModule, FormsModule, NgClass, TieredMenu, ProgressBar, TooltipModule], standalone: true }) -export class BookCardComponent implements OnInit, OnDestroy { +export class BookCardComponent implements OnInit, OnChanges, OnDestroy { @Output() checkboxClick = new EventEmitter<{ index: number; bookId: number; selected: boolean; shiftKey: boolean }>(); @@ -51,6 +51,8 @@ export class BookCardComponent implements OnInit, OnDestroy { items: MenuItem[] | undefined; isHovered: boolean = false; isImageLoaded: boolean = false; + isSubMenuLoading = false; + private additionalFilesLoaded = false; private bookService = inject(BookService); private dialogService = inject(DialogService); @@ -79,6 +81,14 @@ export class BookCardComponent implements OnInit, OnDestroy { }); } + ngOnChanges(changes: SimpleChanges): void { + if (changes['book'] && !changes['book'].firstChange) { + // Reset the flag when book changes + this.additionalFilesLoaded = false; + this.initMenu(); + } + } + get progressPercentage(): number | null { if (this.book.epubProgress?.percentage != null) { return this.book.epubProgress.percentage; @@ -107,6 +117,38 @@ export class BookCardComponent implements OnInit, OnDestroy { this.bookService.readBook(book.id); } + onMenuToggle(event: Event, menu: TieredMenu): void { + menu.toggle(event); + + // Load additional files if not already loaded and needed + if (!this.additionalFilesLoaded && !this.isSubMenuLoading && this.needsAdditionalFilesData()) { + this.isSubMenuLoading = true; + this.bookService.getBookByIdFromAPI(this.book.id, true).subscribe({ + next: (book) => { + this.book = book; + this.additionalFilesLoaded = true; + this.isSubMenuLoading = false; + this.initMenu(); + }, + error: () => { + this.isSubMenuLoading = false; + } + }); + } + } + + private needsAdditionalFilesData(): boolean { + // Don't need to load if already loaded + if (this.additionalFilesLoaded) { + return false; + } + + const hasNoAlternativeFormats = !this.book.alternativeFormats || this.book.alternativeFormats.length === 0; + const hasNoSupplementaryFiles = !this.book.supplementaryFiles || this.book.supplementaryFiles.length === 0; + return (this.hasDownloadPermission() || this.hasDeleteBookPermission()) && + hasNoAlternativeFormats && hasNoSupplementaryFiles; + } + private initMenu() { this.items = [ { @@ -144,33 +186,73 @@ export class BookCardComponent implements OnInit, OnDestroy { const items: MenuItem[] = []; if (this.hasDownloadPermission()) { - items.push({ - label: 'Download', - icon: 'pi pi-download', - command: () => { - this.bookService.downloadFile(this.book.id); - }, - }); + const hasAdditionalFiles = (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) || + (this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0); + + if (hasAdditionalFiles) { + const downloadItems = this.getDownloadMenuItems(); + items.push({ + label: 'Download', + icon: 'pi pi-download', + items: downloadItems + }); + } else if (this.additionalFilesLoaded) { + // Data has been loaded but no additional files exist + items.push({ + label: 'Download', + icon: 'pi pi-download', + command: () => { + this.bookService.downloadFile(this.book.id); + } + }); + } else { + // Data not loaded yet + items.push({ + label: 'Download', + icon: this.isSubMenuLoading ? 'pi pi-spin pi-spinner' : 'pi pi-download', + items: [{label: 'Loading...', disabled: true}] + }); + } } if (this.hasDeleteBookPermission()) { - items.push({ - label: 'Delete Book', - icon: 'pi pi-trash', - command: () => { - this.confirmationService.confirm({ - message: `Are you sure you want to delete "${this.book.metadata?.title}"?`, - header: 'Confirm Deletion', - icon: 'pi pi-exclamation-triangle', - acceptIcon: 'pi pi-trash', - rejectIcon: 'pi pi-times', - acceptButtonStyleClass: 'p-button-danger', - accept: () => { - this.bookService.deleteBooks(new Set([this.book.id])).subscribe(); - } - }); - }, - }); + const hasAdditionalFiles = (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) || + (this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0); + + if (hasAdditionalFiles) { + const deleteItems = this.getDeleteMenuItems(); + items.push({ + label: 'Delete', + icon: 'pi pi-trash', + items: deleteItems + }); + } else if (this.additionalFilesLoaded) { + // Data has been loaded but no additional files exist - show delete book option + items.push({ + label: 'Delete', + icon: 'pi pi-trash', + command: () => { + this.confirmationService.confirm({ + message: `Are you sure you want to delete "${this.book.metadata?.title}"?`, + header: 'Confirm Deletion', + icon: 'pi pi-exclamation-triangle', + acceptIcon: 'pi pi-trash', + rejectIcon: 'pi pi-times', + acceptButtonStyleClass: 'p-button-danger', + accept: () => { + this.bookService.deleteBooks(new Set([this.book.id])).subscribe(); + } + }); + } + }); + } else { + // Data not loaded yet + items.push({ + label: 'Delete', + icon: this.isSubMenuLoading ? 'pi pi-spin pi-spinner' : 'pi pi-trash', + items: [{label: 'Loading...', disabled: true}] + }); + } } if (this.hasEmailBookPermission()) { @@ -396,6 +478,184 @@ export class BookCardComponent implements OnInit, OnDestroy { } } + private getDownloadMenuItems(): MenuItem[] { + const items: MenuItem[] = []; + + // Add main book file first + items.push({ + label: `${this.book.fileName || 'Book File'}`, + icon: 'pi pi-file', + command: () => { + this.bookService.downloadFile(this.book.id); + } + }); + + // Add separator if there are additional files + if (this.hasAdditionalFiles()) { + items.push({ separator: true }); + } + + // Add alternative formats + if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) { + this.book.alternativeFormats.forEach(format => { + const extension = this.getFileExtension(format.filePath); + items.push({ + label: `${format.fileName} (${this.getFileSizeInMB(format)})`, + icon: this.getFileIcon(extension), + command: () => this.downloadAdditionalFile(this.book.id, format.id) + }); + }); + } + + // Add separator if both alternative formats and supplementary files exist + if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0 && + this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) { + items.push({ separator: true }); + } + + // Add supplementary files + if (this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) { + this.book.supplementaryFiles.forEach(file => { + const extension = this.getFileExtension(file.filePath); + items.push({ + label: `${file.fileName} (${this.getFileSizeInMB(file)})`, + icon: this.getFileIcon(extension), + command: () => this.downloadAdditionalFile(this.book.id, file.id) + }); + }); + } + + return items; + } + + private getDeleteMenuItems(): MenuItem[] { + const items: MenuItem[] = []; + + // Add main book deletion + items.push({ + label: 'Book', + icon: 'pi pi-book', + command: () => { + this.confirmationService.confirm({ + message: `Are you sure you want to delete "${this.book.metadata?.title}"?`, + header: 'Confirm Deletion', + icon: 'pi pi-exclamation-triangle', + acceptIcon: 'pi pi-trash', + rejectIcon: 'pi pi-times', + acceptButtonStyleClass: 'p-button-danger', + accept: () => { + this.bookService.deleteBooks(new Set([this.book.id])).subscribe(); + } + }); + } + }); + + // Add separator if there are additional files + if (this.hasAdditionalFiles()) { + items.push({ separator: true }); + } + + // Add alternative formats + if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) { + this.book.alternativeFormats.forEach(format => { + const extension = this.getFileExtension(format.filePath); + items.push({ + label: `${format.fileName} (${this.getFileSizeInMB(format)})`, + icon: this.getFileIcon(extension), + command: () => this.deleteAdditionalFile(this.book.id, format.id, format.fileName || 'file') + }); + }); + } + + // Add separator if both alternative formats and supplementary files exist + if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0 && + this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) { + items.push({ separator: true }); + } + + // Add supplementary files + if (this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) { + this.book.supplementaryFiles.forEach(file => { + const extension = this.getFileExtension(file.filePath); + items.push({ + label: `${file.fileName} (${this.getFileSizeInMB(file)})`, + icon: this.getFileIcon(extension), + command: () => this.deleteAdditionalFile(this.book.id, file.id, file.fileName || 'file') + }); + }); + } + + return items; + } + + private hasAdditionalFiles(): boolean { + return !!(this.book.alternativeFormats && this.book.alternativeFormats.length > 0) || + !!(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0); + } + + private downloadAdditionalFile(bookId: number, fileId: number): void { + this.bookService.downloadAdditionalFile(bookId, fileId); + } + + private deleteAdditionalFile(bookId: number, fileId: number, fileName: string): void { + this.confirmationService.confirm({ + message: `Are you sure you want to delete the additional file "${fileName}"?`, + header: 'Confirm File Deletion', + icon: 'pi pi-exclamation-triangle', + acceptIcon: 'pi pi-trash', + rejectIcon: 'pi pi-times', + acceptButtonStyleClass: 'p-button-danger', + accept: () => { + this.bookService.deleteAdditionalFile(bookId, fileId).subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: `Additional file "${fileName}" deleted successfully` + }); + }, + error: (error) => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: `Failed to delete additional file: ${error.message || 'Unknown error'}` + }); + } + }); + } + }); + } + + private getFileExtension(filePath?: string): string | null { + if (!filePath) return null; + const parts = filePath.split('.'); + if (parts.length < 2) return null; + return parts.pop()?.toUpperCase() || null; + } + + private getFileIcon(fileType: string | null): string { + if (!fileType) return 'pi pi-file'; + switch (fileType.toLowerCase()) { + case 'pdf': + return 'pi pi-file-pdf'; + case 'epub': + case 'mobi': + case 'azw3': + return 'pi pi-book'; + case 'cbz': + case 'cbr': + case 'cbx': + return 'pi pi-image'; + default: + return 'pi pi-file'; + } + } + + private getFileSizeInMB(fileInfo: AdditionalFile): string { + const sizeKb = fileInfo?.fileSizeKb; + return sizeKb != null ? `${(sizeKb / 1024).toFixed(2)} MB` : '-'; + } + private isAdmin(): boolean { return this.userPermissions?.admin ?? false; } diff --git a/booklore-ui/src/app/book/model/book.model.ts b/booklore-ui/src/app/book/model/book.model.ts index 271eb6a4a..92ffc4e6d 100644 --- a/booklore-ui/src/app/book/model/book.model.ts +++ b/booklore-ui/src/app/book/model/book.model.ts @@ -4,7 +4,27 @@ import {BookReview} from '../../book-review-service'; export type BookType = "PDF" | "EPUB" | "CBX"; -export interface Book { +export enum AdditionalFileType { + ALTERNATIVE_FORMAT = 'ALTERNATIVE_FORMAT', + SUPPLEMENTARY = 'SUPPLEMENTARY' +} + +export interface FileInfo { + fileName?: string; + filePath?: string; + fileSubPath?: string; + fileSizeKb?: number; +} + +export interface AdditionalFile extends FileInfo { + id: number; + bookId: number; + additionalFileType: AdditionalFileType; + description?: string; + addedOn?: string; +} + +export interface Book extends FileInfo { id: number; bookType: BookType; libraryId: number; @@ -16,15 +36,13 @@ export interface Book { pdfProgress?: PdfProgress; cbxProgress?: CbxProgress; koreaderProgress?: KoReaderProgress; - filePath?: string; - fileSubPath?: string; - fileName?: string; - fileSizeKb?: number; seriesCount?: number | null; metadataMatchScore?: number | null; readStatus?: ReadStatus; dateFinished?: string; libraryPath?: { id: number }; + alternativeFormats?: AdditionalFile[]; + supplementaryFiles?: AdditionalFile[]; } export interface EpubProgress { diff --git a/booklore-ui/src/app/book/service/book.service.ts b/booklore-ui/src/app/book/service/book.service.ts index 9c7e3ede7..923db5dc4 100644 --- a/booklore-ui/src/app/book/service/book.service.ts +++ b/booklore-ui/src/app/book/service/book.service.ts @@ -2,7 +2,7 @@ import {inject, Injectable} from '@angular/core'; import {BehaviorSubject, first, Observable, of, throwError} from 'rxjs'; import {HttpClient, HttpParams} from '@angular/common/http'; import {catchError, filter, map, tap, shareReplay, finalize, distinctUntilChanged} from 'rxjs/operators'; -import {Book, BookDeletionResponse, BookMetadata, BookRecommendation, BookSetting, BulkMetadataUpdateRequest, MetadataUpdateWrapper, ReadStatus} from '../model/book.model'; +import {Book, BookDeletionResponse, BookMetadata, BookRecommendation, BookSetting, BulkMetadataUpdateRequest, MetadataUpdateWrapper, ReadStatus, AdditionalFileType, AdditionalFile} from '../model/book.model'; import {BookState} from '../model/state/book-state.model'; import {API_CONFIG} from '../../config/api-config'; import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-request.model'; @@ -88,6 +88,26 @@ export class BookService { ); } + refreshBooks(): void { + this.http.get(this.url).pipe( + tap(books => { + this.bookStateSubject.next({ + books: books || [], + loaded: true, + error: null, + }); + }), + catchError(error => { + this.bookStateSubject.next({ + books: null, + loaded: true, + error: error.message, + }); + return of(null); + }) + ).subscribe(); + } + getBookByIdFromState(bookId: number): Book | undefined { const currentState = this.bookStateSubject.value; return currentState.books?.find(book => +book.id === +bookId); @@ -245,6 +265,90 @@ export class BookService { ); } + deleteAdditionalFile(bookId: number, fileId: number): Observable { + const deleteUrl = `${this.url}/${bookId}/files/${fileId}`; + return this.http.delete(deleteUrl).pipe( + tap(() => { + const currentState = this.bookStateSubject.value; + const updatedBooks = (currentState.books || []).map(book => { + if (book.id === bookId) { + return { + ...book, + alternativeFormats: book.alternativeFormats?.filter(file => file.id !== fileId), + supplementaryFiles: book.supplementaryFiles?.filter(file => file.id !== fileId) + }; + } + return book; + }); + + this.bookStateSubject.next({ + ...currentState, + books: updatedBooks + }); + + this.messageService.add({ + severity: 'success', + summary: 'File Deleted', + detail: 'Additional file deleted successfully.' + }); + }), + catchError(error => { + this.messageService.add({ + severity: 'error', + summary: 'Delete Failed', + detail: error?.error?.message || error?.message || 'An error occurred while deleting the file.' + }); + return throwError(() => error); + }) + ); + } + + uploadAdditionalFile(bookId: number, file: File, fileType: AdditionalFileType, description?: string): Observable { + const formData = new FormData(); + formData.append('file', file); + formData.append('additionalFileType', fileType); + if (description) { + formData.append('description', description); + } + + return this.http.post(`${this.url}/${bookId}/files`, formData).pipe( + tap((newFile) => { + const currentState = this.bookStateSubject.value; + const updatedBooks = (currentState.books || []).map(book => { + if (book.id === bookId) { + const updatedBook = { ...book }; + if (fileType === AdditionalFileType.ALTERNATIVE_FORMAT) { + updatedBook.alternativeFormats = [...(book.alternativeFormats || []), newFile]; + } else { + updatedBook.supplementaryFiles = [...(book.supplementaryFiles || []), newFile]; + } + return updatedBook; + } + return book; + }); + + this.bookStateSubject.next({ + ...currentState, + books: updatedBooks + }); + + this.messageService.add({ + severity: 'success', + summary: 'File Uploaded', + detail: 'Additional file uploaded successfully.' + }); + }), + catchError(error => { + this.messageService.add({ + severity: 'error', + summary: 'Upload Failed', + detail: error?.error?.message || error?.message || 'An error occurred while uploading the file.' + }); + return throwError(() => error); + }) + ); + } + downloadFile(bookId: number): void { const downloadUrl = `${this.url}/${bookId}/download`; this.http.get(downloadUrl, {responseType: 'blob', observe: 'response'}) @@ -260,6 +364,21 @@ export class BookService { }); } + downloadAdditionalFile(bookId: number, fileId: number): void { + const downloadUrl = `${this.url}/${bookId}/files/${fileId}/download`; + this.http.get(downloadUrl, {responseType: 'blob', observe: 'response'}) + .subscribe({ + next: (response) => { + const contentDisposition = response.headers.get('Content-Disposition'); + const filename = contentDisposition + ? contentDisposition.match(/filename="(.+?)"/)?.[1] || `additional_file_${fileId}` + : `additional_file_${fileId}`; + this.saveFile(response.body as Blob, filename); + }, + error: (err) => console.error('Error downloading additional file:', err), + }); + } + private saveFile(blob: Blob, filename: string): void { const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html index 20032a70f..4230fe964 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html @@ -419,13 +419,25 @@ (onClick)="assignShelf(book.id)"> @if (userState.user!.permissions.canDownload || userState.user!.permissions.admin) { - - + @if ((book!.alternativeFormats && book!.alternativeFormats.length > 0) || (book!.supplementaryFiles && book!.supplementaryFiles.length > 0)) { + @if (downloadMenuItems$ | async; as downloadItems) { + + } + } @else { + + + } } @if (userState.user!.permissions.canEmailBook || userState.user!.permissions.admin) { @if (emailMenuItems$ | async; as emailItems) { @@ -457,7 +469,7 @@ @if (userState.user!.permissions.canDeleteBook || userState.user!.permissions.admin) { @if (otherItems$ | async; as otherItems) { - + } } diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts index d27a692b4..0d06e5cbe 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts @@ -6,7 +6,7 @@ import {BookService} from '../../../book/service/book.service'; import {Rating, RatingRateEvent} from 'primeng/rating'; import {FormsModule} from '@angular/forms'; import {Tag} from 'primeng/tag'; -import {Book, BookMetadata, BookRecommendation, ReadStatus} from '../../../book/model/book.model'; +import {Book, BookMetadata, BookRecommendation, ReadStatus, FileInfo} from '../../../book/model/book.model'; import {UrlHelperService} from '../../../utilities/service/url-helper.service'; import {UserService} from '../../../settings/user-management/user.service'; import {SplitButton} from 'primeng/splitbutton'; @@ -33,13 +33,15 @@ import {Tab, TabList, TabPanel, TabPanels, Tabs} from 'primeng/tabs'; import {BookReviewsComponent} from '../../../book/components/book-reviews/book-reviews.component'; import {BookNotesComponent} from '../../../book/components/book-notes-component/book-notes-component'; import {ProgressSpinner} from 'primeng/progressspinner'; +import {TieredMenu} from 'primeng/tieredmenu'; +import {AdditionalFileUploaderComponent} from '../../../book/components/additional-file-uploader/additional-file-uploader.component'; @Component({ selector: 'app-metadata-viewer', standalone: true, templateUrl: './metadata-viewer.component.html', styleUrl: './metadata-viewer.component.scss', - imports: [Button, AsyncPipe, Rating, FormsModule, Tag, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner] + imports: [Button, AsyncPipe, Rating, FormsModule, Tag, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner, TieredMenu] }) export class MetadataViewerComponent implements OnInit, OnChanges { @Input() book$!: Observable; @@ -62,7 +64,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges { readMenuItems$!: Observable; refreshMenuItems$!: Observable; otherItems$!: Observable; - + downloadMenuItems$!: Observable; bookInSeries: Book[] = []; isExpanded = false; showFilePath = false; @@ -135,37 +137,140 @@ export class MetadataViewerComponent implements OnInit, OnChanges { ]) ); + this.downloadMenuItems$ = this.book$.pipe( + filter((book): book is Book => book !== null && + ((book.alternativeFormats !== undefined && book.alternativeFormats.length > 0) || + (book.supplementaryFiles !== undefined && book.supplementaryFiles.length > 0))), + map((book): MenuItem[] => { + const items: MenuItem[] = []; + + // Add alternative formats + if (book.alternativeFormats && book.alternativeFormats.length > 0) { + book.alternativeFormats.forEach(format => { + const extension = this.getFileExtension(format.filePath); + items.push({ + label: `${format.fileName} (${this.getFileSizeInMB(format)})`, + icon: this.getFileIcon(extension), + command: () => this.downloadAdditionalFile(book.id, format.id) + }); + }); + } + + // Add separator if both types exist + if (book.alternativeFormats && book.alternativeFormats.length > 0 && + book.supplementaryFiles && book.supplementaryFiles.length > 0) { + items.push({ separator: true }); + } + + // Add supplementary files + if (book.supplementaryFiles && book.supplementaryFiles.length > 0) { + book.supplementaryFiles.forEach(file => { + const extension = this.getFileExtension(file.filePath); + items.push({ + label: `${file.fileName} (${this.getFileSizeInMB(file)})`, + icon: this.getFileIcon(extension), + command: () => this.downloadAdditionalFile(book.id, file.id) + }); + }); + } + + return items; + }) + ); + this.otherItems$ = this.book$.pipe( filter((book): book is Book => book !== null), - map((book): MenuItem[] => [ - { - label: 'Delete Book', - icon: 'pi pi-trash', - command: () => { - this.confirmationService.confirm({ - message: `Are you sure you want to delete "${book.metadata?.title}"?`, - header: 'Confirm Deletion', - icon: 'pi pi-exclamation-triangle', - acceptIcon: 'pi pi-trash', - rejectIcon: 'pi pi-times', - acceptButtonStyleClass: 'p-button-danger', - accept: () => { - this.bookService.deleteBooks(new Set([book.id])).subscribe({ - next: () => { - if (this.metadataCenterViewMode === 'route') { - this.router.navigate(['/dashboard']); - } else { - this.dialogRef?.close(); - } - }, - error: () => { - } - }); - } - }); + map((book): MenuItem[] => { + const items: MenuItem[] = [ + { + label: 'Upload File', + icon: 'pi pi-upload', + command: () => { + this.dialogService.open(AdditionalFileUploaderComponent, { + header: 'Upload Additional File', + modal: true, + closable: true, + style: { + position: 'absolute', + top: '10%', + }, + data: {book} + }); + }, }, + { + label: 'Delete Book', + icon: 'pi pi-trash', + command: () => { + this.confirmationService.confirm({ + message: `Are you sure you want to delete "${book.metadata?.title}"?`, + header: 'Confirm Deletion', + icon: 'pi pi-exclamation-triangle', + acceptIcon: 'pi pi-trash', + rejectIcon: 'pi pi-times', + acceptButtonStyleClass: 'p-button-danger', + accept: () => { + this.bookService.deleteBooks(new Set([book.id])).subscribe({ + next: () => { + if (this.metadataCenterViewMode === 'route') { + this.router.navigate(['/dashboard']); + } else { + this.dialogRef?.close(); + } + }, + error: () => { + } + }); + } + }); + }, + }, + ]; + + // Add delete additional files menu if there are any additional files + if ((book.alternativeFormats && book.alternativeFormats.length > 0) || + (book.supplementaryFiles && book.supplementaryFiles.length > 0)) { + const deleteFileItems: MenuItem[] = []; + + // Add alternative formats + if (book.alternativeFormats && book.alternativeFormats.length > 0) { + book.alternativeFormats.forEach(format => { + const extension = this.getFileExtension(format.filePath); + deleteFileItems.push({ + label: `${format.fileName} (${this.getFileSizeInMB(format)})`, + icon: this.getFileIcon(extension), + command: () => this.deleteAdditionalFile(book.id, format.id, format.fileName || 'file') + }); + }); + } + + // Add separator if both types exist + if (book.alternativeFormats && book.alternativeFormats.length > 0 && + book.supplementaryFiles && book.supplementaryFiles.length > 0) { + deleteFileItems.push({ separator: true }); + } + + // Add supplementary files + if (book.supplementaryFiles && book.supplementaryFiles.length > 0) { + book.supplementaryFiles.forEach(file => { + const extension = this.getFileExtension(file.filePath); + deleteFileItems.push({ + label: `${file.fileName} (${this.getFileSizeInMB(file)})`, + icon: this.getFileIcon(extension), + command: () => this.deleteAdditionalFile(book.id, file.id, file.fileName || 'file') + }); + }); + } + + items.push({ + label: 'Delete Additional Files', + icon: 'pi pi-trash', + items: deleteFileItems + }); } - ]) + + return items; + }) ); this.userService.userState$ @@ -240,6 +345,39 @@ export class MetadataViewerComponent implements OnInit, OnChanges { this.bookService.downloadFile(bookId); } + downloadAdditionalFile(bookId: number, fileId: number) { + this.bookService.downloadAdditionalFile(bookId, fileId); + } + + deleteAdditionalFile(bookId: number, fileId: number, fileName: string) { + this.confirmationService.confirm({ + message: `Are you sure you want to delete the additional file "${fileName}"?`, + header: 'Confirm File Deletion', + icon: 'pi pi-exclamation-triangle', + acceptIcon: 'pi pi-trash', + rejectIcon: 'pi pi-times', + acceptButtonStyleClass: 'p-button-danger', + accept: () => { + this.bookService.deleteAdditionalFile(bookId, fileId).subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: `Additional file "${fileName}" deleted successfully` + }); + }, + error: (error) => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: `Failed to delete additional file: ${error.message || 'Unknown error'}` + }); + } + }); + } + }); + } + quickRefresh(bookId: number) { this.isAutoFetching = true; const request: MetadataRefreshRequest = { @@ -436,8 +574,8 @@ export class MetadataViewerComponent implements OnInit, OnChanges { return lockedKeys.length > 0 && lockedKeys.every(k => metadata[k] === true); } - getFileSizeInMB(book: Book | null): string { - const sizeKb = book?.fileSizeKb; + getFileSizeInMB(fileInfo: FileInfo | null | undefined): string { + const sizeKb = fileInfo?.fileSizeKb; return sizeKb != null ? `${(sizeKb / 1024).toFixed(2)} MB` : '-'; } @@ -468,6 +606,24 @@ export class MetadataViewerComponent implements OnInit, OnChanges { return parts.pop()?.toUpperCase() || null; } + getFileIcon(fileType: string | null): string { + if (!fileType) return 'pi pi-file'; + switch (fileType.toLowerCase()) { + case 'pdf': + return 'pi pi-file-pdf'; + case 'epub': + case 'mobi': + case 'azw3': + return 'pi pi-book'; + case 'cbz': + case 'cbr': + case 'cbx': + return 'pi pi-image'; + default: + return 'pi pi-file'; + } + } + getFileTypeColorClass(fileType: string | null | undefined): string { if (!fileType) return 'bg-gray-600 text-white'; switch (fileType.toLowerCase()) {