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

+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
<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>
{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="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>
@@ -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){
+228 -22
View File
@@ -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<any[]>([]);
const [editing, setEditing] = useState<string | null>(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<any[]>([]);
const [presetSel, setPresetSel] = useState('');
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())
.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() {
<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>
</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 && (
<div className="schnellstart">
<span className="mono-label">Oder mit einer Vorlage beginnen</span>
@@ -500,7 +538,10 @@ export default function PrintApp() {
onDuplicate={() => duplicate(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>
)}
<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".</>}
</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>
{editCell && <CropModal cell={editCell} onClose={() => setEditing(null)}
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 && (
<div className={`toast ${toast.bleibt ? 'bleibt' : ''}`} role="status" aria-live="polite">
<span>{toast.text}</span>
@@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const modalRef = useRef<HTMLDivElement>(null);
const [ordner, setOrdner] = useState<any[]>([]);
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();
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<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 (
<div className="modal" onClick={onClose}>
<div className="modal-inhalt breit" ref={modalRef} role="dialog" aria-modal="true" aria-labelledby={titelId}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div className="modal-inhalt breit lib" ref={box} role="dialog" aria-modal="true" aria-labelledby={titelId}
onClick={(e) => e.stopPropagation()}>
<div className="kopfzeile">
<h2 className="mono-label" id={titelId}>Aus der Bibliothek wählen</h2>
<button className="link" onClick={onClose}>Schließen</button>
</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">
{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.map((it) => (
<button key={it.id} className="pick" onClick={() => onPick(it)} title={it.filename}>
<img src={`/api/items/${it.id}/file?thumb=1`} alt="" loading="lazy" />
<span>{it.filename}</span>
</button>
))}
: !gefiltert.length ? <p className="leer">Nichts gefunden. Andere Suche oder Ordner probieren.</p>
: gefiltert.map((it) => {
const an = gewaehlt.includes(it.id);
return (
<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>
@@ -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;
+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>;
}