Drucken: Bibliothek als Quelle, Verlauf der Drucke, Mailzugang aus der Umgebung

Drei Dinge, die im Alltag gefehlt haben.

**Bilder aus der Bibliothek in den Druckbereich.** Bisher musste man eine
Datei hochladen, auch wenn Klarbild sie längst hatte. Jetzt gibt es einen
Wähler mit Suche, Ordnerfilter und Mehrfachauswahl — erreichbar aus der
leeren Ablage, aus der Kopfzeile der ersten Karte und an jeder Zellengruppe.

**Verlauf der Drucke.** Gespeichert wird bewusst nur die Anleitung, nicht
das Ergebnis: ein A3-Bogen ist schnell 20 bis 80 MB, ein Verlaufseintrag
rund ein bis drei Kilobyte. Weil das Druckmodul ohne KI rechnet, ergibt
derselbe Auftrag wieder exakt dasselbe Blatt — nur die Zeitstempel im PDF
unterscheiden sich, der Bildinhalt ist Byte für Byte gleich. „Nochmal
drucken" gibt es deshalb nur, wenn alle Zellen aus der Bibliothek kamen und
alle Bilder noch da sind; sonst steht am Eintrag, woran es liegt. Je Konto
bleiben die letzten 100 Drucke, ältere räumt der Eintrag selbst weg.
Sichtbar sind nur die eigenen Läufe, Admins sehen alle.

**Mailzugang aus der Umgebung.** Der Versand nimmt jetzt die Server-
Einstellung, solange unter Admin → E-Mail nichts eingetragen ist. Die
Zugangsdaten liegen als Umgebungsvariablen in Coolify, nicht im Repo. Der
Admin zeigt an, welche Quelle gerade wirklich greift.

Unterwegs mitgenommen:

- Auf schmalen Geräten stand die rechte Spalte oben (order:-1, damit die
  Vorschau gleich sichtbar ist). Mit der Verlaufskarte hätte man an einem
  ganzen Block vorbeigescrollt, ehe man ein Bild hinzufügen kann — die
  Karten laufen jetzt in ihrer eigenen Nummerierung.
- Die Kurzfassung listete jede Zelle einzeln: „10 × 15 cm, 10 × 15 cm".
  Gleiche Maße werden zusammengezogen, vorhandene Stückzahlen addiert.
  Der erste Ansatz machte dabei aus „13 × 18 cm" prompt „13 cm ×18" — das
  × im Maß sah aus wie eine Stückzahl. Sieben Tests halten das jetzt fest.
- „das PDF war 0.0 MB groß" für einen 20-KB-Bogen: unter einem Megabyte
  wird in KB gerechnet.
- Fehlt eine Datei auf der Platte, zeigte der Browser ein kaputtes
  Bildsymbol. Jetzt bleibt die Fläche ruhig.

111 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-01 14:17:15 +00:00
1 parent ef2e945080
commit 15634f38bb
11 files changed
+842 -35

No files matched your search

