diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx new file mode 100644 index 0000000..c69c833 --- /dev/null +++ b/src/components/AdminApp.tsx @@ -0,0 +1,188 @@ +import React, { useState, useEffect, useCallback } from 'react'; + +export default function AdminApp() { + const [s, setS] = useState(null); + const [stats, setStats] = useState(null); + const [models, setModels] = useState([]); + const [avail, setAvail] = useState([]); + const [users, setUsers] = useState([]); + const [toast, setToast] = useState(null); + const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); + const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; + + const load = useCallback(async () => { + const [a, b, c, d] = await Promise.all([ + fetch('/api/admin/settings').then((r) => r.json()), + fetch('/api/admin/stats').then((r) => r.json()), + fetch('/api/admin/models?available=1').then((r) => r.json()), + fetch('/api/admin/users').then((r) => r.json()), + ]); + setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []); setUsers(d.users || []); + }, []); + useEffect(() => { load(); }, [load]); + + const field = (k: string, v: any) => setS((p: any) => ({ ...p, [k]: v })); + const saveSettings = async () => { + const body: any = { + picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port, + picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path, + default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency, + cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url, + }; + if (orKey.trim()) body.openrouter_key = orKey.trim(); + if (pdPw.trim()) body.picdrop_password = pdPw.trim(); + await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + setOrKey(''); setPdPw(''); notify('Gespeichert.'); load(); + }; + const testPicdrop = async () => { + notify('Teste …'); + const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json()); + notify(r.message || (r.ok ? 'OK' : 'Fehler')); + }; + const addModel = async (m: any) => { + await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) }); + load(); + }; + const modelAction = async (id: string, action: string) => { + await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, action }) }); + load(); + }; + const setPw = async (id: string) => { + const pw = prompt('Neues Passwort?'); if (!pw) return; + await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) }); + notify('Passwort gesetzt.'); + }; + const addUser = async () => { + const username = prompt('Benutzername?'); if (!username) return; + const password = prompt('Passwort?'); if (!password) return; + await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); + load(); + }; + + if (!s) return

Lädt …

