diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx
index 7051cd8..38d4053 100644
--- a/src/components/LibraryApp.tsx
+++ b/src/components/LibraryApp.tsx
@@ -28,6 +28,11 @@ export default function LibraryApp() {
};
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 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 toggle = (id: string) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const newFolder = async () => {
@@ -43,7 +48,7 @@ export default function LibraryApp() {
{sel.size > 0 && <>
{sel.size} gewählt
- {[...sel].map((id) =>
)}
+
>}
@@ -62,7 +67,11 @@ export default function LibraryApp() {
{sel.has(v.id) ? '✓' : ''}
rename(v.id, e.target.value)} />
- {(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}
+ {(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}
+ {v.delivery_status === 'delivered' && Picdrop ✓}
+ {v.delivery_status === 'pending' && Picdrop …}
+ {v.delivery_status === 'failed' && Picdrop ✕}
+
Laden
@@ -112,6 +121,10 @@ function LibStyles() {
.kachel.gewaehlt .haken{background:var(--accent);}
.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;}
+ .dbadge{display:inline-block;margin-left:6px;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;text-transform:uppercase;background:var(--paper);color:var(--soft);padding:1px 6px;border-radius:20px;}
+ .dbadge.ok{background:var(--accent-bg);color:var(--accent);}
+ .dbadge.err{color:var(--err);}
.mini-select{border:1px solid var(--line);border-radius:3px;padding:5px 7px;font-family:inherit;font-size:12px;background:#fff;}
.lupe{position:fixed;inset:0;background:rgba(22,21,15,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:26px;z-index:50;}
.vergleich{position:relative;max-width:90vw;max-height:76vh;}
diff --git a/src/lib/delivery.ts b/src/lib/delivery.ts
new file mode 100644
index 0000000..576845a
--- /dev/null
+++ b/src/lib/delivery.ts
@@ -0,0 +1,44 @@
+import { one, query } from './db';
+import { getObject } from './storage';
+import { loadConfig, uploadBuffer } from './picdrop';
+
+// Standard-Galerie wie initial gebrieft.
+const DEFAULT_GALLERY = process.env.PICDROP_DEFAULT_GALLERY || 'POSTER LEA';
+
+async function galleryFor(item: { folder_id: string | null; job_id: string }): Promise
{
+ if (item.folder_id) {
+ const f = await one<{ picdrop_gallery: string | null }>('SELECT picdrop_gallery FROM folders WHERE id=$1', [item.folder_id]);
+ if (f?.picdrop_gallery) return f.picdrop_gallery;
+ }
+ const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
+ const g = j?.recipe_snapshot?.picdrop_gallery;
+ return g || DEFAULT_GALLERY;
+}
+
+/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */
+export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> {
+ const it = await one('SELECT id, result_path, filename, folder_id, job_id FROM items WHERE id=$1', [itemId]);
+ if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' };
+ const cfg = await loadConfig();
+ if (!cfg) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Picdrop nicht konfiguriert.' }; }
+
+ await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]);
+ try {
+ const gallery = await galleryFor(it);
+ const buf = await getObject(it.result_path);
+ await uploadBuffer(cfg, gallery, it.filename || `${it.id}.png`, buf);
+ await query(`UPDATE items SET delivery_status='delivered', delivered_at=now() WHERE id=$1`, [itemId]);
+ return { ok: true, message: `Ausgeliefert nach „${gallery}".` };
+ } catch (e: any) {
+ console.error('[delivery] Item', itemId, 'fehlgeschlagen:', e?.message || e);
+ await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]);
+ return { ok: false, message: e?.message || 'Auslieferung fehlgeschlagen.' };
+ }
+}
+
+/** Alle noch offenen (pending) Auslieferungen eines Auftrags abarbeiten. */
+export async function deliverPendingForJob(jobId: string): Promise {
+ const items = await query<{ id: string }>(
+ `SELECT id FROM items WHERE job_id=$1 AND delivery_status='pending' AND status='done'`, [jobId]);
+ for (const it of items) await deliverItem(it.id);
+}
diff --git a/src/pages/api/deliver.ts b/src/pages/api/deliver.ts
new file mode 100644
index 0000000..a8b3bd6
--- /dev/null
+++ b/src/pages/api/deliver.ts
@@ -0,0 +1,18 @@
+import type { APIRoute } from 'astro';
+import { deliverItem } from '../../lib/delivery';
+
+export const prerender = false;
+const json = (b: unknown, s = 200) =>
+ new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
+
+// Body: { itemIds: [...] } — Auswahl (nachträglich) nach Picdrop schicken.
+export const POST: APIRoute = async ({ request, locals }) => {
+ if (!locals.user) return new Response('Unauthorized', { status: 401 });
+ const b = await request.json();
+ const ids: string[] = b.itemIds || [];
+ if (!ids.length) return json({ error: 'Keine Auswahl.' }, 400);
+ const results = [];
+ for (const id of ids) results.push({ id, ...(await deliverItem(id)) });
+ const ok = results.filter((r) => r.ok).length;
+ return json({ delivered: ok, total: ids.length, results });
+};
diff --git a/src/worker.ts b/src/worker.ts
index 5469e07..0c8e83b 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -1,6 +1,7 @@
import { one, query } from './lib/db';
import { startWorker, type GenerateJob } from './lib/queue';
import { processItem } from './lib/process';
+import { deliverPendingForJob } from './lib/delivery';
/** Startet den Warteschlangen-Arbeiter (im selben Node-Prozess wie die App). */
export async function startImageWorker(): Promise {
@@ -53,6 +54,8 @@ async function maybeComplete(jobId: string): Promise {
if (row && row.open === 0) {
await query(`UPDATE jobs SET status='done', finished_at=now() WHERE id=$1 AND status<>'cancelled'`,
[jobId]);
- // TODO: Auslieferung (Picdrop) + Benachrichtigung (Telegram/n8n) anstoßen.
+ // Picdrop-Auslieferung für alle offenen Positionen anstoßen.
+ try { await deliverPendingForJob(jobId); } catch (e) { console.error('[worker] Auslieferung:', e); }
+ // TODO: Benachrichtigung (Telegram/n8n).
}
}