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:
11 files changed
+842
-35
No files matched your search
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user