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
This commit is contained in:
2026-07-23 14:08:13 +00:00
parent e6bc8954af
commit 1fb42862be
6 changed files with 511 additions and 16 deletions
+128
View File
@@ -0,0 +1,128 @@
import React, { useState, useEffect, useCallback } from 'react';
const STATUS_DE: Record<string, string> = {
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<any>(null);
const [items, setItems] = useState<any[]>([]);
const [jobs, setJobs] = useState<any[]>([]);
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 (
<div className="q">
<div className="kopfzeile"><span className="mono-label">Aufträge</span></div>
{jobs.length === 0 ? <p className="fein">Noch keine Aufträge. Leg im Studio los.</p> : (
<div className="joblist">
{jobs.map((j) => (
<a key={j.id} className="jobrow" href={`/warteschlange?job=${j.id}`}>
<span className={`punkt ${j.status}`} />
<b>{j.done_count}/{j.total}</b>
<span className="fein">{STATUS_DE[j.status] || j.status} · {j.by_name || ''}</span>
</a>
))}
</div>
)}
<QStyles />
</div>
);
}
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 (
<div className="q">
<div className="kopfzeile">
<span className="mono-label">Auftrag · {STATUS_DE[job?.status] || job?.status}</span>
<div className="reihe">
{job?.status === 'running' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/pause`)}>Anhalten</button>}
{job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>Fortsetzen</button>}
{failed > 0 && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/retry-failed`)}>Fehlgeschlagene erneut</button>}
{['queued', 'running', 'paused'].includes(job?.status) && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/cancel`)}>Abbrechen</button>}
</div>
</div>
<div className="fortschritt">
<div className="bar"><i style={{ width: `${pct}%` }} /></div>
<div className="fein">{done} fertig{failed ? `, ${failed} fehlgeschlagen` : ''} von {items.length}
{open > 0 && ` · noch ca. ${etaSec >= 60 ? Math.ceil(etaSec / 60) + ' Min' : etaSec + ' Sek'}`}</div>
</div>
<div className="items">
{items.map((it) => (
<div key={it.id} className={`item ${it.status}`}>
<div className="thumb">
{it.status === 'done' && it.result_path
? <img src={`/api/items/${it.id}/file`} alt="" loading="lazy" />
: <span className={`punkt ${it.status}`} />}
</div>
<div className="itxt">
<b>{it.filename || `Bild ${it.position + 1}`}</b>
<span className="fein">{STATUS_DE[it.status]}{it.output_px ? ` · ${it.output_px}px` : ''}</span>
{it.error_message && <span className="fein err">{it.error_message}</span>}
</div>
<div className="iact">
{it.status === 'done' && <a className="mini" href={`/api/items/${it.id}/file?download=1`}>Laden</a>}
{it.status === 'failed' && <button className="mini" onClick={() => act(`/api/items/${it.id}/retry`)}>Erneut</button>}
</div>
</div>
))}
</div>
<QStyles />
</div>
);
}
function QStyles() {
return <style>{`
.q{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);gap:10px;flex-wrap:wrap;}
.reihe{display:flex;gap:6px;flex-wrap:wrap;}
.fortschritt{padding:16px;border-bottom:1px solid var(--line);}
.bar{height:6px;background:var(--paper);border-radius:20px;overflow:hidden;}
.bar i{display:block;height:100%;background:var(--accent);transition:width .4s;}
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;display:block;}
.fein.err{color:var(--err);}
.items{display:flex;flex-direction:column;}
.item{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);}
.thumb{width:44px;height:44px;flex:none;background:var(--paper);border:1px solid var(--line);border-radius:3px;overflow:hidden;display:flex;align-items:center;justify-content:center;}
.thumb img{width:100%;height:100%;object-fit:cover;}
.itxt{flex:1;min-width:0;}
.itxt b{font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.iact{flex:none;}
.mini{background:#fff;border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
.punkt{width:11px;height:11px;border-radius:50%;background:var(--mark);display:inline-block;}
.punkt.running{background:var(--accent);}
.punkt.done{background:var(--ok);}
.punkt.failed{background:var(--err);}
.punkt.queued{background:var(--mark);}
.joblist{display:flex;flex-direction:column;}
.jobrow{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line);text-decoration:none;color:var(--ink);}
p.fein{padding:16px;}
`}</style>;
}
+308
View File
@@ -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<Pic[]>([]);
const [tasks, setTasks] = useState<string[]>(['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<string | null>(null);
const fileRef = useRef<HTMLInputElement>(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 (
<div className="studio">
<section className="karte">
<div className="kopfzeile">
<span className="mono-label">Vorlagen{pics.length ? ` · ${pics.length}` : ''}</span>
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>}
</div>
<div className="buehne">
{pics.length === 0 ? (
<div className={`drop ${dragging ? 'drag' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
<span className="regmark" />
<p>Bilder hierher ziehen,<br />mit <span className="kbd">/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
<p className="fein">Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.</p>
</div>
) : (
<div className="mini-raster">
{pics.map((b) => (
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
{b.src && <img src={b.src} alt="" />}
{b.uploading && <span className="lade" />}
{b.error && <span className="badge">!</span>}
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
</div>
))}
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
onChange={(e) => e.target.files && upload(e.target.files)} />
</div>
</section>
<section className="karte">
<div className="steuer">
{recipes?.length > 0 && (
<div className="feld">
<label>Rezept laden</label>
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
<option value="" disabled>Voreinstellung wählen </option>
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
)}
<div className="feld">
<label>Was soll passieren?</label>
<div className="modi">
{TASKS.map((m) => {
const on = tasks.includes(m.id);
const disabled = m.id === 'contour' && !hasCutout;
return (
<button key={m.id} disabled={disabled}
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
onClick={() => toggleTask(m.id)}>
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
</button>
);
})}
</div>
</div>
{hasFormat && (
<div className="feld">
<label>Ausgabeformat</label>
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
</select>
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn bitte eins wählen.</div>}
{fmt?.cm && !fmt.screen && (
<div className="schalter">
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
</div>
)}
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi druckfertig.</div>}
{zielPx && (
<div className="schalter zart">
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Ränder ergänzen</button>
</div>
)}
</div>
)}
{tasks.includes('contour') && (
<div className="feld">
<label>Stickerrand (mm)</label>
<input className="input" type="number" min={0} max={20} step={0.5}
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
</div>
)}
<div className="feld">
<label>Eigene Anweisung (optional)</label>
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
placeholder="z. B. „Mach den Hintergrund heller, sonst alles lassen.“" />
</div>
<div className="feld">
<label>Wohin?</label>
<div className="schalter">
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
</div>
</div>
<button className="knopf" disabled={!canRun} onClick={run}>
{busy ? <><span className="spin" />Wird angelegt </> : `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`}
</button>
<div className="fein mitte">Läuft serverseitig weiter du kannst das Fenster schließen.</div>
</div>
</section>
{toast && <div className="toast">{toast}</div>}
<StudioStyles />
</div>
);
}
function StudioStyles() {
return <style>{`
.studio{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
@media(max-width:820px){.studio{grid-template-columns:1fr;}}
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;}
.buehne{padding:22px;min-height:290px;display:flex;align-items:center;justify-content:center;}
.drop{width:100%;min-height:250px;border:1.5px dashed var(--line);border-radius:var(--radius-sm);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:26px;cursor:pointer;}
.drop.drag{border-color:var(--accent);background:var(--accent-bg);}
.drop p{margin:0;color:var(--soft);font-size:14px;line-height:1.55;}
.kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;}
.mini-raster{display:grid;grid-template-columns:repeat(auto-fill,minmax(78px,1fr));gap:8px;width:100%;}
.mini-bild{position:relative;aspect-ratio:3/4;background:var(--paper);border:1px solid var(--line);border-radius:3px;overflow:hidden;}
.mini-bild.err{border-color:var(--err);}
.mini-bild img{width:100%;height:100%;object-fit:cover;display:block;}
.mini-bild>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;}
.mini-bild .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;}
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
.mini-add{aspect-ratio:3/4;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
.input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
.input:focus,.select:focus,.area:focus{border-color:var(--accent);}
.area{font-size:13.5px;line-height:1.5;resize:vertical;}
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
.fein.mitte{text-align:center;}
.fein.warn{color:var(--err);}
.modi{display:flex;flex-direction:column;gap:6px;}
.modus{text-align:left;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;cursor:pointer;font-family:inherit;}
.modus b{display:block;font-size:14px;font-weight:600;}
.modus span{display:block;font-size:11.5px;color:var(--soft);margin-top:2px;}
.modus.an{border-color:var(--accent);background:var(--accent-bg);}
.modus.aus{opacity:.5;cursor:not-allowed;}
.schalter{display:flex;gap:6px;margin-top:8px;}
.schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
.schalter.zart button{font-size:12.5px;}
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
.knopf:disabled{background:#C9C8BF;cursor:not-allowed;}
.spin{width:13px;height:13px;display:inline-block;border:2px solid #fff;border-top-color:transparent;border-radius:50%;vertical-align:-2px;margin-right:9px;animation:sp 1s linear infinite;}
@keyframes sp{to{transform:rotate(360deg);}}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
`}</style>;
}
+26
View File
@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { one } from '../../../../lib/db';
import { getObject } from '../../../../lib/storage';
export const prerender = false;
// Streamt das Ergebnis- (oder Quell-)Bild aus dem Objektspeicher.
export const GET: APIRoute = async ({ params, url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const which = url.searchParams.get('src') === '1' ? 'source_path' : 'result_path';
const item = await one<any>(`SELECT ${which} AS path, filename, has_alpha FROM items WHERE id=$1`, [params.id]);
if (!item?.path) return new Response('Nicht gefunden', { status: 404 });
try {
const buf = await getObject(item.path);
const download = url.searchParams.get('download') === '1';
return new Response(buf, {
headers: {
'Content-Type': item.has_alpha ? 'image/png' : 'image/png',
'Cache-Control': 'private, max-age=300',
...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}),
},
});
} catch {
return new Response('Datei nicht verfügbar', { status: 404 });
}
};
+35
View File
@@ -0,0 +1,35 @@
import type { APIRoute } from 'astro';
import { query } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
// Bibliothek mit Filtern: ?task=&format=&folder=&delivery=&by=
export const GET: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const q = url.searchParams;
const where: string[] = [`i.status='done'`];
const args: any[] = [];
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
if (q.get('folder')) add('i.folder_id = ?', q.get('folder'));
if (q.get('delivery')) add('i.delivery_status = ?', q.get('delivery'));
if (q.get('by')) add('j.created_by = ?', q.get('by'));
if (q.get('format')) add('i.output_px = ?', q.get('format'));
if (q.get('task')) {
args.push(JSON.stringify([q.get('task')]));
where.push(`j.recipe_snapshot->'tasks' @> $${args.length}::jsonb`);
}
const rows = await query(
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.folder_id,
i.delivery_status, i.created_at, u.display_name AS by_name,
j.recipe_snapshot->'tasks' AS tasks
FROM items i
JOIN jobs j ON j.id = i.job_id
LEFT JOIN users u ON u.id = j.created_by
WHERE ${where.join(' AND ')}
ORDER BY i.created_at DESC LIMIT 300`, args);
return json({ items: rows });
};
+4 -16
View File
@@ -1,21 +1,9 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
const user = Astro.locals.user!; import StudioApp from '../components/StudioApp.tsx';
import { query } from '../lib/db';
const recipes = await query('SELECT * FROM recipes ORDER BY is_default DESC, name').catch(() => []);
--- ---
<Base title="Studio · Klarbild"> <Base title="Studio · Klarbild">
<section> <StudioApp client:load recipes={recipes} />
<h1 class="display">Studio</h1>
<p class="lead">Willkommen, {user.name}. Ziehe Screenshots herein, wähle ein Rezept — Klarbild macht daraus druckfertige Bilder.</p>
<div class="soon card">
<strong>Aufbau läuft.</strong>
<p>Das Fundament steht (Login, Datenbank, Objektspeicher, Health). Als Nächstes: Upload, Rezepte, Warteschlange und die Bildpipeline.</p>
</div>
</section>
<style>
.display { font-family: var(--font-display); font-size: 2rem; margin: 8px 0 6px; letter-spacing: -0.02em; }
.lead { color: var(--ink-soft); max-width: 60ch; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 20px; box-shadow: var(--shadow); margin-top: 20px; max-width: 60ch; }
.card p { color: var(--ink-mute); margin: 6px 0 0; }
</style>
</Base> </Base>
+10
View File
@@ -0,0 +1,10 @@
---
import Base from '../layouts/Base.astro';
import QueueApp from '../components/QueueApp.tsx';
const jobId = Astro.url.searchParams.get('job');
---
<Base title="Warteschlange · Klarbild">
<h1 class="seiten-titel">Warteschlange</h1>
<QueueApp client:load jobId={jobId} />
<style>.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 16px;letter-spacing:-.02em;}</style>
</Base>