feat(upload): add upload progress tracking with progress bar and stats (#2640)

This commit is contained in:
ACX
2026-02-06 13:38:17 -07:00
committed by GitHub
parent f5326ce435
commit f540e75994
3 changed files with 184 additions and 19 deletions

View File

@@ -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) {

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}