feat: bulk ZIP download, bulk move-to-folder, folder rename

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-23 20:23:03 +00:00
parent 7608bc62da
commit 3bfeadbaa0
3 changed files with 96 additions and 2 deletions
+32 -2
View File
@@ -47,6 +47,19 @@ export default function LibraryApp() {
setPick(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`); 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 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) => { const alternative = async (id: string) => {
notify('Erzeuge Alternative …'); notify('Erzeuge Alternative …');
const r = await fetch(`/api/items/${id}/alternative`, { method: 'POST' }).then((x) => x.json()); 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); if (folderFilter === id) setFolderFilter(null);
load(); 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. // Nach Ordner filtern, dann Alternativen unter ihrem Ursprung gruppieren.
const filtered = folderFilter ? items.filter((v) => v.folder_id === folderFilter) : items; const filtered = folderFilter ? items.filter((v) => v.folder_id === folderFilter) : items;
@@ -89,6 +109,12 @@ export default function LibraryApp() {
{pick.size > 0 && <> {pick.size > 0 && <>
<span className="fein">{pick.size} gewählt</span> <span className="fein">{pick.size} gewählt</span>
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button> <button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
<button className="mini" onClick={zipSelected}>ZIP laden</button>
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
<option value="" disabled>In Ordner </option>
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
<option value="__none">Kein Ordner</option>
</select>
<button className="mini" onClick={delSelected}>Löschen</button> <button className="mini" onClick={delSelected}>Löschen</button>
</>} </>}
<button className="mini" onClick={newFolder}>Neuer Ordner</button> <button className="mini" onClick={newFolder}>Neuer Ordner</button>
@@ -103,7 +129,10 @@ export default function LibraryApp() {
<button className="chip" onClick={() => setFolderFilter(folderFilter === o.id ? null : o.id)}> <button className="chip" onClick={() => setFolderFilter(folderFilter === o.id ? null : o.id)}>
{o.name} · {o.count}{o.picdrop_gallery ? ' →' : ''} {o.name} · {o.count}{o.picdrop_gallery ? ' →' : ''}
</button> </button>
{folderFilter === o.id && <button className="chip-x" title="Ordner löschen" onClick={() => delFolder(o.id)}></button>} {folderFilter === o.id && <>
<button className="chip-x" title="Ordner umbenennen" onClick={() => renameFolder(o)}></button>
<button className="chip-x del" title="Ordner löschen" onClick={() => delFolder(o.id)}></button>
</>}
</span> </span>
))} ))}
</div> </div>
@@ -189,7 +218,8 @@ function LibStyles() {
.chip-wrap{display:inline-flex;align-items:center;border-radius:20px;} .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{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.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{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;} .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;} .galerie{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;align-items:start;}
+14
View File
@@ -21,6 +21,20 @@ export const POST: APIRoute = async ({ request, locals }) => {
return json({ folder: row }); 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=… // Ordner löschen (Bilder bleiben, verlieren nur die Zuordnung). ?id=…
export const DELETE: APIRoute = async ({ request, url, locals }) => { export const DELETE: APIRoute = async ({ request, url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
+50
View File
@@ -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<string, number> = {};
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"`,
},
});
};