From 1fb42862be83b730d0d36c0dd04e9e9547bb8604 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Thu, 23 Jul 2026 14:08:13 +0000 Subject: [PATCH] feat: Studio UI (upload/recipe/submit island) + queue view + library/file API - StudioApp.tsx: upload (file/DnD/paste, HEIC, to /api/uploads), combinable task toggles with validation (contour needs cutout, freistellen+theframe blocked), format+orientation+live px+crop, contour mm, custom instruction, delivery, recipe prefill - QueueApp.tsx + warteschlange.astro: live polling, progress+ETA, per-item status, retry/pause/resume/cancel, result thumbnails+download - api/items (library filters), api/items/:id/file (stream result/source from S3) - index.astro renders Studio island --- src/components/QueueApp.tsx | 128 +++++++++++++ src/components/StudioApp.tsx | 308 +++++++++++++++++++++++++++++++ src/pages/api/items/[id]/file.ts | 26 +++ src/pages/api/items/index.ts | 35 ++++ src/pages/index.astro | 20 +- src/pages/warteschlange.astro | 10 + 6 files changed, 511 insertions(+), 16 deletions(-) create mode 100644 src/components/QueueApp.tsx create mode 100644 src/components/StudioApp.tsx create mode 100644 src/pages/api/items/[id]/file.ts create mode 100644 src/pages/api/items/index.ts create mode 100644 src/pages/warteschlange.astro diff --git a/src/components/QueueApp.tsx b/src/components/QueueApp.tsx new file mode 100644 index 0000000..5f1305b --- /dev/null +++ b/src/components/QueueApp.tsx @@ -0,0 +1,128 @@ +import React, { useState, useEffect, useCallback } from 'react'; + +const STATUS_DE: Record = { + queued: 'wartet', running: 'läuft', done: 'fertig', failed: 'fehlgeschlagen', + skipped: 'übersprungen', paused: 'angehalten', cancelled: 'abgebrochen', +}; + +export default function QueueApp({ jobId }: { jobId: string | null }) { + const [job, setJob] = useState(null); + const [items, setItems] = useState([]); + const [jobs, setJobs] = useState([]); + + const load = useCallback(async () => { + if (jobId) { + const r = await fetch(`/api/jobs/${jobId}`).then((x) => x.json()).catch(() => null); + if (r?.job) { setJob(r.job); setItems(r.items || []); } + } else { + const r = await fetch('/api/jobs').then((x) => x.json()).catch(() => null); + setJobs(r?.jobs || []); + } + }, [jobId]); + + useEffect(() => { + load(); + const active = !job || ['queued', 'running', 'paused'].includes(job?.status); + const t = setInterval(load, active ? 2000 : 6000); + return () => clearInterval(t); + }, [load, job?.status]); + + const act = async (path: string) => { await fetch(path, { method: 'POST' }); load(); }; + + if (!jobId) { + return ( +
+
Aufträge
+ {jobs.length === 0 ?

Noch keine Aufträge. Leg im Studio los.

: ( + + )} + +
+ ); + } + + const done = items.filter((i) => i.status === 'done').length; + const failed = items.filter((i) => i.status === 'failed').length; + const open = items.filter((i) => ['queued', 'running'].includes(i.status)).length; + const pct = items.length ? Math.round(((done + failed) / items.length) * 100) : 0; + const etaSec = open * 20; + + return ( +
+
+ Auftrag · {STATUS_DE[job?.status] || job?.status} +
+ {job?.status === 'running' && } + {job?.status === 'paused' && } + {failed > 0 && } + {['queued', 'running', 'paused'].includes(job?.status) && } +
+
+ +
+
+
{done} fertig{failed ? `, ${failed} fehlgeschlagen` : ''} von {items.length} + {open > 0 && ` · noch ca. ${etaSec >= 60 ? Math.ceil(etaSec / 60) + ' Min' : etaSec + ' Sek'}`}
+
+ +
+ {items.map((it) => ( +
+
+ {it.status === 'done' && it.result_path + ? + : } +
+
+ {it.filename || `Bild ${it.position + 1}`} + {STATUS_DE[it.status]}{it.output_px ? ` · ${it.output_px}px` : ''} + {it.error_message && {it.error_message}} +
+
+ {it.status === 'done' && Laden} + {it.status === 'failed' && } +
+
+ ))} +
+ +
+ ); +} + +function QStyles() { + return ; +} diff --git a/src/components/StudioApp.tsx b/src/components/StudioApp.tsx new file mode 100644 index 0000000..1e57c8a --- /dev/null +++ b/src/components/StudioApp.tsx @@ -0,0 +1,308 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; + +/* Formate (01 §5) — cm in Hochformat-Konvention; screen = feste Pixel */ +const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [number, number] }[] = [ + { id: 'keep', label: 'Original beibehalten' }, + { id: '9x13', label: '9 × 13 cm', cm: [9, 13] }, + { id: '10x15', label: '10 × 15 cm', cm: [10, 15] }, + { id: '13x18', label: '13 × 18 cm', cm: [13, 18] }, + { id: '15x20', label: '15 × 20 cm', cm: [15, 20] }, + { id: '20x30', label: '20 × 30 cm', cm: [20, 30] }, + { id: '30x40', label: '30 × 40 cm', cm: [30, 40] }, + { id: '30x45', label: '30 × 45 cm', cm: [30, 45] }, + { id: '40x50', label: '40 × 50 cm', cm: [40, 50] }, + { id: '40x60', label: '40 × 60 cm', cm: [40, 60] }, + { id: '50x70', label: '50 × 70 cm', cm: [50, 70] }, + { id: '60x90', label: '60 × 90 cm', cm: [60, 90] }, + { id: 'A4', label: 'DIN A4', cm: [21, 29.7] }, + { id: 'A3', label: 'DIN A3', cm: [29.7, 42] }, + { id: 'A2', label: 'DIN A2', cm: [42, 59.4] }, + { id: '20x20', label: '20 × 20 cm', cm: [20, 20] }, + { id: '30x30', label: '30 × 30 cm', cm: [30, 30] }, + { id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] }, + { id: 'hochformat', label: 'Hochformat (9:16)', screen: [2160, 3840] }, + { id: 'sticker', label: 'Sticker (freies Maß)', cm: [5, 5] }, +]; + +const TASKS = [ + { id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche entfernen.' }, + { id: 'cutout', name: 'Freistellen', hint: 'Nur das Motiv, Hintergrund transparent.' }, + { id: 'format', name: 'Format', hint: 'Auf ein bestimmtes Maß bringen.' }, + { id: 'contour', name: 'Kontur', hint: 'Weißen Stickerrand anlegen (nur mit Freistellen).' }, +]; + +const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi); + +interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean } + +export default function StudioApp({ recipes }: { recipes: any[] }) { + const [pics, setPics] = useState([]); + const [tasks, setTasks] = useState(['clean', 'format']); + const [format, setFormat] = useState('30x40'); + const [portrait, setPortrait] = useState(true); + const [crop, setCrop] = useState<'crop' | 'extend'>('crop'); + const [contourMm, setContourMm] = useState(3); + const [custom, setCustom] = useState(''); + const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library'); + const [dragging, setDragging] = useState(false); + const [busy, setBusy] = useState(false); + const [toast, setToast] = useState(null); + const fileRef = useRef(null); + + const fmt = FORMATS.find((f) => f.id === format); + const hasCutout = tasks.includes('cutout'); + const hasFormat = tasks.includes('format'); + const theframeConflict = hasCutout && (format === 'theframe' || format === 'hochformat'); + + const zielPx: [number, number] | null = fmt?.screen + ? (fmt.screen as [number, number]) + : fmt?.cm + ? (portrait ? [px(fmt.cm[0]), px(fmt.cm[1])] : [px(fmt.cm[1]), px(fmt.cm[0])]) + : null; + + const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; + + const upload = useCallback(async (files: FileList | File[]) => { + const arr = Array.from(files).filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name)).slice(0, 100); + for (const f of arr) { + const id = crypto.randomUUID(); + const reader = new FileReader(); + reader.onload = () => setPics((p) => p.map((x) => x.id === id ? { ...x, src: reader.result as string } : x)); + reader.readAsDataURL(f); + setPics((p) => [...p, { id, src: '', name: f.name, uploading: true }]); + const fd = new FormData(); fd.append('files', f); + try { + const res = await fetch('/api/uploads', { method: 'POST', body: fd }); + const j = await res.json(); + const info = j.files?.[0]; + setPics((p) => p.map((x) => x.id === id + ? { ...x, uploading: false, source_path: info?.source_path, error: info?.error } : x)); + } catch { + setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: 'Upload fehlgeschlagen' } : x)); + } + } + }, []); + + useEffect(() => { + const onPaste = (e: ClipboardEvent) => { + const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/')); + if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean)); + }; + window.addEventListener('paste', onPaste); + return () => window.removeEventListener('paste', onPaste); + }, [upload]); + + function toggleTask(id: string) { + setTasks((t) => { + let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id]; + if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour'); // Kontur braucht Freistellen + if (id === 'contour' && next.includes('contour') && !next.includes('cutout')) next = next; // wird per disabled verhindert + return next; + }); + } + + function applyRecipe(r: any) { + if (!r) return; + setTasks(r.tasks || ['clean', 'format']); + setFormat(r.output_format || 'keep'); + setPortrait((r.orientation || 'portrait') !== 'landscape'); + setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop'); + setContourMm(Number(r.contour_mm) || 3); + setCustom(r.custom_instruction || ''); + setDelivery(r.delivery || 'library'); + } + + const ready = pics.filter((p) => p.source_path && !p.error); + const canRun = ready.length > 0 && !busy && tasks.length > 0 && !theframeConflict && + !(tasks.includes('contour') && !hasCutout); + + async function run() { + if (!canRun) return; + setBusy(true); + const recipe = { + tasks: [...tasks].sort((a, b) => ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(a) - ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(b)), + output_format: hasFormat ? format : 'keep', + orientation: portrait ? 'portrait' : 'landscape', + crop_mode: crop, dpi: 300, + contour_mm: tasks.includes('contour') ? contourMm : null, + custom_instruction: custom || null, delivery, + }; + try { + const res = await fetch('/api/jobs', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipe, sources: ready.map((p) => ({ source_path: p.source_path, filename: p.name })) }), + }); + const j = await res.json(); + if (j.jobId) location.href = `/warteschlange?job=${j.jobId}`; + else { notify(j.error || 'Fehler beim Anlegen.'); setBusy(false); } + } catch { notify('Netzwerkfehler.'); setBusy(false); } + } + + return ( +
+
+
+ Vorlagen{pics.length ? ` · ${pics.length}` : ''} + {pics.length > 0 && } +
+
+ {pics.length === 0 ? ( +
fileRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}> + +

Bilder hierher ziehen,
mit ⌘/Strg + V einfügen
oder zum Auswählen tippen.

+

Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.

+
+ ) : ( +
+ {pics.map((b) => ( +
+ {b.src && } + {b.uploading && } + {b.error && !} + +
+ ))} + +
+ )} + e.target.files && upload(e.target.files)} /> +
+
+ +
+
+ {recipes?.length > 0 && ( +
+ + +
+ )} + +
+ +
+ {TASKS.map((m) => { + const on = tasks.includes(m.id); + const disabled = m.id === 'contour' && !hasCutout; + return ( + + ); + })} +
+
+ + {hasFormat && ( +
+ + + {theframeConflict &&
Freistellen + The Frame ergibt keinen Sinn — bitte eins wählen.
} + {fmt?.cm && !fmt.screen && ( +
+ + +
+ )} + {zielPx &&
Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi — druckfertig.
} + {zielPx && ( +
+ + +
+ )} +
+ )} + + {tasks.includes('contour') && ( +
+ + setContourMm(Number(e.target.value))} /> +
+ )} + +
+ +