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:
Generated
+20
@@ -12,6 +12,8 @@
|
||||
"@astrojs/react": "^4.2.1",
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"@fontsource/space-grotesk": "^5.1.0",
|
||||
"@fontsource/space-mono": "^5.1.0",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.41.1",
|
||||
"astro": "^5.5.0",
|
||||
@@ -1163,6 +1165,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/space-grotesk": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/space-grotesk/-/space-grotesk-5.3.0.tgz",
|
||||
"integrity": "sha512-ksnGizDPXIDuvqcTYTSrmZ+evx9sDlS8rp7+42BQ7wU+spt3twEoXfbJz672C+5CLg6VeUQwRy5RXshWb67LcQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/space-mono": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/space-mono/-/space-mono-5.3.0.tgz",
|
||||
"integrity": "sha512-FrpBMOVWn3PRdRHlrZWC55X3kZG/2BTH7SM5ZyS/bky3iKya2BturI5lk+t0RoDQL2JyHzURsVq/EisCsxpI5A==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@grammyjs/types": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-4.0.0.tgz",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.1.3",
|
||||
"@astrojs/react": "^4.2.1",
|
||||
"@fontsource/space-grotesk": "^5.1.0",
|
||||
"@fontsource/space-mono": "^5.1.0",
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"archiver": "^7.0.1",
|
||||
|
||||
+10
-3
@@ -1,4 +1,9 @@
|
||||
---
|
||||
import '@fontsource/space-grotesk/400.css';
|
||||
import '@fontsource/space-grotesk/500.css';
|
||||
import '@fontsource/space-grotesk/700.css';
|
||||
import '@fontsource/space-mono/400.css';
|
||||
import '@fontsource/space-mono/700.css';
|
||||
import '../styles/tokens.css';
|
||||
interface Props { title?: string }
|
||||
const { title = 'Klarbild' } = Astro.props;
|
||||
@@ -15,7 +20,7 @@ const user = Astro.locals.user;
|
||||
<body>
|
||||
{user && (
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand">Klarbild</a>
|
||||
<a href="/" class="brand"><span class="regmark"></span>klarbild</a>
|
||||
<nav>
|
||||
<a href="/">Studio</a>
|
||||
<a href="/bibliothek">Bibliothek</a>
|
||||
@@ -36,8 +41,10 @@ const user = Astro.locals.user;
|
||||
padding: 12px 20px; border-bottom: 1px solid var(--line);
|
||||
background: var(--surface); position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.brand { font-family: var(--font-display); font-weight: 600; font-size: 1.25rem;
|
||||
color: var(--ink); text-decoration: none; letter-spacing: -0.01em; }
|
||||
.brand { display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 1.3rem;
|
||||
color: var(--ink); text-decoration: none; letter-spacing: -0.02em; }
|
||||
.brand .regmark { transform: translateY(1px); }
|
||||
.topbar nav { display: flex; gap: 16px; margin-left: 8px; }
|
||||
.topbar nav a { color: var(--ink-soft); text-decoration: none; font-size: 0.95rem; }
|
||||
.topbar nav a:hover { color: var(--ink); }
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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' } });
|
||||
}
|
||||
+41
-43
@@ -1,52 +1,37 @@
|
||||
/* Klarbild — Design-Tokens (OKLCH). Eigenständige, ruhige Produkt-Identität.
|
||||
Kein Tailwind. Feinschliff folgt in der Design-Phase. */
|
||||
/* Klarbild — Design-Tokens (OKLCH; siehe 06 §6). Identität nach Till-Mockup:
|
||||
Space Grotesk + Space Mono, kräftiges Blau, warmes Papier/Karte, Passermarke,
|
||||
scharfe Ecken, technisch-editorial. Fonts self-hosted (kein Google/US). */
|
||||
:root {
|
||||
/* Flächen */
|
||||
--paper: oklch(98.5% 0.006 95); /* warmes Off-White */
|
||||
--surface: oklch(100% 0 0);
|
||||
--surface-2: oklch(96.5% 0.008 95);
|
||||
--line: oklch(89% 0.008 95);
|
||||
/* Flächen (Papier/Karte) */
|
||||
--paper: oklch(92.8% 0.005 95); /* #EAEAE3 */
|
||||
--card: oklch(98.4% 0.004 95); /* #FBFBF7 */
|
||||
--surface: var(--card);
|
||||
--line: oklch(86.8% 0.007 95); /* #D7D6CC */
|
||||
--mark: oklch(70% 0.006 95); /* #A7A69B Passermarke */
|
||||
|
||||
/* Text */
|
||||
--ink: oklch(24% 0.02 260); /* warmes Fast-Schwarz */
|
||||
--ink-soft: oklch(45% 0.02 260);
|
||||
--ink-mute: oklch(62% 0.015 260);
|
||||
--ink: oklch(21% 0.01 95); /* #16150F */
|
||||
--ink-soft: oklch(49% 0.008 95); /* #6A685D */
|
||||
--soft: var(--ink-soft);
|
||||
|
||||
/* Akzent — klares Petrol/Teal */
|
||||
--accent: oklch(62% 0.12 210);
|
||||
--accent-ink: oklch(30% 0.07 210);
|
||||
--accent-bg: oklch(95% 0.03 210);
|
||||
|
||||
/* Sekundär — warmer Sand */
|
||||
--sand: oklch(85% 0.05 75);
|
||||
/* Akzent — kräftiges Blau */
|
||||
--accent: oklch(45% 0.23 267); /* #1B3BE0 */
|
||||
--accent-2: oklch(38% 0.21 267); /* #122BA8 */
|
||||
--accent-bg: oklch(45% 0.23 267 / 0.06);
|
||||
|
||||
/* Zustände */
|
||||
--ok: oklch(65% 0.14 150);
|
||||
--warn: oklch(75% 0.14 75);
|
||||
--err: oklch(58% 0.17 25);
|
||||
--ok: oklch(60% 0.15 150);
|
||||
--warn: oklch(72% 0.15 75);
|
||||
--err: oklch(55% 0.19 25);
|
||||
|
||||
/* Form */
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--shadow: 0 1px 2px oklch(24% 0.02 260 / 0.06), 0 8px 24px oklch(24% 0.02 260 / 0.06);
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display: "Fraunces", Georgia, serif;
|
||||
--maxw: 1160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--paper: oklch(22% 0.012 260);
|
||||
--surface: oklch(26% 0.014 260);
|
||||
--surface-2: oklch(30% 0.014 260);
|
||||
--line: oklch(36% 0.014 260);
|
||||
--ink: oklch(96% 0.006 95);
|
||||
--ink-soft: oklch(82% 0.01 95);
|
||||
--ink-mute: oklch(66% 0.012 95);
|
||||
--accent: oklch(72% 0.11 210);
|
||||
--accent-bg: oklch(32% 0.05 210);
|
||||
--shadow: 0 1px 2px oklch(0% 0 0 / 0.3), 0 10px 30px oklch(0% 0 0 / 0.35);
|
||||
}
|
||||
--radius: 4px;
|
||||
--radius-sm: 3px;
|
||||
--shadow: 0 1px 2px oklch(21% 0.01 95 / 0.05);
|
||||
--font-sans: 'Space Grotesk', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-mono: 'Space Mono', ui-monospace, monospace;
|
||||
--maxw: 1060px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -57,8 +42,21 @@ body {
|
||||
font-size: 16px; line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--accent-ink); }
|
||||
a { color: var(--accent); }
|
||||
|
||||
/* Mono-Label (uppercase, gesperrt) — durchgängiges UI-Motiv */
|
||||
.mono-label {
|
||||
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: var(--soft);
|
||||
}
|
||||
|
||||
/* Passermarke (Kreuz) — Markenelement */
|
||||
.regmark { width: 15px; height: 15px; position: relative; display: inline-block; }
|
||||
.regmark::before, .regmark::after { content: ''; position: absolute; background: var(--accent); }
|
||||
.regmark::before { left: 50%; top: 0; width: 1.5px; height: 100%; transform: translateX(-50%); }
|
||||
.regmark::after { top: 50%; left: 0; height: 1.5px; width: 100%; transform: translateY(-50%); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
|
||||
}
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user