From 4e1f1d6426c4bc108c942274e22d52b97bed2398 Mon Sep 17 00:00:00 2001 From: fabi Date: Wed, 1 Apr 2026 18:59:23 +0200 Subject: [PATCH] feat: implement client-side upload queue with IndexedDB persistence - upload-queue.ts: IndexedDB-backed queue manager using idb library - File blobs stored in IndexedDB (survives page reloads) - Sequential upload processing (one file at a time) - XHR-based upload with per-file progress tracking - Retry failed uploads, remove/clear completed items - Auto-resumes pending items on page load - UploadQueue.svelte: queue progress UI component - Per-file: filename, size, progress bar, status badge - Retry button on failed items, remove button, clear completed - Processing indicator with pulse animation - /upload page: file picker (multiple, image/video) with caption + hashtags - Drop zone UI with drag-and-drop styling - Caption supports inline #hashtags - Separate comma-separated hashtags field - Link to gallery feed Co-Authored-By: Claude Opus 4.6 --- .../src/lib/components/UploadQueue.svelte | 103 ++++++++ frontend/src/lib/upload-queue.ts | 233 ++++++++++++++++++ frontend/src/routes/upload/+page.svelte | 79 ++++++ 3 files changed, 415 insertions(+) create mode 100644 frontend/src/lib/components/UploadQueue.svelte create mode 100644 frontend/src/lib/upload-queue.ts create mode 100644 frontend/src/routes/upload/+page.svelte diff --git a/frontend/src/lib/components/UploadQueue.svelte b/frontend/src/lib/components/UploadQueue.svelte new file mode 100644 index 0000000..e67b324 --- /dev/null +++ b/frontend/src/lib/components/UploadQueue.svelte @@ -0,0 +1,103 @@ + + +{#if items.length > 0} +
+
+

