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:
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user