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
+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 });
};