+ Upload-Warteschlange + {#if $isProcessing} + + {/if} +

+ {#if hasCompleted} + + {/if} +
+ +
    + {#each items as item (item.id)} +
  • +
    +
    +

    {item.fileName}

    +

    {formatSize(item.fileSize)}

    +
    +
    + + {statusLabel(item.status)} + + {#if item.status === 'error'} + + {/if} + {#if item.status === 'done' || item.status === 'error'} + + {/if} +
    +
    + + {#if item.status === 'uploading'} +
    +
    +
    +

    {item.progress}%

    + {/if} + + {#if item.error} +

    {item.error}

    + {/if} +
  • + {/each} +
+
+{/if} diff --git a/frontend/src/lib/upload-queue.ts b/frontend/src/lib/upload-queue.ts new file mode 100644 index 0000000..eb1fe9f --- /dev/null +++ b/frontend/src/lib/upload-queue.ts @@ -0,0 +1,233 @@ +import { openDB, type IDBPDatabase } from 'idb'; +import { writable, get } from 'svelte/store'; +import { getToken } from './auth'; + +export interface QueueItem { + id: string; + fileName: string; + fileSize: number; + mimeType: string; + caption: string; + hashtags: string; + status: 'pending' | 'uploading' | 'done' | 'error'; + progress: number; + error?: string; +} + +// Store does NOT hold file blobs — those stay in IndexedDB only +export const queueItems = writable([]); +export const isProcessing = writable(false); + +const DB_NAME = 'eventsnap-uploads'; +const STORE_NAME = 'queue'; + +let db: IDBPDatabase | null = null; + +async function getDb(): Promise { + if (db) return db; + db = await openDB(DB_NAME, 1, { + upgrade(database) { + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + } + }); + return db; +} + +export async function loadQueue(): Promise { + const database = await getDb(); + const all = await database.getAll(STORE_NAME); + const items: QueueItem[] = all.map((entry) => ({ + id: entry.id, + fileName: entry.fileName, + fileSize: entry.fileSize, + mimeType: entry.mimeType, + caption: entry.caption ?? '', + hashtags: entry.hashtags ?? '', + status: entry.status === 'uploading' ? 'pending' : entry.status, + progress: entry.status === 'done' ? 100 : 0, + error: entry.error + })); + queueItems.set(items); +} + +export async function addToQueue( + file: File, + caption: string, + hashtags: string +): Promise { + const database = await getDb(); + const id = crypto.randomUUID(); + const entry = { + id, + fileName: file.name, + fileSize: file.size, + mimeType: file.type, + caption, + hashtags, + status: 'pending', + blob: file + }; + await database.put(STORE_NAME, entry); + + queueItems.update((items) => [ + ...items, + { + id, + fileName: file.name, + fileSize: file.size, + mimeType: file.type, + caption, + hashtags, + status: 'pending', + progress: 0 + } + ]); + + processQueue(); +} + +export async function retryItem(id: string): Promise { + const database = await getDb(); + const entry = await database.get(STORE_NAME, id); + if (!entry) return; + + entry.status = 'pending'; + entry.error = undefined; + await database.put(STORE_NAME, entry); + + queueItems.update((items) => + items.map((item) => + item.id === id ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item + ) + ); + + processQueue(); +} + +export async function removeItem(id: string): Promise { + const database = await getDb(); + await database.delete(STORE_NAME, id); + queueItems.update((items) => items.filter((item) => item.id !== id)); +} + +export async function clearCompleted(): Promise { + const database = await getDb(); + const items = get(queueItems); + for (const item of items) { + if (item.status === 'done') { + await database.delete(STORE_NAME, item.id); + } + } + queueItems.update((items) => items.filter((item) => item.status !== 'done')); +} + +let processing = false; + +async function processQueue(): Promise { + if (processing) return; + processing = true; + isProcessing.set(true); + + try { + while (true) { + const items = get(queueItems); + const next = items.find((item) => item.status === 'pending'); + if (!next) break; + + await uploadItem(next.id); + } + } finally { + processing = false; + isProcessing.set(false); + } +} + +async function uploadItem(id: string): Promise { + const database = await getDb(); + const entry = await database.get(STORE_NAME, id); + if (!entry || !entry.blob) { + // No blob — mark as error + updateItemStatus(id, 'error', 'Datei nicht gefunden.'); + return; + } + + updateItemStatus(id, 'uploading'); + + const token = getToken(); + if (!token) { + updateItemStatus(id, 'error', 'Nicht angemeldet.'); + return; + } + + try { + const formData = new FormData(); + formData.append('file', entry.blob, entry.fileName); + if (entry.caption) formData.append('caption', entry.caption); + if (entry.hashtags) formData.append('hashtags', entry.hashtags); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/v1/upload'); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + queueItems.update((items) => + items.map((item) => (item.id === id ? { ...item, progress: pct } : item)) + ); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + try { + const body = JSON.parse(xhr.responseText); + reject(new Error(body.message || `HTTP ${xhr.status}`)); + } catch { + reject(new Error(`HTTP ${xhr.status}`)); + } + } + }); + + xhr.addEventListener('error', () => reject(new Error('Netzwerkfehler'))); + xhr.addEventListener('abort', () => reject(new Error('Abgebrochen'))); + xhr.send(formData); + }); + + // Success — remove blob from IndexedDB, mark done + entry.status = 'done'; + delete entry.blob; + await database.put(STORE_NAME, entry); + updateItemStatus(id, 'done'); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.'; + entry.status = 'error'; + entry.error = msg; + await database.put(STORE_NAME, entry); + updateItemStatus(id, 'error', msg); + } +} + +function updateItemStatus( + id: string, + status: QueueItem['status'], + error?: string +): void { + queueItems.update((items) => + items.map((item) => + item.id === id + ? { + ...item, + status, + progress: status === 'done' ? 100 : status === 'error' ? item.progress : item.progress, + error + } + : item + ) + ); +} diff --git a/frontend/src/routes/upload/+page.svelte b/frontend/src/routes/upload/+page.svelte new file mode 100644 index 0000000..1368d2e --- /dev/null +++ b/frontend/src/routes/upload/+page.svelte @@ -0,0 +1,79 @@ + + +
+
+
+

Hochladen

+ Zur Galerie +
+ +
+ + +
+ + +
+
+ + +
+