; + const stored = new Set(models.map((m) => m.model_id)); + + return ( +
+ {stats && ( +
+
Nutzung & Kosten
+
+
{stats.total}Bilder gesamt
+
{stats.error_rate}%Fehlerquote
+
${stats.cost_30?.toFixed(2)}Kosten 30 Tage
+
${stats.cost_all?.toFixed(2)}Kosten gesamt
+
+
Heute ${stats.cost_today?.toFixed(2)} · 7 Tage ${stats.cost_7?.toFixed(2)} + {stats.last_activity ? ` · zuletzt ${new Date(stats.last_activity).toLocaleString('de-DE')}` : ''}
+
+ )} + +
+
Zugang & Grenzen
+
+
+ setOrKey(e.target.value)} /> +
Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.
+
+
+ field('monthly_budget', e.target.value)} /> +
Bei Erreichen hält die Warteschlange an.
+
+
+
field('default_dpi', e.target.value)} />
+
field('concurrency', e.target.value)} />
+
+
field('cricut_sheet_cm', e.target.value)} />
+
+
+ +
+
Picdrop +
+
+
+
field('picdrop_host', e.target.value)} />
+
field('picdrop_port', e.target.value)} />
+
+
+
+
+
field('picdrop_user', e.target.value)} />
+
+
+ setPdPw(e.target.value)} />
+
field('picdrop_base_path', e.target.value)} />
+
+
+ + + +
+
Modelle
+
+ {models.map((m) => ( +
+
{m.label}{m.is_default && Standard}{m.supports_alpha && transparent}
{m.model_id}
+
+ {!m.is_default && } + +
+
+ ))} + {models.length === 0 &&
Noch keine Modelle. Aus verfügbaren hinzufügen:
} +
+ {avail.filter((m) => !stored.has(m.model_id)).slice(0, 12).map((m) => ( + + ))} +
+
+
+ +
+
Nutzer
+
+ {users.map((u) => ( +
+
{u.display_name || u.username} {u.username} · {u.role}
+ +
+ ))} +
+
+ + {toast &&
{toast}
} + +
+ ); +} + +function AdminStyles() { + return ; +} diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx new file mode 100644 index 0000000..7051cd8 --- /dev/null +++ b/src/components/LibraryApp.tsx @@ -0,0 +1,124 @@ +import React, { useState, useEffect, useCallback } from 'react'; + +interface Item { id: string; filename: string; output_px: string; has_alpha: boolean; + folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; } + +export default function LibraryApp() { + const [items, setItems] = useState([]); + const [folders, setFolders] = useState([]); + const [sel, setSel] = useState>(new Set()); + const [compare, setCompare] = useState(null); + const [toast, setToast] = useState(null); + const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); }; + + const load = useCallback(async () => { + const [i, f] = await Promise.all([ + fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })), + fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })), + ]); + setItems(i.items || []); setFolders(f.folders || []); + }, []); + useEffect(() => { load(); }, [load]); + + const rename = async (id: string, filename: string) => + fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) }); + const setFolder = async (id: string, folder_id: string | null) => { + await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) }); + load(); + }; + 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 toggle = (id: string) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); + + const newFolder = async () => { + const name = prompt('Name des Ordners?'); if (!name) return; + await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); + load(); + }; + + return ( +
+
+ Bibliothek{items.length ? ` · ${items.length}` : ''} +
+ {sel.size > 0 && <> + {sel.size} gewählt + {[...sel].map((id) => )} + + } + +
+
+ + {items.length === 0 ? ( +

Noch nichts erstellt

+

Lade im Studio ein paar Screenshots hoch — die Ergebnisse sammeln sich hier.

+ ) : ( +
+ )} + + {compare && ( +
setCompare(null)}> +
e.stopPropagation()}> + vorher + nachher + { const o = document.getElementById('ov'); if (o) o.style.clipPath = `inset(0 0 0 ${e.target.value}%)`; }} /> +
+
{compare.filename} · {compare.output_px}px — Regler: links Original, rechts Ergebnis
+
+ )} + {toast &&
{toast}
} + +
+ ); +} + +function LibStyles() { + return ; +} diff --git a/src/lib/picdrop.ts b/src/lib/picdrop.ts new file mode 100644 index 0000000..f31c72c --- /dev/null +++ b/src/lib/picdrop.ts @@ -0,0 +1,73 @@ +// Picdrop-Auslieferung via SFTP (ssh2-sftp-client) oder FTPS (basic-ftp). +// Konfiguration kommt aus settings (verschlüsseltes Passwort). +import posixpath from 'node:path/posix'; +import { one } from './db'; +import { decrypt } from './crypto'; + +export interface PicdropCfg { + host: string; protocol: 'ftps' | 'sftp'; port: number; + user: string; password: string; basePath: string; +} + +export async function loadConfig(): Promise { + const s = await one(`SELECT picdrop_host, picdrop_protocol, picdrop_port, picdrop_user, + picdrop_password_enc, picdrop_base_path FROM settings WHERE id=1`); + if (!s?.picdrop_host || !s?.picdrop_user || !s?.picdrop_password_enc) return null; + let password = ''; + try { password = decrypt(s.picdrop_password_enc); } catch { return null; } + return { + host: s.picdrop_host, protocol: (s.picdrop_protocol || 'sftp'), + port: s.picdrop_port || (s.picdrop_protocol === 'ftps' ? 21 : 22), + user: s.picdrop_user, password, basePath: s.picdrop_base_path || '/', + }; +} + +/** Verbindung testen: verbinden + Basisordner listen. */ +export async function testConnection(cfg: PicdropCfg): Promise<{ ok: boolean; message: string }> { + try { + if (cfg.protocol === 'sftp') { + const SftpClient = (await import('ssh2-sftp-client')).default; + const c = new SftpClient(); + await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 }); + await c.list(cfg.basePath || '/'); + await c.end(); + } else { + const { Client } = await import('basic-ftp'); + const c = new Client(15000); + await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true }); + await c.list(cfg.basePath || '/'); + c.close(); + } + return { ok: true, message: 'Verbindung erfolgreich.' }; + } catch (e: any) { + return { ok: false, message: e?.message || 'Verbindung fehlgeschlagen.' }; + } +} + +/** Lädt einen Puffer als Datei hoch (temp-Name → umbenennen, kein .filepart). */ +export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: string, buf: Buffer): Promise { + const dir = posixpath.join(cfg.basePath || '/', gallery || ''); + const finalPath = posixpath.join(dir, filename); + const tmpPath = posixpath.join(dir, `.tmp-${Date.now()}-${filename}`); + const { Readable } = await import('node:stream'); + + if (cfg.protocol === 'sftp') { + const SftpClient = (await import('ssh2-sftp-client')).default; + const c = new SftpClient(); + await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 }); + try { + if (!(await c.exists(dir))) await c.mkdir(dir, true); + await c.put(buf, tmpPath); + await c.rename(tmpPath, finalPath); + } finally { await c.end(); } + } else { + const { Client } = await import('basic-ftp'); + const c = new Client(20000); + await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true }); + try { + await c.ensureDir(dir); + await c.uploadFrom(Readable.from(buf), tmpPath); + await c.rename(tmpPath, finalPath); + } finally { c.close(); } + } +} diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro new file mode 100644 index 0000000..1924c26 --- /dev/null +++ b/src/pages/admin/index.astro @@ -0,0 +1,10 @@ +--- +import Base from '../../layouts/Base.astro'; +import AdminApp from '../../components/AdminApp.tsx'; +if (Astro.locals.user?.role !== 'admin') return Astro.redirect('/'); +--- + +

