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:
parent
a5b610d472
commit
b9cace2997
@ -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(0–100). */
|
||||
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) =>
|
||||
|
||||
351
frontend/src/lib/components/file/FileUpload.svelte
Normal file
351
frontend/src/lib/components/file/FileUpload.svelte
Normal file
@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import { uploadWithProgress, ApiError } from '$lib/api/client';
|
||||
import type { File as ApiFile } from '$lib/api/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
onUploaded: (file: ApiFile) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { onUploaded, children }: Props = $props();
|
||||
|
||||
// ---- Upload queue ----
|
||||
type UploadStatus = 'uploading' | 'done' | 'error';
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
status: UploadStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let queue = $state<QueueItem[]>([]);
|
||||
let fileInput = $state<HTMLInputElement | undefined>();
|
||||
|
||||
let allSettled = $derived(queue.length > 0 && queue.every((i) => i.status !== 'uploading'));
|
||||
|
||||
// ---- File input ----
|
||||
export function open() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
function onInputChange(e: Event) {
|
||||
const files = (e.currentTarget as HTMLInputElement).files;
|
||||
if (files?.length) {
|
||||
void enqueue(Array.from(files));
|
||||
// Reset so the same file can be re-selected
|
||||
(e.currentTarget as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Upload logic ----
|
||||
function uid() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
async function enqueue(files: globalThis.File[]) {
|
||||
const items: QueueItem[] = files.map((f) => ({
|
||||
id: uid(),
|
||||
name: f.name,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
}));
|
||||
queue = [...queue, ...items];
|
||||
|
||||
await Promise.all(
|
||||
files.map((file, i) => uploadOne(file, items[i].id)),
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadOne(file: globalThis.File, itemId: string) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
|
||||
try {
|
||||
const result = await uploadWithProgress<ApiFile>(
|
||||
'/files',
|
||||
fd,
|
||||
(pct) => updateItem(itemId, { progress: pct }),
|
||||
);
|
||||
updateItem(itemId, { status: 'done', progress: 100 });
|
||||
onUploaded(result);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof ApiError
|
||||
? e.status === 415
|
||||
? `Unsupported file type`
|
||||
: e.message
|
||||
: 'Upload failed';
|
||||
updateItem(itemId, { status: 'error', error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
function updateItem(id: string, patch: Partial<QueueItem>) {
|
||||
queue = queue.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
queue = [];
|
||||
}
|
||||
|
||||
// ---- Drag and drop ----
|
||||
let dragCounter = $state(0);
|
||||
let dragOver = $derived(dragCounter > 0);
|
||||
|
||||
function onDragEnter(e: DragEvent) {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragCounter = Math.max(0, dragCounter - 1);
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) void enqueue(files);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*"
|
||||
style="display:none"
|
||||
onchange={onInputChange}
|
||||
/>
|
||||
|
||||
<!-- Drop zone wrapper -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
class:drag-over={dragOver}
|
||||
ondragenter={onDragEnter}
|
||||
ondragleave={onDragLeave}
|
||||
ondragover={onDragOver}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{@render children()}
|
||||
|
||||
{#if dragOver}
|
||||
<div class="drop-overlay" aria-hidden="true">
|
||||
<div class="drop-label">
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
||||
<path d="M18 4v20M10 14l8-10 8 10" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 28h24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Drop files to upload
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Upload progress panel -->
|
||||
{#if queue.length > 0}
|
||||
<div class="upload-panel" role="status">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
{#if allSettled}
|
||||
Uploads complete
|
||||
{:else}
|
||||
Uploading {queue.filter((i) => i.status === 'uploading').length} file(s)…
|
||||
{/if}
|
||||
</span>
|
||||
{#if allSettled}
|
||||
<button class="clear-btn" onclick={clearQueue}>Dismiss</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ul class="upload-list">
|
||||
{#each queue as item (item.id)}
|
||||
<li class="upload-item" class:done={item.status === 'done'} class:error={item.status === 'error'}>
|
||||
<span class="item-name" title={item.name}>{item.name}</span>
|
||||
<div class="item-right">
|
||||
{#if item.status === 'uploading'}
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width: {item.progress}%"></div>
|
||||
</div>
|
||||
<span class="pct">{item.progress}%</span>
|
||||
{:else if item.status === 'done'}
|
||||
<svg class="icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-label="Done">
|
||||
<path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="err-msg" title={item.error}>{item.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ---- Drop zone ---- */
|
||||
.drop-zone {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background-color: color-mix(in srgb, var(--color-accent) 18%, rgba(0, 0, 0, 0.7));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px dashed var(--color-accent);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Upload panel ---- */
|
||||
.upload-panel {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 65px;
|
||||
z-index: 110;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 16px rgba(0, 0, 0, 0.6);
|
||||
padding: 10px 12px;
|
||||
animation: slide-up 0.18s ease-out;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upload-item.done .item-name {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.upload-item.error .item-name {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.item-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background-color: color-mix(in srgb, var(--color-accent) 20%, var(--color-bg-elevated));
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.pct {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.icon-ok {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.err-msg {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-danger);
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -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();
|
||||
</script>
|
||||
|
||||
@ -32,6 +34,15 @@
|
||||
{$selectionActive ? 'Cancel' : 'Select'}
|
||||
</button>
|
||||
|
||||
{#if onUpload}
|
||||
<button class="upload-btn icon-btn" onclick={onUpload} title="Upload files">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 2v9M4 6l4-4 4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 13h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="controls">
|
||||
<select
|
||||
class="sort-select"
|
||||
|
||||
@ -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,6 +197,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<FileUpload bind:this={uploader} onUploaded={handleUploaded}>
|
||||
<main>
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
@ -213,6 +222,7 @@
|
||||
<div class="empty">No files yet.</div>
|
||||
{/if}
|
||||
</main>
|
||||
</FileUpload>
|
||||
</div>
|
||||
|
||||
{#if $selectionActive}
|
||||
|
||||
@ -265,6 +265,38 @@ export function mockApiPlugin(): Plugin {
|
||||
return json(res, 200, getMockFile(id));
|
||||
}
|
||||
|
||||
// POST /files — upload (mock: drain body, return a new fake file)
|
||||
if (method === 'POST' && path === '/files') {
|
||||
// Drain the multipart body without parsing it
|
||||
await new Promise<void>((resolve) => {
|
||||
req.on('data', () => {});
|
||||
req.on('end', resolve);
|
||||
});
|
||||
const idx = MOCK_FILES.length;
|
||||
const id = `00000000-0000-7000-8000-${String(Date.now()).slice(-12)}`;
|
||||
const ct = req.headers['content-type'] ?? '';
|
||||
// Extract filename from Content-Disposition if present (best-effort)
|
||||
const nameMatch = ct.match(/name="([^"]+)"/);
|
||||
const newFile = {
|
||||
id,
|
||||
original_name: nameMatch ? nameMatch[1] : `upload-${idx + 1}.jpg`,
|
||||
mime_type: 'image/jpeg',
|
||||
mime_extension: 'jpg',
|
||||
content_datetime: new Date().toISOString(),
|
||||
notes: null,
|
||||
metadata: null,
|
||||
exif: {},
|
||||
phash: null,
|
||||
creator_id: 1,
|
||||
creator_name: 'admin',
|
||||
is_public: false,
|
||||
is_deleted: false,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
MOCK_FILES.unshift(newFile);
|
||||
return json(res, 201, newFile);
|
||||
}
|
||||
|
||||
// GET /files (cursor pagination + anchor support)
|
||||
if (method === 'GET' && path === '/files') {
|
||||
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user