+9
View File
@@ -23,6 +23,15 @@ services:
SEED_LEA_PASSWORD: "${SEED_LEA_PASSWORD}" SEED_LEA_PASSWORD: "${SEED_LEA_PASSWORD}"
PUBLIC_BASE_URL: "${PUBLIC_BASE_URL}" PUBLIC_BASE_URL: "${PUBLIC_BASE_URL}"
N8N_WEBHOOK_URL: "${N8N_WEBHOOK_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: depends_on:
- db - db
- minio - minio
+40
View File
@@ -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);
+19
View File
@@ -336,6 +336,21 @@ export default function AdminApp() {
von selbst und es kommt kein weiterer Anbieter ins Spiel. Gebraucht wird es für von selbst und es kommt kein weiterer Anbieter ins Spiel. Gebraucht wird es für
<b> Passwort zurücksetzen</b>, <b>Hinweise zu fertigen Aufträgen</b> und <b>Teilen-Links per Mail</b>. <b> Passwort zurücksetzen</b>, <b>Hinweise zu fertigen Aufträgen</b> und <b>Teilen-Links per Mail</b>.
Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.</div> Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.</div>
{s.smtp_quelle === 'umgebung' && s.smtp_wirksam && (
<div className="hinweiskasten">
<b>Der Versand läuft bereits</b> — über die Server-Einstellung
(<code>{s.smtp_wirksam.user}</code> auf <code>{s.smtp_wirksam.host}:{s.smtp_wirksam.port}</code>).
Die Felder unten sind leer, weil hier nichts eingetragen ist; das ist in Ordnung.
Wer hier etwas einträgt und speichert, <b>überschreibt</b> die Server-Einstellung.
</div>
)}
{s.smtp_quelle === 'datenbank' && (
<div className="hinweiskasten">Der Versand läuft über das, was hier eingetragen ist.</div>
)}
{!s.smtp_quelle && (
<div className="hinweiskasten warn">Noch kein Versand eingerichtet — Passwort vergessen,
Auftragshinweise und der Mailversand von Teilen-Links funktionieren bis dahin nicht.</div>
)}
<div className="zwei"> <div className="zwei">
<div className="feld"><label>Postausgangsserver</label> <div className="feld"><label>Postausgangsserver</label>
<input className="input" placeholder="z. B. w020d97a.kasserver.com" value={s.smtp_host ?? ''} onChange={(e) => field('smtp_host', e.target.value)} /></div> <input className="input" placeholder="z. B. w020d97a.kasserver.com" value={s.smtp_host ?? ''} onChange={(e) => field('smtp_host', e.target.value)} /></div>
@@ -599,6 +614,10 @@ function AdminStyles() {
.zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;} .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);} .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;} .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 /* Am Handy war alles zweispaltig — ein Feld „Postausgangsserver" bekam so
rund 156 px. Und Tippziele unter 44 px trifft man nicht zuverlässig. */ rund 156 px. Und Tippziele unter 44 px trifft man nicht zuverlässig. */
@media(max-width:560px){ @media(max-width:560px){
+228 -22
View File
@@ -6,6 +6,7 @@ import {
} from '../lib/printlayout'; } from '../lib/printlayout';
import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize'; import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize';
import { baseCrop, clampCrop, centred, limitsFor, type CropRel, type FitMode } from '../lib/cropmath'; 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 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<any[]>([]); const [targets, setTargets] = useState<any[]>([]);
const [editing, setEditing] = useState<string | null>(null); const [editing, setEditing] = useState<string | null>(null);
const [picker, setPicker] = useState(false); 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<any[]>([]); const [presets, setPresets] = useState<any[]>([]);
const [presetSel, setPresetSel] = useState(''); const [presetSel, setPresetSel] = useState('');
const [busy, setBusy] = useState<null | 'pdf' | 'single'>(null); const [busy, setBusy] = useState<null | 'pdf' | 'single'>(null);
@@ -292,6 +295,32 @@ export default function PrintApp() {
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json()) const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
.then((j) => setPresets(j.presets || [])).catch(() => {}); .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 addFromLibrary = (it: any) => {
const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0); const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0);
const id = uid(); const id = uid();
@@ -475,6 +504,15 @@ export default function PrintApp() {
<span className="drop-fein">Auch mit <span className="kbd">⌘V</span> einfügen · bis zu {MAX_BILDER} Stück · <span className="drop-fein">Auch mit <span className="kbd">⌘V</span> einfügen · bis zu {MAX_BILDER} Stück ·
kein KI-Schritt, nur zuschneiden, skalieren, setzen</span> kein KI-Schritt, nur zuschneiden, skalieren, setzen</span>
</button> </button>
{/* 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. */}
<div className="oder"><span>oder</span></div>
<button type="button" className="drop drop-lib" onClick={() => setPicker(true)}>
<span className="drop-titel">Aus der Bibliothek wählen</span>
<span className="drop-fein">Alles, was Klarbild schon erzeugt hat — mit Suche,
nach Ordner, und mehrere auf einmal</span>
</button>
{presets.length > 0 && ( {presets.length > 0 && (
<div className="schnellstart"> <div className="schnellstart">
<span className="mono-label">Oder mit einer Vorlage beginnen</span> <span className="mono-label">Oder mit einer Vorlage beginnen</span>
@@ -500,7 +538,10 @@ export default function PrintApp() {
onDuplicate={() => duplicate(c.id)} onDuplicate={() => duplicate(c.id)}
onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} /> onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} />
))} ))}
<button className="zelle-add" onClick={() => fileRef.current?.click()}>+ Bild hinzufügen</button> <div className="zelle-add-gruppe">
<button className="zelle-add" onClick={() => fileRef.current?.click()}>+ Hochladen</button>
<button className="zelle-add" onClick={() => setPicker(true)}>+ Aus Bibliothek</button>
</div>
</div> </div>
)} )}
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden <input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
@@ -724,12 +765,31 @@ export default function PrintApp() {
: <>Im Druckdialog <b>„Tatsächliche Größe" / 100 %</b> wählen — nicht „an Seitengröße anpassen".</>} : <>Im Druckdialog <b>„Tatsächliche Größe" / 100 %</b> wählen — nicht „an Seitengröße anpassen".</>}
</div> </div>
</div> </div>
{/* Zuletzt gedruckt — zugeklappt, weil es beim Arbeiten nicht stört,
aber zur Hand ist, wenn man denselben Bogen noch einmal braucht. */}
<Karte nr="4" titel="Zuletzt gedruckt">
<div className="buehne">
<details className="verlauf" onToggle={(e) => setVerlaufOffen((e.target as HTMLDetailsElement).open)}>
<summary>
<span>Liste anzeigen</span>
<em>denselben Bogen noch einmal erzeugen</em>
</summary>
<div className="verlauf-inhalt">
{verlaufOffen && <PrintRuns onLaden={configUebernehmen} />}
</div>
</details>
</div>
</Karte>
</div> </div>
</div> </div>
{editCell && <CropModal cell={editCell} onClose={() => setEditing(null)} {editCell && <CropModal cell={editCell} onClose={() => setEditing(null)}
onChange={(crop) => patch(editCell.id, { crop })} />} onChange={(crop) => patch(editCell.id, { crop })} />}
{picker && <LibraryPicker onClose={() => setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />} {picker && (
<LibraryPicker onClose={() => setPicker(false)}
onPick={(gewaehlt) => { gewaehlt.forEach(addFromLibrary); setPicker(false); }} />
)}
{toast && ( {toast && (
<div className={`toast ${toast.bleibt ? 'bleibt' : ''}`} role="status" aria-live="polite"> <div className={`toast ${toast.bleibt ? 'bleibt' : ''}`} role="status" aria-live="polite">
<span>{toast.text}</span> <span>{toast.text}</span>
@@ -1116,41 +1176,136 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
/* ---------------- Bibliothek ---------------- */ /* ---------------- 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<any[]>([]); const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [ordner, setOrdner] = useState<any[]>([]);
const modalRef = useRef<HTMLDivElement>(null); const [laden, setLaden] = useState(true);
const [fehler, setFehler] = useState(false);
const [suche, setSuche] = useState('');
const [imOrdner, setImOrdner] = useState('');
const [gewaehlt, setGewaehlt] = useState<string[]>([]);
const box = useRef<HTMLDivElement>(null);
const titelId = useId(); const titelId = useId();
useEffect(() => { const holen = useCallback(() => {
fetch('/api/items').then((r) => r.json()) setLaden(true); setFehler(false);
.then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(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(() => { useEffect(() => {
const y = window.scrollY; const y = window.scrollY;
const vorher = document.activeElement as HTMLElement | null; const vorher = document.activeElement as HTMLElement | null;
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; window.scrollTo(0, y); vorher?.focus?.(); }; box.current?.querySelector<HTMLElement>('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 ( return (
<div className="modal" onClick={onClose}> <div className="modal" onClick={onClose}>
<div className="modal-inhalt breit" ref={modalRef} role="dialog" aria-modal="true" aria-labelledby={titelId} <div className="modal-inhalt breit lib" ref={box} role="dialog" aria-modal="true" aria-labelledby={titelId}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div className="kopfzeile"> <div className="kopfzeile">
<h2 className="mono-label" id={titelId}>Aus der Bibliothek wählen</h2> <h2 className="mono-label" id={titelId}>Aus der Bibliothek wählen</h2>
<button className="link" onClick={onClose}>Schließen</button> <button className="link" onClick={onClose}>Schließen</button>
</div> </div>
<div className="lib-filter">
<input className="input" value={suche} onChange={(e) => setSuche(e.target.value)}
placeholder="Nach Name oder Beschreibung suchen …" aria-label="Bibliothek durchsuchen" />
{ordner.length > 0 && (
<select className="select" value={imOrdner} onChange={(e) => setImOrdner(e.target.value)}
aria-label="Ordner">
<option value="">Alle Ordner</option>
{ordner.map((o: any) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
)}
</div>
<div className="picker"> <div className="picker">
{loading ? <p className="leer">Wird geladen …</p> {laden ? <p className="leer">Wird geladen …</p>
: fehler ? (
<p className="leer">Die Bibliothek ließ sich nicht laden.{' '}
<button className="pbtn" onClick={holen}>Noch einmal</button></p>
)
: !items.length ? <p className="leer">Noch keine Bilder in der Bibliothek.</p> : !items.length ? <p className="leer">Noch keine Bilder in der Bibliothek.</p>
: items.map((it) => ( : !gefiltert.length ? <p className="leer">Nichts gefunden. Andere Suche oder Ordner probieren.</p>
<button key={it.id} className="pick" onClick={() => onPick(it)} title={it.filename}> : gefiltert.map((it) => {
<img src={`/api/items/${it.id}/file?thumb=1`} alt="" loading="lazy" /> const an = gewaehlt.includes(it.id);
<span>{it.filename}</span> return (
</button> <button key={it.id} type="button" className={`pick ${an ? 'an' : ''}`}
))} aria-pressed={an} onClick={() => umschalten(it.id)} title={it.filename}>
<span className="pick-bild">
{/* Fehlt die Datei auf der Platte, zeigt der Browser sonst ein
kaputtes Bildsymbol. Lieber eine ruhige Fläche — und die
Kachel bleibt anklickbar, der Druck scheitert dann sauber
mit einer Meldung statt mit einem leeren Feld. */}
<img src={`/api/items/${it.id}/file?thumb=1`} alt="" loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; }} />
<span className="pick-haken">{an ? '✓' : ''}</span>
</span>
<span className="pick-name">{it.filename}</span>
{it.output_px && <span className="pick-mass">{it.output_px.replace('x', ' × ')}</span>}
</button>
);
})}
</div>
<div className="lib-fuss">
<span className="fein">
{gewaehlt.length
? `${zahl(gewaehlt.length, 'Bild', 'Bilder')} gewählt`
: `${zahl(gefiltert.length, 'Bild', 'Bilder')}${suche || imOrdner ? ' gefunden' : ' in der Bibliothek'}`}
</span>
<div className="reihe">
{gewaehlt.length > 0 && (
<button className="pbtn" onClick={() => setGewaehlt([])}>Auswahl aufheben</button>
)}
<button className="knopf schmal" onClick={uebernehmen} disabled={!gewaehlt.length}>
{gewaehlt.length > 1 ? `${gewaehlt.length} übernehmen` : 'Übernehmen'}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1170,7 +1325,12 @@ function PrintStyles() {
.arbeit-rechts{position:sticky;top:82px;} .arbeit-rechts{position:sticky;top:82px;}
@media(max-width:1080px){ @media(max-width:1080px){
.arbeit{grid-template-columns:1fr;gap:14px;} .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);} .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-titel{font-size:15px;font-weight:600;}
.drop-fein{color:var(--soft);font-size:12.5px;line-height:1.6;max-width:44ch;} .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;} .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);} .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;} .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; .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;} display:flex;flex-direction:column;font-family:inherit;}
.pick img{width:100%;aspect-ratio:1;object-fit:cover;display:block;} .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);} .pick:hover{border-color:var(--accent);}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7; .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;
+217
View File
@@ -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<Run[]>([]);
const [laden, setLaden] = useState(true);
const [fehler, setFehler] = useState(false);
const [busy, setBusy] = useState<string | null>(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 <p className="pr-leer">Wird geladen …</p>;
if (fehler) return (
<div className="pr">
<p className="pr-leer">Der Verlauf ließ sich nicht laden.{' '}
<button className="pr-btn" onClick={() => { setLaden(true); holen(); }}>Noch einmal</button></p>
<PrintRunsStyles />
</div>
);
if (!runs.length) return (
<div className="pr">
<div className="pr-leer-box">
<span className="regmark" />
<h2>Noch nichts gedruckt</h2>
<p>Sobald du im <a href="/drucken">Druckbereich</a> ein PDF erzeugst, steht es hier —
mit einem Knopf, um genau denselben Bogen noch einmal zu bekommen.</p>
</div>
<PrintRunsStyles />
</div>
);
return (
<div className="pr">
{msg && <p className={`pr-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
<div className="pr-liste">
{runs.map((r) => (
<article key={r.id} className="pr-karte">
<div className="pr-kopf">
<div className="pr-titel">
<h3>{r.summary || 'Druckbogen'}</h3>
<span className="pr-wann">{wann(r.created_at)}</span>
</div>
{r.origin === 'wiederholung' && <span className="pr-marke">wiederholt</span>}
{r.origin === 'telegram' && <span className="pr-marke">Telegram</span>}
</div>
<div className="pr-meta">
{r.paper} · {zahl(r.pages, 'Bogen', 'Bogen')} · {zahl(r.pieces, 'Teil', 'Teile')}
{r.bytes ? <> · das PDF war {groesse(r.bytes)} groß</> : null}
</div>
{r.bilder.length > 0 && (
<div className="pr-bilder">
{r.bilder.slice(0, 8).map((b) => (
<img key={b.id} src={`/api/items/${b.id}/file?thumb=1`} alt={b.filename || ''}
title={b.filename || ''} loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; }} />
))}
{r.bilder.length > 8 && <span className="pr-mehr">+{r.bilder.length - 8}</span>}
</div>
)}
<div className="pr-aktionen">
{r.wiederholbar ? (
<button className="pr-btn stark" disabled={busy === r.id} onClick={() => nochmal(r)}>
{busy === r.id ? 'Wird erzeugt …' : 'Nochmal drucken'}
</button>
) : (
<span className="pr-warum">
{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.'}
</span>
)}
{onLaden && (
<button className="pr-btn" onClick={() => onLaden(r.config)}>Einstellung übernehmen</button>
)}
<button className="pr-btn gefahr" onClick={() => loeschen(r)}>Entfernen</button>
</div>
</article>
))}
</div>
<p className="pr-fein">
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.
</p>
<PrintRunsStyles />
</div>
);
}
function PrintRunsStyles() {
return <style>{`
.pr{display:grid;gap:14px;}
.pr-leer{color:var(--soft);}
.pr-leer-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:44px 24px;text-align:center;}
.pr-leer-box h2{margin:14px 0 8px;font-size:1.15rem;}
.pr-leer-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
.pr-liste{display:grid;gap:12px;}
.pr-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:15px;display:grid;gap:10px;}
.pr-kopf{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;}
.pr-titel{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline;min-width:0;flex:1;}
.pr-titel h3{margin:0;font-size:.98rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
.pr-wann{font-size:.78rem;color:var(--soft);white-space:nowrap;}
.pr-marke{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
padding:3px 8px;border-radius:20px;background:var(--paper);color:var(--soft);border:1px solid var(--line);}
.pr-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
.pr-bilder{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
.pr-bilder img{width:46px;height:46px;object-fit:cover;border:1px solid var(--line);border-radius:3px;
background:var(--paper);}
.pr-mehr{font-family:var(--font-mono);font-size:11px;color:var(--soft);}
.pr-aktionen{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
.pr-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
padding:9px 12px;font-size:.84rem;cursor:pointer;font-family:inherit;color:var(--ink);white-space:nowrap;}
.pr-btn:hover{border-color:var(--accent);}
.pr-btn.stark{background:var(--accent);color:var(--card);border-color:var(--accent);font-weight:600;}
.pr-btn.gefahr{color:var(--err);}
.pr-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
.pr-warum{font-size:.8rem;color:var(--soft);line-height:1.5;}
.pr-fein{margin:0;font-size:.78rem;color:var(--soft);line-height:1.6;max-width:70ch;}
.pr-msg{margin:0;font-size:.9rem;}
.pr-msg.ok{color:var(--ok);}
.pr-msg.err{color:var(--err);}
@media(max-width:640px){
.pr-btn{flex:1;text-align:center;padding:11px 10px;}
.pr-warum{flex-basis:100%;}
}
`}</style>;
}
+47 -13
View File
@@ -15,21 +15,55 @@ export interface MailCfg {
user: string; password: string; from: string; user: string; password: string; from: string;
} }
/** SMTP-Zugang aus den Einstellungen — `null`, wenn nichts konfiguriert ist. */ export interface MailQuelle { cfg: MailCfg; quelle: 'datenbank' | 'umgebung' }
export async function loadMailConfig(): Promise<MailCfg | null> {
/**
* 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<MailQuelle | null> {
const s = await one<any>( const s = await one<any>(
`SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, smtp_from FROM settings WHERE id=1`); `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 = ''; if (s?.smtp_host && s?.smtp_user && s?.smtp_password) {
try { password = decrypt(s.smtp_password); } catch { return null; } try {
return { return { quelle: 'datenbank', cfg: {
host: s.smtp_host, host: s.smtp_host,
port: Number(s.smtp_port) || 465, port: Number(s.smtp_port) || 465,
secure: s.smtp_secure !== false, secure: s.smtp_secure !== false,
user: s.smtp_user, user: s.smtp_user,
password, password: decrypt(s.smtp_password),
from: s.smtp_from || s.smtp_user, 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<MailCfg | null> {
return (await loadMailSource())?.cfg ?? null;
} }
export const mailReady = async (): Promise<boolean> => !!(await loadMailConfig()); export const mailReady = async (): Promise<boolean> => !!(await loadMailConfig());
+190
View File
@@ -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<string, number>();
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<string | null> {
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<RunListe[]> {
const runs = await query<PrintRun>(
`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<string, { id: string; filename: string | null }[]>();
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<PrintRun | null> {
return one<PrintRun>(
`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<boolean> {
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;
}
+5
View File
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { one, query } from '../../../lib/db'; import { one, query } from '../../../lib/db';
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto'; import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
import { loadMailSource } from '../../../lib/mail';
export const prerender = false; export const prerender = false;
const randomToken = () => 'klb_' + randomBytes(24).toString('hex'); const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
@@ -10,6 +11,7 @@ const json = (b: unknown, s = 200) =>
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
const s = await one<any>('SELECT * FROM settings WHERE id=1'); const s = await one<any>('SELECT * FROM settings WHERE id=1');
const mail = await loadMailSource();
if (!s) return json({ settings: null }); if (!s) return json({ settings: null });
let orMask = ''; let orMask = '';
if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'gesetzt'; } } 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) // 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_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, 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 // Teilen
share_default_days: s.share_default_days, share_default_days: s.share_default_days,
} }); } });
+23
View File
@@ -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);
};
+21
View File
@@ -7,6 +7,7 @@ import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
import { cfgForKey } from '../../../lib/delivery'; import { cfgForKey } from '../../../lib/delivery';
import { uploadBuffer } from '../../../lib/picdrop'; import { uploadBuffer } from '../../../lib/picdrop';
import { one } from '../../../lib/db'; import { one } from '../../../lib/db';
import { recordRun, kurzfassung } from '../../../lib/printruns';
export const prerender = false; 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), { return new Response(Buffer.from(bytes), {
headers: { headers: {
'Content-Type': 'application/pdf', 'Content-Type': 'application/pdf',
+43
View File
@@ -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('…'));
});