diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx index 5db7e84..01d7c01 100644 --- a/src/components/LibraryApp.tsx +++ b/src/components/LibraryApp.tsx @@ -47,6 +47,19 @@ export default function LibraryApp() { 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 moveSelected = async (folder_id: string | null) => { + for (const id of pick) await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) }); + setPick(new Set()); load(); notify(folder_id ? 'In Ordner verschoben.' : 'Aus Ordner entfernt.'); + }; + const zipSelected = async () => { + notify('Packe ZIP …'); + const res = await fetch('/api/items/zip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }); + if (!res.ok) { notify('ZIP fehlgeschlagen.'); return; } + const blob = await res.blob(); + const a = document.createElement('a'); a.href = URL.createObjectURL(blob); + a.download = `klarbild_${new Date().toISOString().slice(0, 10)}.zip`; a.click(); + URL.revokeObjectURL(a.href); notify('ZIP geladen.'); + }; const alternative = async (id: string) => { notify('Erzeuge Alternative …'); const r = await fetch(`/api/items/${id}/alternative`, { method: 'POST' }).then((x) => x.json()); @@ -68,6 +81,13 @@ export default function LibraryApp() { if (folderFilter === id) setFolderFilter(null); load(); }; + const renameFolder = async (o: any) => { + const name = prompt('Ordner umbenennen:', o.name); if (name == null) return; + const gallery = prompt('Picdrop-Galerie (leer = Standard):', o.picdrop_gallery || ''); + await fetch('/api/folders', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: o.id, name: name.trim() || o.name, picdrop_gallery: (gallery || '').trim() || null }) }); + load(); + }; // Nach Ordner filtern, dann Alternativen unter ihrem Ursprung gruppieren. const filtered = folderFilter ? items.filter((v) => v.folder_id === folderFilter) : items; @@ -89,6 +109,12 @@ export default function LibraryApp() { {pick.size > 0 && <> {pick.size} gewählt + + } @@ -103,7 +129,10 @@ export default function LibraryApp() { - {folderFilter === o.id && } + {folderFilter === o.id && <> + + + } ))} @@ -189,7 +218,8 @@ function LibStyles() { .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;} + .chip-x{border:none;background:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0 4px;} + .chip-x.del{color:var(--err);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;} diff --git a/src/pages/api/folders/index.ts b/src/pages/api/folders/index.ts index e3759b4..e03ffdb 100644 --- a/src/pages/api/folders/index.ts +++ b/src/pages/api/folders/index.ts @@ -21,6 +21,20 @@ export const POST: APIRoute = async ({ request, locals }) => { return json({ folder: row }); }; +// Ordner umbenennen / Galerie ändern. Body { id, name?, picdrop_gallery? } +export const PATCH: APIRoute = async ({ request, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const b = await request.json(); + if (!b.id) return json({ error: 'Keine ID.' }, 400); + const sets: string[] = []; const args: any[] = []; + if (typeof b.name === 'string' && b.name.trim()) { args.push(b.name.trim()); sets.push(`name=$${args.length}`); } + if ('picdrop_gallery' in b) { args.push(b.picdrop_gallery?.trim() || null); sets.push(`picdrop_gallery=$${args.length}`); } + if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400); + args.push(b.id); + const row = await one(`UPDATE folders SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args); + 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 }); diff --git a/src/pages/api/items/zip.ts b/src/pages/api/items/zip.ts new file mode 100644 index 0000000..617b2b6 --- /dev/null +++ b/src/pages/api/items/zip.ts @@ -0,0 +1,50 @@ +import type { APIRoute } from 'astro'; +import archiver from 'archiver'; +import { query } from '../../../lib/db'; +import { getObject } from '../../../lib/storage'; + +export const prerender = false; + +/** Lädt mehrere Ergebnisse als ZIP. Body { itemIds: [...] } */ +export const POST: APIRoute = async ({ request, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const b = await request.json().catch(() => ({} as any)); + const ids: string[] = b.itemIds || []; + if (!ids.length) return new Response('Keine Auswahl.', { status: 400 }); + + const rows = await query<{ id: string; result_path: string | null; filename: string | null }>( + `SELECT id, result_path, filename FROM items WHERE id = ANY($1) AND result_path IS NOT NULL`, [ids]); + if (!rows.length) return new Response('Keine Ergebnisse.', { status: 404 }); + + const archive = archiver('zip', { zlib: { level: 6 } }); + const seen: Record = {}; + for (const r of rows) { + try { + const buf = await getObject(r.result_path!); + let name = r.filename || `${r.id}.png`; + if (!/\.[a-z0-9]+$/i.test(name)) name += '.png'; + // Namenskollisionen vermeiden + if (seen[name] != null) { const n = ++seen[name]; name = name.replace(/(\.[^.]+)$/, `_${n}$1`); } + else seen[name] = 0; + archive.append(buf, { name }); + } catch { /* Datei überspringen */ } + } + const done = archive.finalize(); + + // Archiver-Stream (Node) in einen Web-ReadableStream überführen. + const stream = new ReadableStream({ + start(controller) { + archive.on('data', (c: Buffer) => controller.enqueue(new Uint8Array(c))); + archive.on('end', () => controller.close()); + archive.on('error', (e: any) => controller.error(e)); + done.catch(() => {}); + }, + }); + const stamp = new Date().toISOString().slice(0, 10); + return new Response(stream, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="klarbild_${stamp}.zip"`, + }, + }); +};