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
+31 -21
View File
@@ -4,6 +4,7 @@
import { api } from '$lib/api/client';
import { ApiError } from '$lib/api/client';
import FileCard from '$lib/components/file/FileCard.svelte';
import FileUpload from '$lib/components/file/FileUpload.svelte';
import FilterBar from '$lib/components/file/FilterBar.svelte';
import Header from '$lib/components/layout/Header.svelte';
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
@@ -13,6 +14,12 @@
import { parseDslFilter } from '$lib/utils/dsl';
import type { File, FileCursorPage } from '$lib/api/types';
let uploader = $state<{ open: () => void } | undefined>();
function handleUploaded(file: File) {
files = [file, ...files];
}
const LIMIT = 50;
const FILE_SORT_OPTIONS = [
@@ -179,6 +186,7 @@
onSortChange={(s) => fileSorting.setSort(s as FileSortField)}
onOrderToggle={() => fileSorting.toggleOrder()}
onFilterToggle={() => (filterOpen = !filterOpen)}
onUpload={() => uploader?.open()}
/>
{#if filterOpen}
@@ -189,30 +197,32 @@
/>
{/if}
<main>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<FileUpload bind:this={uploader} onUploaded={handleUploaded}>
<main>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="grid">
{#each files as file, i (file.id)}
<FileCard
{file}
index={i}
selected={$selectionStore.ids.has(file.id ?? '')}
selectionMode={$selectionActive}
onTap={(e) => handleTap(file, i, e)}
onLongPress={(pt) => handleLongPress(file, i, pt)}
/>
{/each}
</div>
<div class="grid">
{#each files as file, i (file.id)}
<FileCard
{file}
index={i}
selected={$selectionStore.ids.has(file.id ?? '')}
selectionMode={$selectionActive}
onTap={(e) => handleTap(file, i, e)}
onLongPress={(pt) => handleLongPress(file, i, pt)}
/>
{/each}
</div>
<InfiniteScroll {loading} {hasMore} onLoadMore={loadMore} />
<InfiniteScroll {loading} {hasMore} onLoadMore={loadMore} />
{#if !loading && !hasMore && files.length === 0}
<div class="empty">No files yet.</div>
{/if}
</main>
{#if !loading && !hasMore && files.length === 0}
<div class="empty">No files yet.</div>
{/if}
</main>
</FileUpload>
</div>
{#if $selectionActive}