mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Book additional files support UI (2/3) (#870)
* feat(api/model): add new entity BookAdditionalFileEntity * feat(api/db): add book additional file repository * feat(api/db): add migration * test(api/db): add book additional file repository test * test(api/db): add book additional file repository tests for hash uniqueness * feat(api/domain): add support to additional file model * feat(api): add additional files controller * refactor(api): move addAdditionalFile to FileUploadService as uploadAdditionalFile method * feat(service): search book by additional file * feat(services): process deleted additional files with ability to promote alternative formats to book instead of deleting them * refactor(util): use common code to resolve patter for entity and domain object * feat(service): move additional files * test(service): test move additional files along with book itself * feat(ui/domain): add alternativeFormats and supplementaryFiles to book model * feat(ui/domain): add download additional file method to book service * refactor(api/domain): extract FileInfo interface with common fields Allow to share the same interface thet is implemented by AdditionFile and Book * feat(ui): show multiple download options * feat(ui/domain): add delete additional file method to book service * feat(ui): add delete additional file ui * feat(ui): add additional-file-uploader.component * feat(ui/domain): add uploadAdditionalFile to the service * feat(ui): add Upload File menu item * feat(ui): show supplementary files in download menu item * feat(ui): show supplementary files in delete file menu item * feat(ui): book card allow to select single file to download or delete
This commit is contained in:
committed by
GitHub
parent
b81a8d1422
commit
f725ececf5
@@ -0,0 +1,129 @@
|
||||
<div class="flex flex-col gap-6 p-4 items-center justify-center w-full max-w-[700px]">
|
||||
<!-- Book Information -->
|
||||
<div class="w-full text-center">
|
||||
<h3 class="text-lg font-semibold mb-2">{{ book.metadata?.title || 'Unknown Title' }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ book.filePath }}</p>
|
||||
</div>
|
||||
|
||||
<!-- File Type Selection -->
|
||||
<p-select
|
||||
[options]="fileTypeOptions"
|
||||
[(ngModel)]="fileType"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select file type"
|
||||
class="w-full"
|
||||
[disabled]="isUploading"
|
||||
></p-select>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="w-full">
|
||||
<label for="description" class="block text-sm font-medium mb-2">Description (Optional)</label>
|
||||
<p-textarea
|
||||
[(ngModel)]="description"
|
||||
rows="3"
|
||||
placeholder="Add a description for this file..."
|
||||
class="w-full"
|
||||
[disabled]="isUploading"
|
||||
></p-textarea>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<p-fileupload class="w-full" name="file"
|
||||
[maxFileSize]="maxFileSizeBytes"
|
||||
[customUpload]="true"
|
||||
[multiple]="false"
|
||||
(onSelect)="onFilesSelect($event)"
|
||||
(uploadHandler)="uploadFiles($event)"
|
||||
[disabled]="isUploading">
|
||||
<ng-template #header let-files let-chooseCallback="chooseCallback" let-clearCallback="clearCallback" let-uploadCallback="uploadCallback">
|
||||
<div class="flex flex-wrap items-center justify-center flex-1 gap-4">
|
||||
<div class="flex gap-4">
|
||||
<p-button (onClick)="choose($event, chooseCallback)"
|
||||
icon="pi pi-images"
|
||||
[rounded]="true"
|
||||
[outlined]="true"
|
||||
[disabled]="isChooseDisabled()"/>
|
||||
<p-button (onClick)="uploadEvent(uploadCallback)"
|
||||
icon="pi pi-cloud-upload"
|
||||
[rounded]="true"
|
||||
[outlined]="true"
|
||||
severity="success"
|
||||
[disabled]="isUploadDisabled()"/>
|
||||
<p-button (onClick)="onClear(clearCallback)"
|
||||
icon="pi pi-times"
|
||||
[rounded]="true"
|
||||
[outlined]="true"
|
||||
severity="danger"
|
||||
[disabled]="!filesPresent() || isUploading"/>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template #content let-files let-removeFileCallback="removeFileCallback">
|
||||
<div class="flex flex-col gap-8 px-4">
|
||||
@if (files?.length > 0) {
|
||||
<div>
|
||||
<div class="max-h-96 max-w-[22rem] md:max-w-none overflow-y-auto pr-2">
|
||||
<div class="flex flex-wrap">
|
||||
@for (uploadFile of this.files; track uploadFile; let i = $index) {
|
||||
<div class="flex justify-between items-center w-full gap-4">
|
||||
<div class="flex items-center gap-4 overflow-hidden flex-1">
|
||||
<p-badge [value]="uploadFile.status" [severity]="getBadgeSeverity(uploadFile.status)" class="shrink-0"/>
|
||||
<span class="font-semibold text-ellipsis whitespace-nowrap overflow-hidden flex-1">
|
||||
{{ uploadFile.file.name }}
|
||||
</span>
|
||||
<div class="shrink-0">{{ formatSize(uploadFile.file.size) }}</div>
|
||||
</div>
|
||||
@switch (uploadFile.status) {
|
||||
@case ('Pending') {
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
(click)="onRemoveTemplatingFile($event, uploadFile.file, removeFileCallback, i)"
|
||||
severity="danger"
|
||||
[text]="true"/>
|
||||
}
|
||||
@case ('Uploading') {
|
||||
<i
|
||||
class="pi pi-spin pi-spinner p-3"
|
||||
style="color: slateblue"
|
||||
pTooltip="Uploading"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
}
|
||||
@case ('Uploaded') {
|
||||
<i
|
||||
class="pi pi-check p-3"
|
||||
style="color: green"
|
||||
pTooltip="Uploaded"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
}
|
||||
@case ('Failed') {
|
||||
<i
|
||||
class="pi pi-exclamation-triangle p-3"
|
||||
style="color: darkred"
|
||||
pTooltip="{{ uploadFile.errorMessage || 'Upload failed' }}"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template #file></ng-template>
|
||||
<ng-template #empty>
|
||||
<div class="flex items-center justify-center flex-col text-center">
|
||||
<i class="pi pi-cloud-upload !border-2 !rounded-full !p-8 !text-4xl !text-muted-color"></i>
|
||||
<p class="mt-6 mb-2">Drag and drop a file here to upload.</p>
|
||||
<p class="mt-2 mb-2">
|
||||
Upload an additional file for <strong>{{ book.metadata?.title || 'this book' }}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-fileupload>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
:host ::ng-deep .p-fileupload-content p-progressbar {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -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<void>();
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Book[]>(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<void> {
|
||||
const deleteUrl = `${this.url}/${bookId}/files/${fileId}`;
|
||||
return this.http.delete<void>(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<AdditionalFile> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('additionalFileType', fileType);
|
||||
if (description) {
|
||||
formData.append('description', description);
|
||||
}
|
||||
|
||||
return this.http.post<AdditionalFile>(`${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');
|
||||
|
||||
@@ -419,13 +419,25 @@
|
||||
(onClick)="assignShelf(book.id)">
|
||||
</p-button>
|
||||
@if (userState.user!.permissions.canDownload || userState.user!.permissions.admin) {
|
||||
<p-button
|
||||
label="Download"
|
||||
icon="pi pi-download"
|
||||
severity="success"
|
||||
outlined
|
||||
(onClick)="download(book!.id)">
|
||||
</p-button>
|
||||
@if ((book!.alternativeFormats && book!.alternativeFormats.length > 0) || (book!.supplementaryFiles && book!.supplementaryFiles.length > 0)) {
|
||||
@if (downloadMenuItems$ | async; as downloadItems) {
|
||||
<p-splitbutton
|
||||
label="Download"
|
||||
icon="pi pi-download"
|
||||
[model]="downloadItems"
|
||||
(onClick)="download(book!.id)"
|
||||
severity="success"
|
||||
outlined/>
|
||||
}
|
||||
} @else {
|
||||
<p-button
|
||||
label="Download"
|
||||
icon="pi pi-download"
|
||||
severity="success"
|
||||
outlined
|
||||
(onClick)="download(book!.id)">
|
||||
</p-button>
|
||||
}
|
||||
}
|
||||
@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) {
|
||||
<p-button icon="pi pi-ellipsis-v" outlined severity="danger" (click)="entitymenu.toggle($event)"></p-button>
|
||||
<p-menu #entitymenu [model]="otherItems" [popup]="true" appendTo="body"></p-menu>
|
||||
<p-tieredMenu #entitymenu [model]="otherItems" [popup]="true" appendTo="body"></p-tieredMenu>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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<Book | null>;
|
||||
@@ -62,7 +64,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
readMenuItems$!: Observable<MenuItem[]>;
|
||||
refreshMenuItems$!: Observable<MenuItem[]>;
|
||||
otherItems$!: Observable<MenuItem[]>;
|
||||
|
||||
downloadMenuItems$!: Observable<MenuItem[]>;
|
||||
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()) {
|
||||
|
||||
Reference in New Issue
Block a user