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:
@@ -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 && <>
|
||||
<span className="fein">{pick.size} gewählt</span>
|
||||
<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={newFolder}>Neuer Ordner</button>
|
||||
@@ -103,7 +129,10 @@ export default function LibraryApp() {
|
||||
<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>}
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
@@ -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;}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"`,
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user