diff --git a/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.html b/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.html index 0bfa78beb..cfefe8889 100644 --- a/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.html +++ b/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.html @@ -126,6 +126,33 @@ @if (files?.length > 0) { + @if (isUploading || uploadCompleted) { +
+
+ + @if (isUploading) { + + Uploading... + } @else { + + Complete + } + + + {{ getUploadedCount() }}/{{ this.files.length }} files + @if (getFailedCount() > 0) { + ({{ getFailedCount() }} failed) + } + + {{ formatSize(getUploadedBytes()) }} / {{ formatSize(getTotalBytes()) }} + {{ getOverallProgress() }}% +
+ +
+ }
@for (uploadFile of this.files; track uploadFile; let i = $index) {
@@ -133,8 +160,11 @@ - {{ uploadFile.file.name }} + {{ uploadFile.file.name }} {{ formatSize(uploadFile.file.size) }} + @if (uploadFile.status === 'Uploading') { + {{ uploadFile.progress }}% + }
@switch (uploadFile.status) { diff --git a/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.scss b/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.scss index de15ac082..bc2933a43 100644 --- a/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.scss +++ b/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.scss @@ -178,6 +178,85 @@ } } + .upload-progress-summary { + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.625rem 0.875rem; + margin-bottom: 0.5rem; + + .progress-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; + font-size: 0.85rem; + + .progress-status { + display: flex; + align-items: center; + gap: 0.375rem; + font-weight: 600; + color: var(--text-color); + + i { + font-size: 0.9rem; + color: var(--primary-color); + } + + .pi-check-circle { + color: #22c55e; + } + } + + .progress-files { + color: var(--text-secondary-color); + + .failed-count { + color: #ef4444; + font-weight: 500; + } + } + + .progress-bytes { + color: var(--text-secondary-color); + margin-left: auto; + } + + .progress-percent { + font-weight: 700; + color: var(--primary-color); + min-width: 42px; + text-align: right; + } + } + + ::ng-deep .overall-progress-bar { + height: 6px; + border-radius: 3px; + background: var(--overlay-background); + + .p-progressbar-value { + border-radius: 3px; + transition: width 0.3s ease; + } + } + + @media (max-width: 480px) { + padding: 0.5rem 0.75rem; + + .progress-row { + flex-wrap: wrap; + gap: 0.5rem; + font-size: 0.8rem; + + .progress-bytes { + margin-left: 0; + } + } + } + } + .files-list { display: flex; flex-direction: column; @@ -246,6 +325,15 @@ font-size: 0.875rem; flex-shrink: 0; } + + .file-progress-text { + font-size: 0.85rem; + font-weight: 600; + color: var(--primary-color); + min-width: 40px; + text-align: right; + flex-shrink: 0; + } } .file-actions { @@ -265,12 +353,9 @@ color: #22c55e; } - &.warning { - color: #f97316; - } - + &.warning, &.error { - color: var(--red-500); + color: #f97316; } } } diff --git a/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.ts b/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.ts index 62d2f0693..cb3930e71 100644 --- a/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.ts +++ b/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.ts @@ -12,17 +12,19 @@ import {LibraryState} from '../../../features/book/model/state/library-state.mod import {Observable} from 'rxjs'; import {API_CONFIG} from '../../../core/config/api-config'; import {Book} from '../../../features/book/model/book.model'; -import {HttpClient} from '@angular/common/http'; +import {HttpClient, HttpEventType, HttpRequest} from '@angular/common/http'; import {Tooltip} from 'primeng/tooltip'; import {AppSettingsService} from '../../service/app-settings.service'; import {filter, take} from 'rxjs/operators'; import {AppSettings} from '../../model/app-settings.model'; import {SelectButton} from 'primeng/selectbutton'; import {DynamicDialogRef} from 'primeng/dynamicdialog'; +import {ProgressBar} from 'primeng/progressbar'; interface UploadingFile { file: File; status: 'Pending' | 'Uploading' | 'Uploaded' | 'Failed'; + progress: number; errorMessage?: string; } @@ -37,7 +39,8 @@ interface UploadingFile { Select, Badge, Tooltip, - SelectButton + SelectButton, + ProgressBar ], templateUrl: './book-uploader.component.html', styleUrl: './book-uploader.component.scss' @@ -142,6 +145,7 @@ export class BookUploaderComponent implements OnInit { this.files.unshift({ file, status: 'Failed', + progress: 0, errorMessage: errorMsg }); this.messageService.add({ @@ -151,13 +155,20 @@ export class BookUploaderComponent implements OnInit { life: 5000 }); } else { - this.files.unshift({file, status: 'Pending'}); + this.files.unshift({file, status: 'Pending', progress: 0}); } } } - onRemoveTemplatingFile(_event: any, _file: File, removeFileCallback: (event: any, index: number) => void, index: number): void { - removeFileCallback(_event, index); + onRemoveTemplatingFile(_event: any, file: File, removeFileCallback: (event: any, index: number) => void, _index: number): void { + // Remove from our tracking array + this.files = this.files.filter(f => f.file !== file); + + // Find and remove from p-fileupload's internal array (index may differ from ours) + const fileUploadIndex = this.fileUpload.files?.findIndex(f => f.name === file.name && f.size === file.size) ?? -1; + if (fileUploadIndex >= 0) { + removeFileCallback(_event, fileUploadIndex); + } } uploadEvent(uploadCallback: () => void): void { @@ -202,6 +213,7 @@ export class BookUploaderComponent implements OnInit { for (const uploadFile of batch) { uploadFile.status = 'Uploading'; + uploadFile.progress = 0; const formData = new FormData(); const cleanFile = new File([uploadFile.file], uploadFile.file.name, {type: uploadFile.file.type}); @@ -218,19 +230,28 @@ export class BookUploaderComponent implements OnInit { uploadUrl = `${API_CONFIG.BASE_URL}/api/v1/files/upload/bookdrop`; } - this.http.post(uploadUrl, formData).subscribe({ - next: () => { - uploadFile.status = 'Uploaded'; - if (--pending === 0) { - setTimeout(() => { - this.uploadBatch(files, startIndex + batchSize, batchSize, destination, libraryId, pathId); - }, 1000); + const req = new HttpRequest('POST', uploadUrl, formData, { + reportProgress: true + }); + + this.http.request(req).subscribe({ + next: (event) => { + if (event.type === HttpEventType.UploadProgress && event.total) { + uploadFile.progress = Math.round((event.loaded / event.total) * 100); + } else if (event.type === HttpEventType.Response) { + uploadFile.status = 'Uploaded'; + uploadFile.progress = 100; + if (--pending === 0) { + setTimeout(() => { + this.uploadBatch(files, startIndex + batchSize, batchSize, destination, libraryId, pathId); + }, 1000); + } } }, error: (err) => { uploadFile.status = 'Failed'; + uploadFile.progress = 0; uploadFile.errorMessage = err?.error?.message || 'Upload failed due to unknown error.'; - console.error('Upload failed for', uploadFile.file.name, err); if (--pending === 0) { setTimeout(() => { this.uploadBatch(files, startIndex + batchSize, batchSize, destination, libraryId, pathId); @@ -307,4 +328,33 @@ export class BookUploaderComponent implements OnInit { closeDialog(): void { this.ref.close(); } + + getOverallProgress(): number { + if (this.files.length === 0) return 0; + const totalProgress = this.files.reduce((sum, f) => sum + f.progress, 0); + return Math.round(totalProgress / this.files.length); + } + + getUploadedCount(): number { + return this.files.filter(f => f.status === 'Uploaded').length; + } + + getFailedCount(): number { + return this.files.filter(f => f.status === 'Failed').length; + } + + getUploadingCount(): number { + return this.files.filter(f => f.status === 'Uploading').length; + } + + getTotalBytes(): number { + return this.files.reduce((sum, f) => sum + f.file.size, 0); + } + + getUploadedBytes(): number { + return this.files + .filter(f => f.status === 'Uploaded') + .reduce((sum, f) => sum + f.file.size, 0); + } + }