feat: upload/recipe/job API routes + Klarbild visual identity

- api: /uploads (multipart, HEIC, 25MB guard, S3), /recipes (GET/POST),
  /jobs (create+snapshot+enqueue, list), /jobs/:id (status+items),
  /jobs/:id/:action (pause/resume/cancel/retry-failed), /items/:id/retry
- design: adopt Till mockup identity — Space Grotesk + Space Mono (self-hosted,
  no google/US), blue accent, paper/card, registration-mark motif; OKLCH tokens
- verified: astro build passes
This commit is contained in:
2026-07-23 11:47:27 +00:00
parent 577b13f680
commit c12d55368c
10 changed files with 285 additions and 46 deletions
+18
View File
@@ -0,0 +1,18 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../../lib/db';
import { enqueue } from '../../../../lib/queue';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const item = await one<{ id: string; job_id: string }>(
'SELECT id, job_id FROM items WHERE id=$1', [params.id]);
if (!item) return json({ error: 'Nicht gefunden.' }, 404);
await query(`UPDATE items SET status='queued', error_message=NULL WHERE id=$1`, [item.id]);
await query(`UPDATE jobs SET status='queued' WHERE id=$1 AND status IN ('done','paused')`, [item.job_id]);
await enqueue({ itemId: item.id, jobId: item.job_id });
return json({ ok: true });
};
+17
View File
@@ -0,0 +1,17 @@
import type { APIRoute } from 'astro';
import { one, 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' } });
export const GET: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const job = await one('SELECT * FROM jobs WHERE id=$1', [params.id]);
if (!job) return json({ error: 'Nicht gefunden.' }, 404);
const items = await query(
`SELECT id, position, status, filename, output_px, has_alpha, error_message,
result_path, delivery_status, cost FROM items WHERE job_id=$1 ORDER BY position`,
[params.id]);
return json({ job, items });
};
+41
View File
@@ -0,0 +1,41 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../../lib/db';
import { enqueue } from '../../../../lib/queue';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const id = params.id!;
const job = await one<{ id: string }>('SELECT id FROM jobs WHERE id=$1', [id]);
if (!job) return json({ error: 'Nicht gefunden.' }, 404);
switch (params.action) {
case 'pause':
await query(`UPDATE jobs SET status='paused' WHERE id=$1`, [id]);
break;
case 'resume': {
await query(`UPDATE jobs SET status='queued' WHERE id=$1`, [id]);
const items = await query<{ id: string }>(
`SELECT id FROM items WHERE job_id=$1 AND status IN ('queued','running')`, [id]);
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
break;
}
case 'cancel':
await query(`UPDATE jobs SET status='cancelled', finished_at=now() WHERE id=$1`, [id]);
await query(`UPDATE items SET status='skipped' WHERE job_id=$1 AND status IN ('queued','running')`, [id]);
break;
case 'retry-failed': {
const items = await query<{ id: string }>(
`UPDATE items SET status='queued', error_message=NULL WHERE job_id=$1 AND status='failed' RETURNING id`, [id]);
await query(`UPDATE jobs SET status='queued', failed_count=0 WHERE id=$1`, [id]);
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
break;
}
default:
return json({ error: 'Unbekannte Aktion.' }, 400);
}
return json({ ok: true });
};
+61
View File
@@ -0,0 +1,61 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { enqueue } from '../../../lib/queue';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const rows = await query(
`SELECT j.*, u.display_name AS by_name FROM jobs j
LEFT JOIN users u ON u.id=j.created_by ORDER BY j.created_at DESC LIMIT 50`);
return json({ jobs: rows });
};
// Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}], origin? }
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const sources: any[] = b.sources || [];
if (!sources.length) return json({ error: 'Keine Bilder.' }, 400);
let snapshot: any = b.recipe;
if (b.recipeId) {
const r = await one('SELECT * FROM recipes WHERE id=$1', [b.recipeId]);
if (!r) return json({ error: 'Rezept nicht gefunden.' }, 404);
snapshot = r;
}
if (!snapshot) return json({ error: 'Kein Rezept.' }, 400);
// Rezept-Snapshot einfrieren
const snap = {
tasks: snapshot.tasks || [],
output_format: snapshot.output_format,
orientation: snapshot.orientation,
crop_mode: snapshot.crop_mode || 'crop',
dpi: snapshot.dpi || 300,
contour_mm: snapshot.contour_mm ?? null,
model_key: snapshot.model_key ?? null,
custom_instruction: snapshot.custom_instruction ?? null,
delivery: snapshot.delivery || 'library',
picdrop_gallery: snapshot.picdrop_gallery ?? null,
};
const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total)
VALUES ($1,$2,$3,'queued',$4) RETURNING id`,
[locals.user.uid, b.origin || 'web', JSON.stringify(snap), sources.length]);
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const item = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality)
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`,
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']);
await enqueue({ itemId: item!.id, jobId: job!.id });
}
return json({ jobId: job!.id });
};
+25
View File
@@ -0,0 +1,25 @@
import type { APIRoute } from 'astro';
import { query, one } 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' } });
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const rows = await query('SELECT * FROM recipes ORDER BY is_default DESC, name');
return json({ recipes: rows });
};
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const row = await one(
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm,
model_key, delivery, picdrop_gallery, custom_instruction, is_default, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,false,$12) RETURNING *`,
[b.name, JSON.stringify(b.tasks || []), b.output_format, b.orientation, b.crop_mode || 'crop',
b.dpi || 300, b.contour_mm ?? null, b.model_key ?? null, b.delivery || 'library',
b.picdrop_gallery ?? null, b.custom_instruction ?? null, locals.user.uid]);
return json({ recipe: row });
};
+50
View File
@@ -0,0 +1,50 @@
import type { APIRoute } from 'astro';
import { randomUUID } from 'node:crypto';
import sharp from 'sharp';
import { putObject, sourceKey } from '../../lib/storage';
import { heicToPng } from '../../lib/pipeline';
export const prerender = false;
const MAX_BYTES = 25 * 1024 * 1024;
const OK_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/heic', 'image/heif'];
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const form = await request.formData();
const files = form.getAll('files').filter((f): f is File => f instanceof File);
if (!files.length) return json({ error: 'Keine Dateien.' }, 400);
const out: any[] = [];
for (const file of files) {
if (file.size > MAX_BYTES) { out.push({ filename: file.name, error: 'Größer als 25 MB.' }); continue; }
const isImg = OK_TYPES.includes(file.type) || /\.(png|jpe?g|webp|heic|heif)$/i.test(file.name);
if (!isImg) { out.push({ filename: file.name, error: 'Nicht unterstütztes Format.' }); continue; }
try {
let buf = Buffer.from(await file.arrayBuffer());
let ext = 'png';
const heic = /heic|heif/i.test(file.type) || /\.(heic|heif)$/i.test(file.name);
if (heic) buf = await heicToPng(buf);
else {
const meta = await sharp(buf, { failOn: 'none' }).metadata();
ext = meta.format === 'jpeg' ? 'jpg' : (meta.format || 'png');
}
const meta = await sharp(buf, { failOn: 'none' }).metadata();
const key = sourceKey(randomUUID(), ext);
await putObject(key, buf, `image/${ext === 'jpg' ? 'jpeg' : ext}`);
out.push({
source_path: key, filename: file.name,
width: meta.width, height: meta.height,
source_quality: 'original',
});
} catch (e: any) {
out.push({ filename: file.name, error: 'Konnte nicht gelesen werden.' });
}
}
return json({ files: out });
};
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}