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"`,
+ },
+ });
+};