diff --git a/docker-compose.yaml b/docker-compose.yaml index 587d0e6..4566fbe 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,6 +23,15 @@ services: SEED_LEA_PASSWORD: "${SEED_LEA_PASSWORD}" PUBLIC_BASE_URL: "${PUBLIC_BASE_URL}" N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL}" + # E-Mail-Versand. Die Datenbank hat Vorrang; diese Werte greifen, solange + # unter Admin → E-Mail nichts eingetragen ist. So läuft der Versand nach + # einem frischen Aufsetzen sofort, ohne dass jemand etwas abtippen muss. + SMTP_HOST: "${SMTP_HOST}" + SMTP_PORT: "${SMTP_PORT}" + SMTP_SECURE: "${SMTP_SECURE}" + SMTP_USER: "${SMTP_USER}" + SMTP_PASSWORD: "${SMTP_PASSWORD}" + SMTP_FROM: "${SMTP_FROM}" depends_on: - db - minio diff --git a/migrations/016_print_history.sql b/migrations/016_print_history.sql new file mode 100644 index 0000000..dcad541 --- /dev/null +++ b/migrations/016_print_history.sql @@ -0,0 +1,40 @@ +-- Druck-Historie: was zuletzt gedruckt wurde ------------------------------ +-- +-- Gespeichert wird **nur die Anleitung**, nicht das Ergebnis: die +-- Bogen-Einstellung, die Verweise auf die Quellbilder und ein paar Eckdaten. +-- Ein A3-Druckbogen als PDF ist schnell 20–80 MB; bei ein paar Dutzend +-- Drucken wäre die Platte voll, und zwar mit Dateien, die sich in Sekunden +-- neu erzeugen lassen. Der Bogen wird beim erneuten Drucken einfach neu +-- gerechnet — deterministisch, aus denselben Quellen. +-- +-- Zeilengröße: rund 1–3 KB je Eintrag. Tausend Drucke sind damit ein paar +-- Megabyte statt vieler Gigabyte. +CREATE TABLE IF NOT EXISTS print_runs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + -- Woher der Druck kam: 'web' | 'telegram' | 'mcp' | 'api' + origin text NOT NULL DEFAULT 'web', + -- Sprechende Kurzfassung für die Liste („13 × 18 cm ×1, 35 × 45 mm ×8"). + summary text, + paper text, -- „DIN A4 · 210 × 297 mm" + pages integer NOT NULL DEFAULT 1, + pieces integer NOT NULL DEFAULT 1, + bytes integer, -- Größe des erzeugten PDFs, nur zur Information + -- Die vollständige Anfrage, aus der sich der Bogen neu erzeugen lässt. + -- Bewusst der Anfragekörper und nicht ein eigenes Format: Was hier steht, + -- lässt sich unverändert wieder an /api/print/sheet schicken. + config jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS print_runs_user_idx ON print_runs(created_by, created_at DESC); +CREATE INDEX IF NOT EXISTS print_runs_created_idx ON print_runs(created_at DESC); + +-- Welche Bibliotheksbilder in einem Druck steckten. Nur für Bilder aus der +-- Bibliothek — hochgeladene Einzeldateien haben keine dauerhafte Kennung und +-- sind nach dem Aufräumen der Quellen ohnehin weg. +CREATE TABLE IF NOT EXISTS print_run_items ( + run_id uuid NOT NULL REFERENCES print_runs(id) ON DELETE CASCADE, + item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE, + PRIMARY KEY (run_id, item_id) +); +CREATE INDEX IF NOT EXISTS print_run_items_item_idx ON print_run_items(item_id); diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index 2a9021e..23ef501 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -336,6 +336,21 @@ export default function AdminApp() { von selbst und es kommt kein weiterer Anbieter ins Spiel. Gebraucht wird es für Passwort zurücksetzen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt. + {s.smtp_quelle === 'umgebung' && s.smtp_wirksam && ( +
+ Der Versand läuft bereits — über die Server-Einstellung + ({s.smtp_wirksam.user} auf {s.smtp_wirksam.host}:{s.smtp_wirksam.port}). + Die Felder unten sind leer, weil hier nichts eingetragen ist; das ist in Ordnung. + Wer hier etwas einträgt und speichert, überschreibt die Server-Einstellung. +
+ )} + {s.smtp_quelle === 'datenbank' && ( +
Der Versand läuft über das, was hier eingetragen ist.
+ )} + {!s.smtp_quelle && ( +
Noch kein Versand eingerichtet — Passwort vergessen, + Auftragshinweise und der Mailversand von Teilen-Links funktionieren bis dahin nicht.
+ )}
field('smtp_host', e.target.value)} />
@@ -599,6 +614,10 @@ function AdminStyles() { .zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;} .zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);} .karte>.fein{padding:0 16px 14px;} + .hinweiskasten{padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-sm); + background:var(--paper);font-size:12.5px;line-height:1.55;} + .hinweiskasten.warn{border-color:var(--warn);background:oklch(72% .15 75 / .08);} + .hinweiskasten code{font-family:var(--font-mono);font-size:11.5px;} /* Am Handy war alles zweispaltig — ein Feld „Postausgangsserver" bekam so rund 156 px. Und Tippziele unter 44 px trifft man nicht zuverlässig. */ @media(max-width:560px){ diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx index ff1200d..c80699c 100644 --- a/src/components/PrintApp.tsx +++ b/src/components/PrintApp.tsx @@ -6,6 +6,7 @@ import { } from '../lib/printlayout'; import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize'; import { baseCrop, clampCrop, centred, limitsFor, type CropRel, type FitMode } from '../lib/cropmath'; +import PrintRuns from './PrintRuns'; /* -------------------------------------------------------------------------- Drucken — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit @@ -127,6 +128,8 @@ export default function PrintApp() { const [targets, setTargets] = useState([]); const [editing, setEditing] = useState(null); const [picker, setPicker] = useState(false); + // Der Verlauf lädt erst beim Aufklappen — eine Anfrage weniger auf jeder Seite. + const [verlaufOffen, setVerlaufOffen] = useState(false); const [presets, setPresets] = useState([]); const [presetSel, setPresetSel] = useState(''); const [busy, setBusy] = useState(null); @@ -292,6 +295,32 @@ export default function PrintApp() { const loadPresets = () => fetch('/api/print/presets').then((r) => r.json()) .then((j) => setPresets(j.presets || [])).catch(() => {}); + /** + * Die Einstellung eines früheren Drucks übernehmen — Papier, Marken, Ränder, + * Qualität. Die **Bilder** kommen bewusst nicht mit: Wer „übernehmen" drückt, + * will meist dieselbe Einrichtung für ein anderes Motiv. Wer denselben Bogen + * will, nimmt „Nochmal drucken". + */ + const configUebernehmen = (cfg: any) => { + if (!cfg) return; + try { + if (cfg.paper?.id) setPaperId(cfg.paper.id); + else if (cfg.paper?.size) { setPaperId('custom'); setPaperCustom(String(cfg.paper.size)); } + if (typeof cfg.landscape === 'boolean') setPaperLandscape(cfg.landscape); + if (cfg.marks?.mode) setMarks(cfg.marks.mode); + if (cfg.marks?.lengthMm != null) setMarkLen(Number(cfg.marks.lengthMm)); + if (cfg.marks?.offsetMm != null) setMarkOff(Number(cfg.marks.offsetMm)); + if (cfg.marginMm != null) setMarginMm(Number(cfg.marginMm)); + if (cfg.bleedMm != null) setBleedMm(Number(cfg.bleedMm)); + if (cfg.gapMm != null) { setAutoGap(false); setGapMm(Number(cfg.gapMm)); } + if (cfg.dpi != null) setDpi(Number(cfg.dpi)); + if (cfg.ext) setExt(cfg.ext === 'png' ? 'png' : 'jpg'); + if (typeof cfg.center === 'boolean') setCenter(cfg.center); + notify('Einstellung übernommen. Die Bilder wählst du selbst — „Nochmal drucken" nimmt auch die.'); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } catch { notify('Diese Einstellung ließ sich nicht übernehmen.', true); } + }; + const addFromLibrary = (it: any) => { const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0); const id = uid(); @@ -475,6 +504,15 @@ export default function PrintApp() { Auch mit ⌘V einfügen · bis zu {MAX_BILDER} Stück · kein KI-Schritt, nur zuschneiden, skalieren, setzen + {/* Der zweite Weg gehört gleichberechtigt hierher: Die meisten + Bilder liegen längst in Klarbild und müssen nicht erst + exportiert und wieder hochgeladen werden. */} +
oder
+ {presets.length > 0 && (
Oder mit einer Vorlage beginnen @@ -500,7 +538,10 @@ export default function PrintApp() { onDuplicate={() => duplicate(c.id)} onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} /> ))} - +
+ + +
)} Im Druckdialog „Tatsächliche Größe" / 100 % wählen — nicht „an Seitengröße anpassen".}
+ + {/* Zuletzt gedruckt — zugeklappt, weil es beim Arbeiten nicht stört, + aber zur Hand ist, wenn man denselben Bogen noch einmal braucht. */} + +
+
setVerlaufOffen((e.target as HTMLDetailsElement).open)}> + + Liste anzeigen + denselben Bogen noch einmal erzeugen + +
+ {verlaufOffen && } +
+
+
+
{editCell && setEditing(null)} onChange={(crop) => patch(editCell.id, { crop })} />} - {picker && setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />} + {picker && ( + setPicker(false)} + onPick={(gewaehlt) => { gewaehlt.forEach(addFromLibrary); setPicker(false); }} /> + )} {toast && (
{toast.text} @@ -1116,41 +1176,136 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop /* ---------------- Bibliothek ---------------- */ -function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) { +/** + * Bilder aus der Bibliothek holen. + * + * Der erste Entwurf war eine Textzeile im Kartenkopf und eine ungefilterte + * Kachelwand — bei ein paar hundert Bildern unbrauchbar, und kaum zu finden. + * Jetzt: Suche, Ordner, Mehrfachauswahl. Gesucht wird im Browser über die + * geladene Liste; das ist bei 400 Einträgen sofort da und spart eine Rundreise + * pro Tastendruck. + */ +function LibraryPicker({ onPick, onClose }: { onPick: (items: any[]) => void; onClose: () => void }) { const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const modalRef = useRef(null); + const [ordner, setOrdner] = useState([]); + const [laden, setLaden] = useState(true); + const [fehler, setFehler] = useState(false); + const [suche, setSuche] = useState(''); + const [imOrdner, setImOrdner] = useState(''); + const [gewaehlt, setGewaehlt] = useState([]); + const box = useRef(null); const titelId = useId(); - useEffect(() => { - fetch('/api/items').then((r) => r.json()) - .then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false)); + const holen = useCallback(() => { + setLaden(true); setFehler(false); + Promise.all([ + fetch('/api/items').then((r) => { if (!r.ok) throw new Error(); return r.json(); }), + fetch('/api/folders').then((r) => (r.ok ? r.json() : { folders: [] })).catch(() => ({ folders: [] })), + ]) + .then(([i, f]) => { setItems(i.items || []); setOrdner(f.folders || []); }) + .catch(() => setFehler(true)) + .finally(() => setLaden(false)); }, []); + useEffect(holen, [holen]); + + // Hintergrund festhalten, Fokus einfangen, Escape schließt. useEffect(() => { const y = window.scrollY; const vorher = document.activeElement as HTMLElement | null; document.body.style.overflow = 'hidden'; - return () => { document.body.style.overflow = ''; window.scrollTo(0, y); vorher?.focus?.(); }; - }, []); + box.current?.querySelector('input')?.focus(); + const key = (e: KeyboardEvent) => { + if (e.key === 'Escape') { e.stopPropagation(); onClose(); } + }; + document.addEventListener('keydown', key); + return () => { + document.removeEventListener('keydown', key); + document.body.style.overflow = ''; window.scrollTo(0, y); vorher?.focus?.(); + }; + }, [onClose]); + + const gefiltert = useMemo(() => { + const q = suche.trim().toLowerCase(); + return items.filter((it) => + (!imOrdner || it.folder_id === imOrdner) + && (!q || String(it.filename || '').toLowerCase().includes(q) + || String(it.prompt_used || '').toLowerCase().includes(q))); + }, [items, suche, imOrdner]); + + const umschalten = (id: string) => + setGewaehlt((g) => (g.includes(id) ? g.filter((x) => x !== id) : [...g, id])); + + const uebernehmen = () => { + const gewaehlteBilder = gewaehlt + .map((id) => items.find((i) => i.id === id)) + .filter(Boolean); + if (gewaehlteBilder.length) onPick(gewaehlteBilder); + }; return (
-
e.stopPropagation()} - onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}> +
e.stopPropagation()}>

