Compare commits

...

4 Commits

8 changed files with 254 additions and 42 deletions
+4
View File
@@ -0,0 +1,4 @@
-- Alternativen/Regenerierung: mehrere Ergebnisse zum selben Ursprung gruppieren.
-- variant_of zeigt auf das „Wurzel"-Item (die erste Fassung); Alternativen teilen es.
ALTER TABLE items ADD COLUMN IF NOT EXISTS variant_of uuid REFERENCES items(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS items_variant_idx ON items(variant_of);
+119 -39
View File
@@ -1,18 +1,28 @@
import React, { useState, useEffect, useCallback } from 'react';
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean;
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; model_used: string; }
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string;
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; created_at: string; }
export default function LibraryApp() {
const [items, setItems] = useState<Item[]>([]);
const [folders, setFolders] = useState<any[]>([]);
const [models, setModels] = useState<any[]>([]);
const modelLabel = (id: string) => models.find((m) => m.model_id === id)?.label || null;
const [sel, setSel] = useState<Set<string>>(new Set());
const [folderFilter, setFolderFilter] = useState<string | null>(null);
const [pick, setPick] = useState<Set<string>>(new Set());
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
const [compare, setCompare] = useState<Item | null>(null);
const [toast, setToast] = useState<string | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
// Modellname: Admin-Label, sonst lesbarer Rest der Modell-ID.
const modelName = (id: string | null) => {
if (!id) return null;
const m = models.find((x) => x.model_id === id);
if (m) return m.label;
return id.split('/').pop()!.replace(/-/g, ' ');
};
const load = useCallback(async () => {
const [i, f, m] = await Promise.all([
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
@@ -30,27 +40,52 @@ export default function LibraryApp() {
load();
};
const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); };
const delSelected = async () => { for (const id of sel) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setSel(new Set()); load(); notify('Gelöscht.'); };
const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify('Gelöscht.'); };
const deliverSelected = async () => {
notify('Sende an Picdrop …');
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...sel] }) }).then((x) => x.json());
setSel(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`);
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
setPick(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`);
};
const toggle = (id: string) => setPick((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const alternative = async (id: string) => {
notify('Erzeuge Alternative …');
const r = await fetch(`/api/items/${id}/alternative`, { method: 'POST' }).then((x) => x.json());
if (r.ok) notify('Alternative wird erzeugt — erscheint gleich hier.'); else notify(r.error || 'Fehler.');
setTimeout(load, 2500);
};
const toggle = (id: string) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const newFolder = async () => {
const name = prompt('Name des Ordners?'); if (!name) return;
await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
const gallery = prompt('Picdrop-Galerie für diesen Ordner? (leer = Standard)') || '';
await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, picdrop_gallery: gallery.trim() || null }) });
load();
};
const delFolder = async (id: string) => {
if (!confirm('Ordner löschen? Die Bilder bleiben erhalten.')) return;
await fetch(`/api/folders?id=${id}`, { method: 'DELETE' });
if (folderFilter === id) setFolderFilter(null);
load();
};
// Nach Ordner filtern, dann Alternativen unter ihrem Ursprung gruppieren.
const filtered = folderFilter ? items.filter((v) => v.folder_id === folderFilter) : items;
const groupMap = new Map<string, Item[]>();
for (const it of filtered) {
const root = it.variant_of || it.id;
if (!groupMap.has(root)) groupMap.set(root, []);
groupMap.get(root)!.push(it);
}
const groups = [...groupMap.values()].map((g) => g.slice().sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at)));
groups.sort((a, b) => +new Date(b[b.length - 1].created_at) - +new Date(a[a.length - 1].created_at));
const shown = (g: Item[]) => g.find((x) => x.id === variantSel[g[0].id]) || g[g.length - 1];
return (
<div>
<div className="kopfzeile">
<span className="mono-label">Bibliothek{items.length ? ` · ${items.length}` : ''}</span>
<span className="mono-label">Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}</span>
<div className="reihe">
{sel.size > 0 && <>
<span className="fein">{sel.size} gewählt</span>
{pick.size > 0 && <>
<span className="fein">{pick.size} gewählt</span>
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
<button className="mini" onClick={delSelected}>Löschen</button>
</>}
@@ -58,34 +93,68 @@ export default function LibraryApp() {
</div>
</div>
{items.length === 0 ? (
<div className="leer"><span className="regmark" /><h3>Noch nichts erstellt</h3>
<p>Lade im Studio ein paar Screenshots hoch die Ergebnisse sammeln sich hier.</p></div>
{/* Ordner-Filter */}
<div className="ordnerleiste">
<button className={`chip ${!folderFilter ? 'an' : ''}`} onClick={() => setFolderFilter(null)}>Alle · {items.length}</button>
{folders.map((o) => (
<span key={o.id} className={`chip-wrap ${folderFilter === o.id ? 'an' : ''}`}>
<button className="chip" onClick={() => setFolderFilter(folderFilter === o.id ? null : o.id)}>
{o.name} · {o.count}{o.picdrop_gallery ? ' →' : ''}
</button>
{folderFilter === o.id && <button className="chip-x" title="Ordner löschen" onClick={() => delFolder(o.id)}></button>}
</span>
))}
</div>
<div className="fein hinweis">Ordner gruppieren die Bibliothek und können je Ordner eine eigene Picdrop-Galerie bekommen (Pfeil = eigene Galerie hinterlegt).</div>
{filtered.length === 0 ? (
<div className="leer"><span className="regmark" /><h3>{folderFilter ? 'Ordner ist leer' : 'Noch nichts erstellt'}</h3>
<p>{folderFilter ? 'Weise Bildern über das Ordner-Menü diesen Ordner zu.' : 'Lege im Studio los — die Ergebnisse sammeln sich hier.'}</p></div>
) : (
<div className="galerie">
{items.map((v) => (
<article key={v.id} className={`kachel ${sel.has(v.id) ? 'gewaehlt' : ''}`}>
<div className="kachel-bild" onClick={() => toggle(v.id)}>
<img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
<span className="haken">{sel.has(v.id) ? '' : ''}</span>
</div>
<input className="name" defaultValue={v.filename || ''} onBlur={(e) => rename(v.id, e.target.value)} />
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelLabel(v.model_used) ? ` · ${modelLabel(v.model_used)}` : ''}
{v.delivery_status === 'delivered' && <span className="dbadge ok">Picdrop </span>}
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop </span>}
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop </span>}
</div>
<div className="reihe knapp">
<button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>
<a className="mini" href={`/api/items/${v.id}/file?download=1`}>Laden</a>
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
<option value="">Kein Ordner</option>
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
<button className="mini" onClick={() => del(v.id)}></button>
</div>
</article>
))}
{groups.map((g) => {
const v = shown(g);
const multi = g.length > 1;
return (
<article key={g[0].id} className={`kachel ${pick.has(v.id) ? 'gewaehlt' : ''}`}>
<div className="kachel-bild" onClick={() => toggle(v.id)}>
<img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
<span className="haken">{pick.has(v.id) ? '✓' : ''}</span>
{(v.mode === 'compose' || v.mode === 'generate') &&
<span className="modetag">{v.mode === 'compose' ? 'kombiniert' : 'erzeugt'}</span>}
{multi && <span className="fassung">Fassung {g.indexOf(v) + 1}/{g.length}</span>}
</div>
{multi && (
<div className="varianten">
{g.map((x, idx) => (
<button key={x.id} className={`vthumb ${x.id === v.id ? 'an' : ''}`}
onClick={() => setVariantSel((s) => ({ ...s, [g[0].id]: x.id }))} title={`Fassung ${idx + 1}`}>
<img src={`/api/items/${x.id}/file?thumb=1`} alt="" loading="lazy" />
</button>
))}
</div>
)}
<input className="name" defaultValue={v.filename || ''} key={v.id} onBlur={(e) => rename(v.id, e.target.value)} />
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelName(v.model_used) ? ` · ${modelName(v.model_used)}` : ''}
{v.delivery_status === 'delivered' && <span className="dbadge ok">Picdrop </span>}
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop </span>}
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop </span>}
</div>
<div className="reihe knapp">
<button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>
<a className="mini" href={`/api/items/${v.id}/file?download=1`}>Laden</a>
<button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alternative</button>
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
<option value="">Kein Ordner</option>
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
<button className="mini" onClick={() => del(v.id)}></button>
</div>
</article>
);
})}
</div>
)}
@@ -108,11 +177,16 @@ export default function LibraryApp() {
function LibStyles() {
return <style>{`
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap;}
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
.reihe{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}.reihe.knapp{margin-top:8px;}
.fein{font-size:11.5px;color:var(--soft);}
.hinweis{margin:0 0 14px;line-height:1.5;}
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
.hidden{display:none;}
.ordnerleiste{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-bottom:8px;}
.chip-wrap{display:inline-flex;align-items:center;border-radius:20px;}
.chip{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:6px 13px;cursor:pointer;font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;color:var(--soft);white-space:nowrap;}
.chip.an,.chip-wrap.an .chip{border-color:var(--accent);background:var(--accent-bg);color:var(--accent);}
.chip-x{border:none;background:none;color:var(--err);cursor:pointer;font-size:12px;padding:0 8px 0 2px;}
.leer{padding:60px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px;}
.leer h3{margin:0;font-size:17px;}.leer p{margin:0;color:var(--soft);max-width:38ch;}
.galerie{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;align-items:start;}
@@ -125,6 +199,12 @@ function LibStyles() {
.kachel-bild:active img{transform:scale(.98);}
.haken{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;}
.kachel.gewaehlt .haken{background:var(--accent);}
.modetag{position:absolute;top:6px;right:6px;background:rgba(59,91,219,.92);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:20px;}
.fassung{position:absolute;bottom:6px;right:6px;background:rgba(22,21,15,.7);color:#fff;font-family:var(--font-mono);font-size:9px;padding:2px 7px;border-radius:20px;}
.varianten{display:flex;gap:5px;margin-top:6px;overflow-x:auto;}
.vthumb{flex:0 0 auto;width:40px;height:40px;border:1px solid var(--line);border-radius:3px;overflow:hidden;padding:0;background:none;cursor:pointer;}
.vthumb.an{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
.vthumb img{width:100%;height:100%;object-fit:cover;display:block;}
.name{margin-top:8px;width:100%;font-size:12.5px;padding:6px 8px;font-family:var(--font-mono);border:1px solid var(--line);border-radius:3px;background:#fff;}
.meta{font-size:11px;color:var(--soft);margin-top:5px;}
.mini.stark{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;}
+2
View File
@@ -15,6 +15,7 @@ const nav = [
{ href: '/', label: 'Studio', icon: 'studio' },
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
];
@@ -24,6 +25,7 @@ const ICONS: Record<string, string> = {
library: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></svg>`,
queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`,
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
};
---
<!doctype html>
+4
View File
@@ -15,6 +15,10 @@ export async function seed(): Promise<void> {
('google/gemini-3.1-flash-image','Schnell','In wenigen Sekunden fertig. Für den Alltag.',true,true,true,0),
('google/gemini-3-pro-image','Beste Qualität','Dauert länger, trifft Schrift und Details genauer.',true,false,true,1)`);
}
// Open-Source-Option (FLUX.2, offene Gewichte) nachrüsten — auch für bestehende Installationen.
await query(`INSERT INTO models (model_id, label, description, active, is_default, supports_alpha, sort)
SELECT 'black-forest-labs/flux.2-flex','Open Source (FLUX.2)','Offene Gewichte, gute Qualität. Kein transparentes Freistellen.',true,false,false,2
WHERE NOT EXISTS (SELECT 1 FROM models WHERE model_id='black-forest-labs/flux.2-flex')`);
// Nutzer
const till = await one(`SELECT id FROM users WHERE username='till'`);
+83
View File
@@ -0,0 +1,83 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="Anleitung · Klarbild">
<div class="howto">
<div class="hkopf">
<h1>So funktioniert Klarbild</h1>
<button class="druck" onclick="window.print()">Als PDF / drucken</button>
</div>
<p class="lead">Aus Screenshots und Fundstücken werden druckfertige Bilder — bereinigt, freigestellt,
im richtigen Format, optional direkt bei Picdrop. Alles geht über die Website und über Telegram.</p>
<section>
<h2>1 · Bild bearbeiten</h2>
<ol>
<li>Im <b>Studio</b> oben <b>Bearbeiten</b> wählen.</li>
<li>Screenshots hochladen — ziehen, mit ⌘/Strg + V einfügen oder tippen (bis zu 100 auf einmal).</li>
<li>Wählen, was passieren soll: <b>Bereinigen</b> (Rahmen, Wand, Shop-Oberfläche weg),
<b>Freistellen</b> (Motiv frei, Hintergrund transparent), <b>Format</b> (z. B. 30 × 40 cm oder The Frame),
<b>Kontur</b> (weißer Stickerrand).</li>
<li>Beim Format „<b>Zuschneiden</b>" oder „<b>Erweitern</b>" wählen — Erweitern erzeugt mehr Szene rund ums
Motiv (Outpainting), ideal für The Frame.</li>
<li><b>Qualität</b> und Ziel (<b>Bibliothek</b>, <b>Picdrop</b> oder beides) wählen, dann <b>Loslegen</b>.</li>
</ol>
</section>
<section>
<h2>2 · Mehrere Bilder kombinieren</h2>
<p>Modus <b>Kombinieren</b>: mindestens zwei Bilder hochladen und beschreiben, was entstehen soll —
z. B. „das Poolbild, aber mit unserer Hündin Frieda am Beckenrand". Das erste Bild ist die Leitszene,
weitere liefern Personen oder Motive. Es entsteht ein neues, zusammengesetztes Bild.</p>
</section>
<section>
<h2>3 · Neues Bild aus Text</h2>
<p>Modus <b>Neu erzeugen</b>: ganz ohne Vorlage, nur aus einer Beschreibung. Je genauer der Text,
desto besser das Ergebnis.</p>
</section>
<section>
<h2>4 · Bibliothek, Ordner & Alternativen</h2>
<p>Alle Ergebnisse landen in der <b>Bibliothek</b>. Über die Ordner-Leiste oben lässt sich nach Ordner
filtern; jedem Ordner kann eine eigene Picdrop-Galerie zugeordnet werden. Mit <b>+ Alternative</b>
entsteht eine weitere Fassung desselben Bildes — die Fassungen liegen gruppiert beieinander und lassen
sich per Miniaturleiste umschalten. <b>Vorher/Nachher</b> zeigt den Vergleich zum Original.</p>
</section>
<section>
<h2>5 · Alles per Telegram</h2>
<ul>
<li>Einmalig koppeln: im Admin einen Code erzeugen und im Chat mit dem Bot eingeben.</li>
<li><b>Bearbeiten:</b> Bilder weiterleiten (am besten als <i>Datei</i> für volle Qualität) → Rezept wählen.</li>
<li><b>Kombinieren:</b> zwei oder mehr Bilder mit einer Bildunterschrift senden (z. B. „mit unserer Hündin
Frieda") → Knopf „🔀 Kombinieren".</li>
<li><b>Neu erzeugen:</b> <code>/neu &lt;Beschreibung&gt;</code> — z. B. <code>/neu ein Olivenzweig auf Sand</code>.</li>
<li>Der Bot meldet sich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.</li>
</ul>
</section>
<section>
<h2>Tipps</h2>
<ul>
<li>Für Druck immer 300 dpi und ein cm-Format wählen — die Pixelmaße stehen direkt dabei.</li>
<li>In Telegram Bilder als <i>Datei</i> senden, nicht als Foto — sonst komprimiert Telegram.</li>
<li>Sticker: Freistellen + Kontur zusammen wählen.</li>
</ul>
</section>
</div>
<style>
.howto{max-width:760px;margin:0 auto;}
.hkopf{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
.howto h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
.druck{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-family:inherit;font-size:.85rem;color:var(--ink);}
.lead{color:var(--soft);line-height:1.6;margin:4px 0 20px;}
.howto section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
.howto h2{font-size:1.05rem;margin:0 0 10px;}
.howto ol,.howto ul{margin:0;padding-left:20px;line-height:1.65;}
.howto li{margin-bottom:6px;}
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
@media print{.topbar,.tabbar,.druck{display:none !important;}.howto section{break-inside:avoid;}}
</style>
</Base>
+10
View File
@@ -20,3 +20,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
[b.name.trim(), b.picdrop_gallery || null]);
return json({ folder: row });
};
// Ordner löschen (Bilder bleiben, verlieren nur die Zuordnung). ?id=…
export const DELETE: APIRoute = async ({ request, url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
let id = url.searchParams.get('id');
if (!id) { try { id = (await request.json())?.id; } catch { /* egal */ } }
if (!id) return json({ error: 'Keine ID.' }, 400);
await query('DELETE FROM folders WHERE id=$1', [id]);
return json({ ok: true });
};
+29
View File
@@ -0,0 +1,29 @@
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' } });
/** Erzeugt eine weitere Fassung (Alternative) zum selben Ursprung, gleiches Rezept. */
export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const item = await one<any>(
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]);
if (!item) return json({ error: 'Nicht gefunden.' }, 404);
// Ohne Quelle & ohne Modellaufruf gibt es nichts zu variieren.
const root = item.variant_of || item.id;
const it = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_path, source_paths, filename, source_quality, variant_of)
VALUES ($1, (SELECT COALESCE(max(position),0)+1 FROM items WHERE job_id=$1), 'queued', $2, $3, $4, 'original', $5)
RETURNING id`,
[item.job_id, item.source_path, item.source_paths ? JSON.stringify(item.source_paths) : null,
item.filename, root]);
// Auftrag zählt eine Position mehr und läuft erneut an.
await query(`UPDATE jobs SET total=total+1, status='queued', finished_at=NULL
WHERE id=$1 AND status IN ('done','paused')`, [item.job_id]);
await enqueue({ itemId: it!.id, jobId: item.job_id });
return json({ ok: true, itemId: it!.id });
};
+3 -3
View File
@@ -23,13 +23,13 @@ export const GET: APIRoute = async ({ url, locals }) => {
}
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, i.model_used, u.display_name AS by_name,
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.thumb_path, i.folder_id,
i.delivery_status, i.created_at, i.model_used, i.variant_of, j.mode, 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);
ORDER BY i.created_at DESC LIMIT 400`, args);
return json({ items: rows });
};