Admin

+ + + diff --git a/src/pages/api/admin/models.ts b/src/pages/api/admin/models.ts new file mode 100644 index 0000000..adb0aa0 --- /dev/null +++ b/src/pages/api/admin/models.ts @@ -0,0 +1,45 @@ +import type { APIRoute } from 'astro'; +import { one, query } from '../../../lib/db'; +import { listImageModels } from '../../../lib/openrouter'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +// GET: gespeicherte Modelle; ?available=1 zusätzlich die von OpenRouter verfügbaren. +export const GET: APIRoute = async ({ url }) => { + const models = await query('SELECT * FROM models ORDER BY sort, label'); + const out: any = { models }; + if (url.searchParams.get('available') === '1') { + try { + const avail = await listImageModels(); + out.available = avail.map((m: any) => ({ + model_id: m.id, name: m.name, + supports_alpha: (m.supported_parameters?.background?.values || []).includes('transparent') + || !!m.supported_parameters?.background, + })); + } catch (e: any) { out.available_error = e?.friendly || 'Modellliste nicht abrufbar.'; } + } + return json(out); +}; + +// POST: Modell anlegen/aktualisieren (upsert by model_id) oder Aktion (toggle/default/delete). +export const POST: APIRoute = async ({ request }) => { + const b = await request.json(); + if (b.action === 'delete' && b.id) { await query('DELETE FROM models WHERE id=$1', [b.id]); return json({ ok: true }); } + if (b.action === 'default' && b.id) { + await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]); + return json({ ok: true }); + } + if (!b.model_id || !b.label) return json({ error: 'model_id + label nötig.' }, 400); + const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]); + if (existing) { + await query(`UPDATE models SET label=$2, description=$3, active=$4, supports_alpha=$5, sort=$6 WHERE id=$1`, + [existing.id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]); + return json({ id: existing.id }); + } + const row = await one(`INSERT INTO models (model_id, label, description, active, supports_alpha, sort) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, + [b.model_id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]); + return json({ model: row }); +}; diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts new file mode 100644 index 0000000..e8e3626 --- /dev/null +++ b/src/pages/api/admin/settings.ts @@ -0,0 +1,38 @@ +import type { APIRoute } from 'astro'; +import { one, query } from '../../../lib/db'; +import { encrypt, maskSecret, decrypt } from '../../../lib/crypto'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +export const GET: APIRoute = async () => { + const s = await one('SELECT * FROM settings WHERE id=1'); + if (!s) return json({ settings: null }); + let orMask = ''; + if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'gesetzt'; } } + return json({ settings: { + openrouter_key_masked: orMask, openrouter_key_set: !!s.openrouter_key_enc, + picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port, + picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path, + picdrop_password_set: !!s.picdrop_password_enc, + default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency, + cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url, + } }); +}; + +export const PATCH: APIRoute = async ({ request }) => { + const b = await request.json(); + const sets: string[] = []; const args: any[] = []; + const set = (col: string, val: any) => { args.push(val); sets.push(`${col}=$${args.length}`); }; + + if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key))); + if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password))); + for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path', + 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url']) { + if (col in b) set(col, b[col] === '' ? null : b[col]); + } + if (!sets.length) return json({ ok: true }); + await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args); + return json({ ok: true }); +}; diff --git a/src/pages/api/admin/stats.ts b/src/pages/api/admin/stats.ts new file mode 100644 index 0000000..658473e --- /dev/null +++ b/src/pages/api/admin/stats.ts @@ -0,0 +1,25 @@ +import type { APIRoute } from 'astro'; +import { query, one } from '../../../lib/db'; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const total = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='done'`); + const failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`); + const cost = async (days: number | null) => (await one<{ c: string }>( + `SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` + + (days ? ` AND created_at > now() - interval '${days} days'` : '')) + )?.c || '0'; + const byModel = await query(`SELECT COALESCE(model_used,'—') m, count(*)::int n, COALESCE(sum(cost),0)::float c + FROM items WHERE status='done' GROUP BY 1 ORDER BY n DESC`); + const last = await one<{ t: string }>(`SELECT max(created_at)::text t FROM items`); + + const totalN = Number(total?.n || 0), failedN = Number(failed?.n || 0); + return new Response(JSON.stringify({ + total: totalN, failed: failedN, + error_rate: totalN + failedN ? Math.round((failedN / (totalN + failedN)) * 100) : 0, + cost_today: Number(await cost(1)), cost_7: Number(await cost(7)), + cost_30: Number(await cost(30)), cost_all: Number(await cost(null)), + by_model: byModel, last_activity: last?.t || null, + }), { headers: { 'Content-Type': 'application/json' } }); +}; diff --git a/src/pages/api/admin/test-picdrop.ts b/src/pages/api/admin/test-picdrop.ts new file mode 100644 index 0000000..5c4f6a1 --- /dev/null +++ b/src/pages/api/admin/test-picdrop.ts @@ -0,0 +1,12 @@ +import type { APIRoute } from 'astro'; +import { loadConfig, testConnection } from '../../../lib/picdrop'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +export const POST: APIRoute = async () => { + const cfg = await loadConfig(); + if (!cfg) return json({ ok: false, message: 'Picdrop nicht vollständig konfiguriert.' }); + return json(await testConnection(cfg)); +}; diff --git a/src/pages/api/admin/users.ts b/src/pages/api/admin/users.ts new file mode 100644 index 0000000..bdb93d3 --- /dev/null +++ b/src/pages/api/admin/users.ts @@ -0,0 +1,28 @@ +import type { APIRoute } from 'astro'; +import { one, query } from '../../../lib/db'; +import { hashPassword } from '../../../lib/auth'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +export const GET: APIRoute = async () => { + const users = await query('SELECT id, username, role, display_name, created_at FROM users ORDER BY username'); + return json({ users }); +}; + +// POST: neuen Nutzer anlegen ODER Passwort setzen (bei vorhandenem username/id). +export const POST: APIRoute = async ({ request }) => { + const b = await request.json(); + if (b.id && b.password) { + await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]); + return json({ ok: true }); + } + if (!b.username || !b.password) return json({ error: 'username + password nötig.' }, 400); + const exists = await one('SELECT id FROM users WHERE username=$1', [b.username.trim()]); + if (exists) return json({ error: 'Benutzername vergeben.' }, 400); + const row = await one(`INSERT INTO users (username, role, display_name, password_hash) + VALUES ($1,$2,$3,$4) RETURNING id, username, role, display_name`, + [b.username.trim(), b.role === 'admin' ? 'admin' : 'user', b.display_name || b.username.trim(), await hashPassword(b.password)]); + return json({ user: row }); +}; diff --git a/src/pages/api/folders/[id].ts b/src/pages/api/folders/[id].ts new file mode 100644 index 0000000..d0cf2db --- /dev/null +++ b/src/pages/api/folders/[id].ts @@ -0,0 +1,20 @@ +import type { APIRoute } from 'astro'; +import { one, query } from '../../../lib/db'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +export const PATCH: APIRoute = async ({ params, request, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const b = await request.json(); + const row = await one(`UPDATE folders SET name=COALESCE($2,name), picdrop_gallery=$3 WHERE id=$1 RETURNING *`, + [params.id, b.name?.trim() || null, b.picdrop_gallery ?? null]); + return json({ folder: row }); +}; + +export const DELETE: APIRoute = async ({ params, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + await query('DELETE FROM folders WHERE id=$1', [params.id]); + return json({ ok: true }); +}; diff --git a/src/pages/api/folders/index.ts b/src/pages/api/folders/index.ts new file mode 100644 index 0000000..0a44d23 --- /dev/null +++ b/src/pages/api/folders/index.ts @@ -0,0 +1,22 @@ +import type { APIRoute } from 'astro'; +import { one, query } from '../../../lib/db'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +export const GET: APIRoute = async ({ locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const rows = await query(`SELECT f.*, (SELECT count(*) FROM items WHERE folder_id=f.id)::int AS count + FROM folders f ORDER BY f.name`); + return json({ folders: rows }); +}; + +export const POST: APIRoute = async ({ request, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const b = await request.json(); + if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400); + const row = await one(`INSERT INTO folders (name, picdrop_gallery) VALUES ($1,$2) RETURNING *`, + [b.name.trim(), b.picdrop_gallery || null]); + return json({ folder: row }); +}; diff --git a/src/pages/api/items/[id].ts b/src/pages/api/items/[id].ts new file mode 100644 index 0000000..81c4fd6 --- /dev/null +++ b/src/pages/api/items/[id].ts @@ -0,0 +1,27 @@ +import type { APIRoute } from 'astro'; +import { one, query } from '../../../lib/db'; +import { deleteObject } from '../../../lib/storage'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +export const PATCH: APIRoute = async ({ params, request, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const b = await request.json(); + const sets: string[] = []; const args: any[] = []; + if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); } + if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); } + if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400); + args.push(params.id); + const row = await one(`UPDATE items SET ${sets.join(',')} WHERE id=$${args.length} RETURNING id, filename, folder_id`, args); + return json({ item: row }); +}; + +export const DELETE: APIRoute = async ({ params, locals }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const it = await one<{ result_path: string | null }>('SELECT result_path FROM items WHERE id=$1', [params.id]); + if (it?.result_path) await deleteObject(it.result_path).catch(() => {}); + await query('DELETE FROM items WHERE id=$1', [params.id]); + return json({ ok: true }); +}; diff --git a/src/pages/bibliothek.astro b/src/pages/bibliothek.astro new file mode 100644 index 0000000..c00adde --- /dev/null +++ b/src/pages/bibliothek.astro @@ -0,0 +1,9 @@ +--- +import Base from '../layouts/Base.astro'; +import LibraryApp from '../components/LibraryApp.tsx'; +--- + +

Bibliothek

+ + +