feat: add upload progress bars and error toasts

Adds uploadWithProgress() using XMLHttpRequest for real-time upload
progress events. MessageInput now shows per-file progress bars with
percentage overlay during upload. Failed uploads show toast warnings
with the specific error instead of silently failing. Upload timeout
raised to 10 minutes for large files.
This commit is contained in:
Jannis Braun
2026-03-23 02:51:56 +01:00
parent fdc8732ba7
commit 9a5e613331
2 changed files with 112 additions and 30 deletions
+40
View File
@@ -128,6 +128,7 @@ export class BackspaceApiClient {
readonly uploads: {
upload: (file: File) => Promise<Attachment>;
uploadWithProgress: (file: File, onProgress: (loaded: number, total: number) => void) => Promise<Attachment>;
url: (filename: string) => string;
};
@@ -313,6 +314,44 @@ export class BackspaceApiClient {
return response.json() as Promise<Attachment>;
}
function uploadFileWithProgress(file: File, onProgress: (loaded: number, total: number) => void): Promise<Attachment> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress(e.loaded, e.total);
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { reject(new Error('Invalid server response')); }
} else if (xhr.status === 401 && onUnauthorized) {
onUnauthorized();
reject(new Error('Unauthorized'));
} else if (xhr.status === 413) {
reject(new Error('File too large'));
} else {
try {
const body = JSON.parse(xhr.responseText);
reject(new Error(body.error || `Upload failed (${xhr.status})`));
} catch { reject(new Error(`Upload failed (${xhr.status})`)); }
}
});
xhr.addEventListener('error', () => reject(new Error('Upload failed — network error')));
xhr.addEventListener('timeout', () => reject(new Error('Upload timed out')));
xhr.open('POST', `${baseUrl}/uploads`);
xhr.timeout = 10 * 60 * 1000; // 10 minutes for large files
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
});
}
this.auth = {
register: (data: RegisterRequest) =>
request<AuthResponse>('POST', '/auth/register', data, false),
@@ -428,6 +467,7 @@ export class BackspaceApiClient {
this.uploads = {
upload: uploadFile,
uploadWithProgress: uploadFileWithProgress,
url: (filename: string) => `${baseUrl}/uploads/${filename}`,
};