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:
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -1,21 +1,9 @@
|
||||
---
|
||||
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">
|
||||
<section>
|
||||
<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>
|
||||
<StudioApp client:load recipes={recipes} />
|
||||
</Base>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user