Aus der Bibliothek wählen

+ +
+ setSuche(e.target.value)} + placeholder="Nach Name oder Beschreibung suchen …" aria-label="Bibliothek durchsuchen" /> + {ordner.length > 0 && ( + + )} +
+
- {loading ?

Wird geladen …

+ {laden ?

Wird geladen …

+ : fehler ? ( +

Die Bibliothek ließ sich nicht laden.{' '} +

+ ) : !items.length ?

Noch keine Bilder in der Bibliothek.

- : items.map((it) => ( - - ))} + : !gefiltert.length ?

Nichts gefunden. Andere Suche oder Ordner probieren.

+ : gefiltert.map((it) => { + const an = gewaehlt.includes(it.id); + return ( + + ); + })} +
+ +
+ + {gewaehlt.length + ? `${zahl(gewaehlt.length, 'Bild', 'Bilder')} gewählt` + : `${zahl(gefiltert.length, 'Bild', 'Bilder')}${suche || imOrdner ? ' gefunden' : ' in der Bibliothek'}`} + +
+ {gewaehlt.length > 0 && ( + + )} + +
@@ -1170,7 +1325,12 @@ function PrintStyles() { .arbeit-rechts{position:sticky;top:82px;} @media(max-width:1080px){ .arbeit{grid-template-columns:1fr;gap:14px;} - .arbeit-rechts{position:static;order:-1;} /* Vorschau vor die Einstellungen */ + /* Auf schmalen Geräten laufen die Karten in ihrer eigenen Nummerierung: + 1 Bilder, 2 Bogen & Schnitt, 3 Vorschau, 4 Zuletzt gedruckt. Früher + stand die rechte Spalte oben (order:-1, damit die Vorschau gleich + sichtbar ist) — mit der Verlaufskarte scrollte man dadurch an einem + ganzen Block vorbei, ehe man überhaupt ein Bild hinzufügen konnte. */ + .arbeit-rechts{position:static;} } .karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);} @@ -1191,6 +1351,48 @@ function PrintStyles() { .drop-titel{font-size:15px;font-weight:600;} .drop-fein{color:var(--soft);font-size:12.5px;line-height:1.6;max-width:44ch;} .kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;} + /* „oder" zwischen den beiden gleichwertigen Wegen ins Modul */ + .oder{display:flex;align-items:center;gap:12px;margin:12px 0;color:var(--soft); + font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;} + .oder::before,.oder::after{content:'';flex:1;height:1px;background:var(--line);} + .drop-lib{min-height:0;padding:18px;border-style:solid;background:var(--paper);} + .drop-lib:hover{border-color:var(--accent);} + .zelle-add-gruppe{display:flex;flex-direction:column;gap:8px;align-self:start;} + .zelle-add-gruppe .zelle-add{min-height:0;padding:13px;} + + .lib{display:flex;flex-direction:column;max-height:min(86vh,780px);} + .lib-filter{display:flex;gap:8px;padding:0 16px 12px;} + .lib-filter .input{flex:1;min-width:0;} + .lib-filter .select{flex:0 0 auto;max-width:44%;} + .lib-fuss{display:flex;align-items:center;justify-content:space-between;gap:12px; + padding:12px 16px;border-top:1px solid var(--line);} + .pick.an{border-color:var(--accent);background:var(--accent-bg);} + .pick-bild{position:relative;display:block;line-height:0;background:var(--paper);} + .pick-haken{position:absolute;top:5px;left:5px;width:22px;height:22px;border-radius:50%; + border:1.5px solid #fff;background:rgba(22,21,15,.4);color:#fff;display:flex; + align-items:center;justify-content:center;font-size:12px;line-height:1;} + .pick.an .pick-haken{background:var(--accent);border-color:var(--card);} + .pick-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .pick-mass{display:block;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;color:var(--soft);} + @media(max-width:640px){ + .lib-filter{flex-direction:column;} + .lib-filter .select{max-width:none;} + .lib-fuss{flex-direction:column;align-items:stretch;gap:8px;} + .lib-fuss .reihe{display:flex;gap:8px;} + .lib-fuss .reihe>*{flex:1;} + } + + .verlauf{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--paper);} + .verlauf>summary{list-style:none;cursor:pointer;display:flex;align-items:baseline; + justify-content:space-between;gap:10px;padding:12px;font-size:13.5px;} + .verlauf>summary::-webkit-details-marker{display:none;} + .verlauf>summary::after{content:'▾';color:var(--soft);font-size:11px;transition:transform .15s;} + .verlauf[open]>summary::after{transform:rotate(180deg);} + .verlauf>summary em{font-style:normal;font-size:11.5px;color:var(--soft);margin-left:auto;} + /* Auf dem Handy bricht der Zusatz um und macht aus der Zeile einen Block. */ + @media(max-width:560px){ .verlauf>summary em{display:none;} } + .verlauf-inhalt{padding:0 12px 12px;} + .schnellstart{margin-top:14px;padding-top:14px;border-top:1px solid var(--line);} .schnell-reihe{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;} @@ -1341,7 +1543,11 @@ function PrintStyles() { .pick{border:1px solid var(--line);background:#fff;border-radius:3px;padding:0;cursor:pointer;overflow:hidden; display:flex;flex-direction:column;font-family:inherit;} .pick img{width:100%;aspect-ratio:1;object-fit:cover;display:block;} - .pick span{font-size:10px;color:var(--soft);padding:4px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + /* Nur die Textzeilen polstern: Ein Selektor auf alle Spans traf auch die + Hülle um das Bild und hätte den Haken verschoben. */ + .pick-name,.pick-mass{font-size:10px;color:var(--soft);padding:4px 5px 0; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .pick-mass{padding-bottom:5px;} .pick:hover{border-color:var(--accent);} .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7; diff --git a/src/components/PrintRuns.tsx b/src/components/PrintRuns.tsx new file mode 100644 index 0000000..121f1e6 --- /dev/null +++ b/src/components/PrintRuns.tsx @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** + * Die letzten Drucke. + * + * Gespeichert ist **nur die Anleitung** — Bogen, Maße, Stückzahlen und die + * Verweise auf die Bilder. Kein PDF: Ein A3-Bogen ist schnell 20–80 MB, und + * das Modul rechnet ohne KI, also ergibt derselbe Auftrag wieder exakt + * dasselbe Blatt. Neu erzeugen ist billiger als aufheben. + * + * „Nochmal drucken" geht deshalb nur, wenn **alle** Bilder aus der Bibliothek + * kamen und noch da sind. Bei hochgeladenen Dateien ist die Quelle nach dem + * Aufräumen weg — das steht dann auch so am Eintrag. + */ +interface Run { + id: string; origin: string; summary: string | null; paper: string | null; + pages: number; pieces: number; bytes: number | null; created_at: string; + bilder: { id: string; filename: string | null }[]; + fehlend: number; hochgeladen: number; wiederholbar: boolean; config: any; +} + +const wann = (s: string) => { + const d = new Date(s); + const minuten = Math.round((Date.now() - d.getTime()) / 60000); + if (minuten < 1) return 'gerade eben'; + if (minuten < 60) return `vor ${minuten} min`; + if (minuten < 24 * 60) return `vor ${Math.round(minuten / 60)} h`; + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); +}; +// Ein Testbogen sind 20 KB, ein A3-Poster 80 MB. `toFixed(1)` in MB machte aus +// dem kleinen Fall „0.0 MB groß" — also unterhalb eines Megabytes in KB. +const groesse = (b: number | null) => { + if (!b) return ''; + if (b < 1024 * 1024) return `${Math.max(1, Math.round(b / 1024))} KB`; + return `${(b / 1048576).toFixed(1)} MB`.replace('.', ','); +}; +const zahl = (n: number, ein: string, viele: string) => `${n} ${n === 1 ? ein : viele}`; + +export default function PrintRuns({ onLaden }: { onLaden?: (config: any) => void }) { + const [runs, setRuns] = useState([]); + const [laden, setLaden] = useState(true); + const [fehler, setFehler] = useState(false); + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null); + + const holen = useCallback(async () => { + try { + const r = await fetch('/api/print/runs'); + if (!r.ok) throw new Error(String(r.status)); + const d = await r.json(); + setRuns(d.runs || []); setFehler(false); + } catch { setFehler(true); } + finally { setLaden(false); } + }, []); + useEffect(() => { holen(); }, [holen]); + + const sagen = (t: string, ok = true) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); }; + + /** Denselben Bogen noch einmal erzeugen — dieselbe Anfrage, dasselbe PDF. */ + const nochmal = async (r: Run) => { + setBusy(r.id); + try { + const res = await fetch('/api/print/sheet', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + // `origin` markiert den Lauf als Wiederholung, damit die Liste nicht + // aussieht, als hätte jemand denselben Bogen zweimal von Hand gebaut. + body: JSON.stringify({ ...r.config, origin: 'wiederholung' }), + }); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + sagen(j.error || 'Das hat nicht geklappt — vielleicht fehlt ein Bild.', false); + return; + } + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `klarbild-druck-${new Date().toISOString().slice(0, 10)}.pdf`; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 4000); + sagen('PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %" wählen.'); + holen(); + } catch { sagen('Netzwerkfehler.', false); } + finally { setBusy(null); } + }; + + const loeschen = async (r: Run) => { + if (!confirm('Diesen Eintrag aus der Liste nehmen?\n\nDie Bilder bleiben in der Bibliothek — es verschwindet nur der Verlaufseintrag.')) return; + const res = await fetch(`/api/print/runs?id=${r.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({})); + if (res.ok) { setRuns((p) => p.filter((x) => x.id !== r.id)); } else sagen(res.error || 'Hat nicht geklappt.', false); + }; + + if (laden) return

Wird geladen …

; + + if (fehler) return ( +
+

Der Verlauf ließ sich nicht laden.{' '} +

+ +
+ ); + + if (!runs.length) return ( +
+
+ +

Noch nichts gedruckt

+

Sobald du im Druckbereich ein PDF erzeugst, steht es hier — + mit einem Knopf, um genau denselben Bogen noch einmal zu bekommen.

+
+ +
+ ); + + return ( +
+ {msg &&

{msg.t}

} + +
+ {runs.map((r) => ( +
+
+
+

{r.summary || 'Druckbogen'}

+ {wann(r.created_at)} +
+ {r.origin === 'wiederholung' && wiederholt} + {r.origin === 'telegram' && Telegram} +
+ +
+ {r.paper} · {zahl(r.pages, 'Bogen', 'Bogen')} · {zahl(r.pieces, 'Teil', 'Teile')} + {r.bytes ? <> · das PDF war {groesse(r.bytes)} groß : null} +
+ + {r.bilder.length > 0 && ( +
+ {r.bilder.slice(0, 8).map((b) => ( + {b.filename { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; }} /> + ))} + {r.bilder.length > 8 && +{r.bilder.length - 8}} +
+ )} + +
+ {r.wiederholbar ? ( + + ) : ( + + {r.fehlend > 0 + ? `${zahl(r.fehlend, 'Bild ist', 'Bilder sind')} inzwischen gelöscht — nicht wiederholbar.` + : 'Damals hochgeladen statt aus der Bibliothek — die Quelldatei ist weg.'} + + )} + {onLaden && ( + + )} + +
+
+ ))} +
+ +

+ Hier liegen nur die Einstellungen, nicht die PDFs — ein A3-Bogen wären schnell 20 bis 80 MB. + Weil das Druckmodul ohne KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt. + Aufgehoben werden die letzten {100} Drucke je Person. +

+ + +
+ ); +} + +function PrintRunsStyles() { + return ; +} diff --git a/src/lib/mail.ts b/src/lib/mail.ts index 4d8b213..725f986 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -15,21 +15,55 @@ export interface MailCfg { user: string; password: string; from: string; } -/** SMTP-Zugang aus den Einstellungen — `null`, wenn nichts konfiguriert ist. */ -export async function loadMailConfig(): Promise { +export interface MailQuelle { cfg: MailCfg; quelle: 'datenbank' | 'umgebung' } + +/** + * SMTP-Zugang — erst aus den Einstellungen, sonst aus der Umgebung. + * + * Dieselbe Rangfolge wie beim OpenRouter-Schlüssel: Was jemand im Admin + * einträgt, gewinnt. Die Umgebungsvariablen sind der Startwert, damit der + * Versand nach einem frischen Aufsetzen sofort läuft, ohne dass jemand + * Zugangsdaten abtippen muss — und damit das Passwort **nur** dort und in der + * verschlüsselten Spalte liegt, nie im Repository. + */ +export async function loadMailSource(): Promise { const s = await one( `SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, smtp_from FROM settings WHERE id=1`); - if (!s?.smtp_host || !s?.smtp_user || !s?.smtp_password) return null; - let password = ''; - try { password = decrypt(s.smtp_password); } catch { return null; } - return { - host: s.smtp_host, - port: Number(s.smtp_port) || 465, - secure: s.smtp_secure !== false, - user: s.smtp_user, - password, - from: s.smtp_from || s.smtp_user, - }; + + if (s?.smtp_host && s?.smtp_user && s?.smtp_password) { + try { + return { quelle: 'datenbank', cfg: { + host: s.smtp_host, + port: Number(s.smtp_port) || 465, + secure: s.smtp_secure !== false, + user: s.smtp_user, + password: decrypt(s.smtp_password), + from: s.smtp_from || s.smtp_user, + } }; + } catch { + // Entschlüsselung gescheitert (z. B. nach einem Wechsel des + // ENCRYPTION_KEY) — dann lieber die Umgebung als gar nichts. + console.error('[mail] Gespeichertes Passwort nicht lesbar, weiche auf die Umgebung aus.'); + } + } + + const e = process.env; + if (e.SMTP_HOST && e.SMTP_USER && e.SMTP_PASSWORD) { + return { quelle: 'umgebung', cfg: { + host: e.SMTP_HOST, + port: Number(e.SMTP_PORT) || 465, + secure: e.SMTP_SECURE !== 'false', + user: e.SMTP_USER, + password: e.SMTP_PASSWORD, + from: e.SMTP_FROM || e.SMTP_USER, + } }; + } + return null; +} + +/** Nur der Zugang, ohne die Herkunft. */ +export async function loadMailConfig(): Promise { + return (await loadMailSource())?.cfg ?? null; } export const mailReady = async (): Promise => !!(await loadMailConfig()); diff --git a/src/lib/printruns.ts b/src/lib/printruns.ts new file mode 100644 index 0000000..4f98110 --- /dev/null +++ b/src/lib/printruns.ts @@ -0,0 +1,190 @@ +// Druck-Historie — was zuletzt gedruckt wurde, und wie man es wiederholt. +// +// Der springende Punkt: Gespeichert wird die **Anleitung**, nicht das Ergebnis. +// Ein A3-Bogen als PDF ist schnell 20–80 MB; ein paar Dutzend davon füllen die +// Platte mit Dateien, die sich in Sekunden neu rechnen lassen. Also liegt hier +// der Anfragekörper, mit dem `/api/print/sheet` denselben Bogen erneut erzeugt +// — rund 1–3 KB je Eintrag. +// +// Das geht nur, weil das Druckmodul **deterministisch** ist: dieselben Quellen +// und dieselbe Einstellung ergeben dasselbe PDF. Bei den KI-Aufträgen wäre das +// Unsinn, dort käme jedes Mal etwas anderes heraus. +import { one, query } from './db.ts'; + +export const MAX_RUNS_JE_NUTZER = 100; + +export interface PrintRun { + id: string; + created_by: string | null; + origin: string; + summary: string | null; + paper: string | null; + pages: number; + pieces: number; + bytes: number | null; + config: any; + created_at: string; +} + +export interface RunEingang { + userId: string | null; + origin?: string; + summary: string; + paper: string; + pages: number; + pieces: number; + bytes: number; + /** Der Anfragekörper, unverändert. */ + config: any; + /** Kennungen der verwendeten Bibliotheksbilder. */ + itemIds: string[]; +} + +/** + * Was am Bogen hing, in einem Satz. + * + * Die Liste kommt zellenweise herein — zwei Zellen mit demselben Maß ergäben + * sonst „10 × 15 cm, 10 × 15 cm". Gleiche Maße werden deshalb zusammengezogen + * und ihre Stückzahlen addiert; ein vorhandenes „×3" wird dabei mitgerechnet, + * damit nicht „×3 ×2" entsteht. + */ +export function kurzfassung(kinds: string[]): string { + if (!kinds.length) return 'Druckbogen'; + + // Nur die **abschließende** Stückzahl zählt: „35 × 45 mm ×8" hat zwei Mal ein + // ×, und ein gieriger Ausdruck machte aus „13 × 18 cm" prompt „13 cm ×18". + // Deshalb ans Ende verankert, mit optionalem Zusatz in Klammern dahinter. + const ZAHL = /^(.*?)\s*×\s*(\d+)(\s*\([^)]*\))?$/; + + const summe = new Map(); + for (const k of kinds) { + const m = ZAHL.exec(k); + const grund = m ? `${m[1]}${m[3] || ''}` : k; + const anzahl = m ? Number(m[2]) : 1; + summe.set(grund, (summe.get(grund) || 0) + anzahl); + } + + // „30 × 40 cm (Poster) ×3" läse sich schief — die Stückzahl gehört vor den + // Zusatz, dorthin, wo sie hereinkam. + const teile = [...summe].map(([grund, n]) => { + if (n <= 1) return grund; + const z = /^(.*?)(\s*\([^)]*\))$/.exec(grund); + return z ? `${z[1]} ×${n}${z[2]}` : `${grund} ×${n}`; + }); + const s = teile.join(', '); + return s.length <= 120 ? s : `${s.slice(0, 117)}…`; +} + +/** + * Einen Druck festhalten. + * + * Fehler hier dürfen den Druck **nie** scheitern lassen — das PDF ist zu dem + * Zeitpunkt längst fertig und unterwegs zum Nutzer. Deshalb fängt der Aufrufer + * ab, und diese Funktion meldet nur ins Log. + */ +export async function recordRun(e: RunEingang): Promise { + const run = await one<{ id: string }>( + `INSERT INTO print_runs (created_by, origin, summary, paper, pages, pieces, bytes, config) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, + [e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes, JSON.stringify(e.config)]); + if (!run) return null; + + const ids = [...new Set(e.itemIds.filter(Boolean))]; + if (ids.length) { + // Bilder können zwischenzeitlich gelöscht worden sein — dann fällt die + // Verknüpfung weg, der Eintrag bleibt. + await query( + `INSERT INTO print_run_items (run_id, item_id) + SELECT $1, x FROM unnest($2::uuid[]) AS x + WHERE EXISTS (SELECT 1 FROM items WHERE id = x) + ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) => + console.error('[printruns] Bildverweise nicht gespeichert:', err?.message || err)); + } + + // Alte Einträge desselben Nutzers wegräumen. Ohne das wächst die Liste + // unbegrenzt, und niemand scrollt bis zum Druck von vor zwei Jahren. + if (e.userId) { + await query( + `DELETE FROM print_runs + WHERE created_by = $1 AND id NOT IN ( + SELECT id FROM print_runs WHERE created_by = $1 + ORDER BY created_at DESC LIMIT $2)`, + [e.userId, MAX_RUNS_JE_NUTZER]).catch(() => {}); + } + return run.id; +} + +export interface RunListe extends PrintRun { + /** Bilder, die es noch gibt — nur damit lässt sich der Druck wiederholen. */ + bilder: { id: string; filename: string | null }[]; + /** Wie viele **Bibliotheksbilder** inzwischen gelöscht sind. */ + fehlend: number; + /** Wie viele Zellen aus einer hochgeladenen Datei kamen — die sind weg. */ + hochgeladen: number; + /** Lässt sich der Bogen unverändert neu erzeugen? */ + wiederholbar: boolean; +} + +/** + * Die letzten Drucke. Admins sehen alle, sonst nur die eigenen — dieselbe + * Regel wie bei den Teilen-Links. + */ +export async function listRuns(userId: string | null, istAdmin: boolean, limit = 30): Promise { + const runs = await query( + `SELECT * FROM print_runs + WHERE $2::boolean OR created_by = $1 + ORDER BY created_at DESC LIMIT $3`, [userId, istAdmin, Math.min(limit, 100)]); + if (!runs.length) return []; + + const bilder = await query<{ run_id: string; id: string; filename: string | null }>( + `SELECT ri.run_id, i.id, i.filename + FROM print_run_items ri JOIN items i ON i.id = ri.item_id + WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL`, + [runs.map((r) => r.id)]); + + const nachRun = new Map(); + for (const b of bilder) { + const l = nachRun.get(b.run_id) || []; + l.push({ id: b.id, filename: b.filename }); + nachRun.set(b.run_id, l); + } + + return runs.map((r) => { + const vorhanden = nachRun.get(r.id) || []; + const zellen: any[] = Array.isArray(r.config?.cells) ? r.config.cells : []; + // Nur Zellen aus der Bibliothek lassen sich wiederholen. Hochgeladene + // Dateien liegen unter `sources/…` und werden je nach Einstellung nach der + // Verarbeitung aufgeräumt — dann fehlt die Quelle. + const ausBibliothek = zellen.filter((c) => c?.src?.kind === 'item').length; + // Verschiedene Gründe, verschieden erklärt: Ein gelöschtes Bibliotheksbild + // ist etwas anderes als eine Datei, die nie in der Bibliothek lag. Beides + // in einen Zähler zu werfen führte zu „1 Bild ist nicht mehr da" für einen + // Bogen, dessen Quelle schlicht ein Upload war. + const eindeutige = new Set(zellen.filter((c) => c?.src?.kind === 'item') + .map((c) => String(c.src.id))).size; + return { + ...r, + bilder: vorhanden, + fehlend: Math.max(0, eindeutige - vorhanden.length), + hochgeladen: zellen.length - ausBibliothek, + // Wiederholbar nur, wenn **alle** Zellen aus der Bibliothek kommen und + // alle Bilder noch da sind. Halb geht nicht: Ein Bogen mit fehlendem + // Bild wäre nicht derselbe Bogen. + wiederholbar: zellen.length > 0 && ausBibliothek === zellen.length && vorhanden.length === zellen.length, + }; + }); +} + +/** Einen einzelnen Druck holen — für das erneute Erzeugen. */ +export async function getRun(id: string, userId: string | null, istAdmin: boolean): Promise { + return one( + `SELECT * FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2)`, + [id, userId, istAdmin]); +} + +export async function deleteRun(id: string, userId: string | null, istAdmin: boolean): Promise { + const r = await query<{ id: string }>( + `DELETE FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`, + [id, userId, istAdmin]); + return r.length > 0; +} diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts index f8735c8..21d057b 100644 --- a/src/pages/api/admin/settings.ts +++ b/src/pages/api/admin/settings.ts @@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'; import { randomBytes } from 'node:crypto'; import { one, query } from '../../../lib/db'; import { encrypt, maskSecret, decrypt } from '../../../lib/crypto'; +import { loadMailSource } from '../../../lib/mail'; export const prerender = false; const randomToken = () => 'klb_' + randomBytes(24).toString('hex'); @@ -10,6 +11,7 @@ const json = (b: unknown, s = 200) => export const GET: APIRoute = async () => { const s = await one('SELECT * FROM settings WHERE id=1'); + const mail = await loadMailSource(); if (!s) return json({ settings: null }); let orMask = ''; if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'gesetzt'; } } @@ -35,6 +37,9 @@ export const GET: APIRoute = async () => { // E-Mail-Versand (Passwort wird nie zurückgegeben, nur ob es gesetzt ist) smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure, smtp_user: s.smtp_user, smtp_from: s.smtp_from, smtp_password_set: !!s.smtp_password, + // Woher der gerade wirksame Zugang kommt: 'datenbank' | 'umgebung' | null + smtp_quelle: mail?.quelle ?? null, + smtp_wirksam: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null, // Teilen share_default_days: s.share_default_days, } }); diff --git a/src/pages/api/print/runs.ts b/src/pages/api/print/runs.ts new file mode 100644 index 0000000..d40d755 --- /dev/null +++ b/src/pages/api/print/runs.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from 'astro'; +import { listRuns, deleteRun } from '../../../lib/printruns'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Die letzten Drucke — Einstellung und Quellen, nie das PDF selbst. */ +export const GET: APIRoute = async ({ locals, url }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const limit = Number(url.searchParams.get('limit')) || 30; + const runs = await listRuns(locals.user.uid, locals.user.role === 'admin', limit); + return json({ runs }); +}; + +export const DELETE: APIRoute = async ({ locals, url }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const id = url.searchParams.get('id') || ''; + if (!UUID.test(id)) return json({ error: 'Unbekannter Eintrag.' }, 404); + const ok = await deleteRun(id, locals.user.uid, locals.user.role === 'admin'); + return ok ? json({ ok: true }) : json({ error: 'Unbekannter Eintrag.' }, 404); +}; diff --git a/src/pages/api/print/sheet.ts b/src/pages/api/print/sheet.ts index 35f345f..c95b63b 100644 --- a/src/pages/api/print/sheet.ts +++ b/src/pages/api/print/sheet.ts @@ -7,6 +7,7 @@ import { paperById, parseSizeMm, labelMm } from '../../../lib/paper'; import { cfgForKey } from '../../../lib/delivery'; import { uploadBuffer } from '../../../lib/picdrop'; import { one } from '../../../lib/db'; +import { recordRun, kurzfassung } from '../../../lib/printruns'; export const prerender = false; @@ -206,6 +207,26 @@ export const POST: APIRoute = async ({ request, locals }) => { } } + // Den Druck festhalten — nur die Anleitung, nicht das PDF. Fehler dabei + // dürfen den Bogen nie kosten: Er ist fertig und geht gleich raus. + try { + await recordRun({ + userId: locals.user.uid ?? null, + origin: String(body?.origin || 'web'), + summary: kurzfassung(kinds), + paper: `${labelMm(sheet.wMm, sheet.hMm)}`, + pages: pages.length, + pieces: totalPieces, + bytes: bytes.length, + config: body, + itemIds: [...cellsIn, ...big.map((b) => b.cell)] + .map((c: any) => (c?.src?.kind === 'item' ? String(c.src.id) : null)) + .filter(Boolean) as string[], + }); + } catch (e: any) { + console.error('[print] Historie nicht gespeichert:', e?.message || e); + } + return new Response(Buffer.from(bytes), { headers: { 'Content-Type': 'application/pdf', diff --git a/tests/printruns.test.ts b/tests/printruns.test.ts new file mode 100644 index 0000000..40fb065 --- /dev/null +++ b/tests/printruns.test.ts @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { kurzfassung } from '../src/lib/printruns.ts'; + +/** + * Die Kurzfassung steht in der Verlaufsliste. Sie kommt zellenweise herein — + * zwei Zellen mit demselben Maß dürfen dort nicht zweimal auftauchen. + */ + +test('Einzelnes Maß bleibt, wie es ist', () => { + assert.equal(kurzfassung(['13 × 18 cm']), '13 × 18 cm'); +}); + +test('Gleiche Maße werden zusammengezogen', () => { + assert.equal(kurzfassung(['10 × 15 cm', '10 × 15 cm']), '10 × 15 cm ×2'); +}); + +test('Vorhandene Stückzahlen werden addiert, nicht angehängt', () => { + assert.equal(kurzfassung(['35 × 45 mm ×8', '35 × 45 mm ×4']), '35 × 45 mm ×12'); + assert.equal(kurzfassung(['10 × 15 cm ×3', '10 × 15 cm']), '10 × 15 cm ×4'); +}); + +test('Verschiedene Maße bleiben nebeneinander, in ihrer Reihenfolge', () => { + assert.equal(kurzfassung(['13 × 18 cm', '35 × 45 mm ×8']), '13 × 18 cm, 35 × 45 mm ×8'); +}); + +test('Zusätze wie (Poster) trennen die Gruppen', () => { + assert.equal(kurzfassung(['30 × 40 cm ×2 (Poster)', '30 × 40 cm ×1 (Poster)']), + '30 × 40 cm ×3 (Poster)'); + assert.equal(kurzfassung(['30 × 40 cm (Poster)', '30 × 40 cm (überstehend)']), + '30 × 40 cm (Poster), 30 × 40 cm (überstehend)'); +}); + +test('Leere Liste ergibt etwas Lesbares', () => { + assert.equal(kurzfassung([]), 'Druckbogen'); +}); + +test('Sehr lange Aufzählungen werden gekürzt', () => { + const viele = Array.from({ length: 40 }, (_, i) => `${i + 10} × ${i + 15} cm`); + const s = kurzfassung(viele); + assert.ok(s.length <= 118, `zu lang: ${s.length}`); + assert.ok(s.endsWith('…')); +});