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} +