From b9cace2997421ca826b72aa2a0e6a5611e795d64 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sun, 5 Apr 2026 14:02:26 +0300 Subject: [PATCH] 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 --- frontend/src/lib/api/client.ts | 37 ++ .../src/lib/components/file/FileUpload.svelte | 351 ++++++++++++++++++ .../src/lib/components/layout/Header.svelte | 11 + frontend/src/routes/files/+page.svelte | 52 +-- frontend/vite-mock-plugin.ts | 32 ++ 5 files changed, 462 insertions(+), 21 deletions(-) create mode 100644 frontend/src/lib/components/file/FileUpload.svelte diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index fe38fb7..3fef352 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -89,6 +89,43 @@ async function request(path: string, init?: RequestInit): Promise { return res.json(); } +/** Upload with XHR so we can track progress via onProgress(0–100). */ +export function uploadWithProgress( + path: string, + formData: FormData, + onProgress: (pct: number) => void, +): Promise { + 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: (path: string) => request(path), post: (path: string, body?: unknown) => diff --git a/frontend/src/lib/components/file/FileUpload.svelte b/frontend/src/lib/components/file/FileUpload.svelte new file mode 100644 index 0000000..578b1b3 --- /dev/null +++ b/frontend/src/lib/components/file/FileUpload.svelte @@ -0,0 +1,351 @@ + + + + + + + +
+ {@render children()} + + {#if dragOver} + + {/if} +
+ + +{#if queue.length > 0} +
+
+ + {#if allSettled} + Uploads complete + {:else} + Uploading {queue.filter((i) => i.status === 'uploading').length} file(s)… + {/if} + + {#if allSettled} + + {/if} +
+ +
    + {#each queue as item (item.id)} +
  • + {item.name} +
    + {#if item.status === 'uploading'} +
    +
    +
    + {item.progress}% + {:else if item.status === 'done'} + + + + {:else} + {item.error} + {/if} +
    +
  • + {/each} +
+
+{/if} + + \ No newline at end of file diff --git a/frontend/src/lib/components/layout/Header.svelte b/frontend/src/lib/components/layout/Header.svelte index f5f1126..f076bc0 100644 --- a/frontend/src/lib/components/layout/Header.svelte +++ b/frontend/src/lib/components/layout/Header.svelte @@ -10,6 +10,7 @@ onSortChange: (sort: string) => void; onOrderToggle: () => void; onFilterToggle: () => void; + onUpload?: () => void; } let { @@ -20,6 +21,7 @@ onSortChange, onOrderToggle, onFilterToggle, + onUpload, }: Props = $props(); @@ -32,6 +34,15 @@ {$selectionActive ? 'Cancel' : 'Select'} + {#if onUpload} + + {/if} +