mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(upload): add upload progress tracking with progress bar and stats (#2640)
This commit is contained in:
@@ -126,6 +126,33 @@
|
||||
</ng-template>
|
||||
<ng-template #content let-files let-removeFileCallback="removeFileCallback">
|
||||
@if (files?.length > 0) {
|
||||
@if (isUploading || uploadCompleted) {
|
||||
<div class="upload-progress-summary">
|
||||
<div class="progress-row">
|
||||
<span class="progress-status">
|
||||
@if (isUploading) {
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
Uploading...
|
||||
} @else {
|
||||
<i class="pi pi-check-circle"></i>
|
||||
Complete
|
||||
}
|
||||
</span>
|
||||
<span class="progress-files">
|
||||
{{ getUploadedCount() }}/{{ this.files.length }} files
|
||||
@if (getFailedCount() > 0) {
|
||||
<span class="failed-count">({{ getFailedCount() }} failed)</span>
|
||||
}
|
||||
</span>
|
||||
<span class="progress-bytes">{{ formatSize(getUploadedBytes()) }} / {{ formatSize(getTotalBytes()) }}</span>
|
||||
<span class="progress-percent">{{ getOverallProgress() }}%</span>
|
||||
</div>
|
||||
<p-progressbar
|
||||
[value]="getOverallProgress()"
|
||||
[showValue]="false"
|
||||
styleClass="overall-progress-bar"/>
|
||||
</div>
|
||||
}
|
||||
<div class="files-list">
|
||||
@for (uploadFile of this.files; track uploadFile; let i = $index) {
|
||||
<div class="file-item">
|
||||
@@ -133,8 +160,11 @@
|
||||
<p-badge
|
||||
[value]="getFileStatusLabel(uploadFile)"
|
||||
[severity]="getBadgeSeverity(uploadFile.status)"/>
|
||||
<span class="file-name">{{ uploadFile.file.name }}</span>
|
||||
<span class="file-name" [pTooltip]="uploadFile.file.name" tooltipPosition="top">{{ uploadFile.file.name }}</span>
|
||||
<span class="file-size">{{ formatSize(uploadFile.file.size) }}</span>
|
||||
@if (uploadFile.status === 'Uploading') {
|
||||
<span class="file-progress-text">{{ uploadFile.progress }}%</span>
|
||||
}
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
@switch (uploadFile.status) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Book>(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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user