feat(frontend): implement file upload with drag-and-drop and per-file progress

- client.ts: add uploadWithProgress() using XHR for upload progress events
- FileUpload.svelte: drag-drop zone wrapper, multi-file queue with individual
  progress bars, success/error status, MIME rejection message, dismiss panel
- Header.svelte: optional onUpload prop renders upload icon button
- files/+page.svelte: wire upload button, prepend uploaded files to grid
- vite-mock-plugin.ts: handle POST /files, unshift new file into mock array
- Fix crypto.randomUUID() crash on non-secure HTTP context (use Date.now + Math.random)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 14:02:26 +03:00
parent a5b610d472
commit b9cace2997
5 changed files with 462 additions and 21 deletions
+37
View File
@@ -89,6 +89,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return res.json();
}
/** Upload with XHR so we can track progress via onProgress(0100). */
export function uploadWithProgress<T>(
path: string,
formData: FormData,
onProgress: (pct: number) => void,
): Promise<T> {
return new Promise((resolve, reject) => {
const token = get(authStore).accessToken;
const xhr = new XMLHttpRequest();
xhr.open('POST', BASE + path);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText) as T);
} catch {
resolve(undefined as T);
}
} else {
let body: { code?: string; message?: string } = {};
try {
body = JSON.parse(xhr.responseText);
} catch { /* ignore */ }
reject(new ApiError(xhr.status, body.code ?? 'error', body.message ?? xhr.statusText));
}
};
xhr.onerror = () => reject(new ApiError(0, 'network_error', 'Network error'));
xhr.send(formData);
});
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>