Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 26s

This commit is contained in:
Lucas Orth
2026-08-24 12:13:54 +02:00
commit 32aa0a549f
38 changed files with 7881 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
import { useEffect, useMemo, useState } from 'react';
/**
* Live-Vorschau neben dem Formular.
*
* Die Vorschau ist absichtlich kontingentiert: Jede Generierung kostet einen
* Aufruf der Scriptalizer-API (Tageslimit). Der Zähler ist daher Teil der
* Bedienung und kein Beiwerk.
*
* Schriftstück und zugehöriger Umschlag gehören zusammen und werden gemeinsam
* gezeigt (Schriftstück oben, Umschlag unten). Die Pfeile blättern je Empfänger.
*/
export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
/** Ein Empfänger-Dokument: Schriftstück + optional zugehöriger Umschlag. */
type Gruppe = { brief?: PreviewDoc; umschlag?: PreviewDoc };
type Props = {
titel: string;
format: string;
docs: PreviewDoc[];
gesamtDokumente: number;
verbleibend: number;
maximal: number;
laedt: boolean;
fehler: string | null;
onGenerate: () => void;
bereit: boolean;
};
/** Flache [brief, umschlag, brief, …]-Liste zu Empfänger-Gruppen bündeln. */
function gruppiere(docs: PreviewDoc[]): Gruppe[] {
const gruppen: Gruppe[] = [];
for (const d of docs) {
if (d.art === 'umschlag') {
const letzte = gruppen[gruppen.length - 1];
if (letzte && letzte.brief && !letzte.umschlag) letzte.umschlag = d;
else gruppen.push({ umschlag: d });
} else {
gruppen.push({ brief: d });
}
}
return gruppen;
}
export default function PreviewPanel({
titel, format, docs, gesamtDokumente, verbleibend, maximal, laedt, fehler, onGenerate, bereit,
}: Props) {
const [index, setIndex] = useState(0);
const [zoomSvg, setZoomSvg] = useState<string | null>(null);
// Die Zoom-Fläche wird auf den Inhaltsbereich begrenzt (unterhalb der
// Preiszeile, rechts der Seitenleiste), statt das ganze Fenster zu belegen.
const [zoomBox, setZoomBox] = useState<React.CSSProperties>({});
const gruppen = useMemo(() => gruppiere(docs), [docs]);
function messeBox(): React.CSSProperties {
if (typeof window === 'undefined' || window.innerWidth <= 900) return {};
const content = document.querySelector('.sk-content');
if (!content) return {};
const c = content.getBoundingClientRect();
const bar = document.querySelector('.sk-pricebar');
const top = Math.max(c.top, bar ? bar.getBoundingClientRect().bottom : c.top);
return {
top: `${top}px`, left: `${c.left}px`, width: `${c.width}px`,
height: `${Math.max(0, c.bottom - top)}px`, right: 'auto', bottom: 'auto',
};
}
function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
/** Seitenverhältnis (Breite/Höhe) aus der viewBox – sperrt die Lightbox-Abmessungen. */
function seitenverhaeltnis(svg: string): string {
const m = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
return m ? `${m[1]} / ${m[2]}` : '210 / 297';
}
/** Musterbild der großen Vorschau als PNG herunterladen (SVG → Canvas). */
function bildSpeichern() {
if (!zoomSvg) return;
const m = zoomSvg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
const w = Math.round(m ? Number(m[1]) : 794);
const h = Math.round(m ? Number(m[2]) : 1123);
const scale = 2; // für ein scharfes Musterbild
// Gespeicherte Vorschau in Kugelschreiber-Blau (wie die Anzeige); die
// an die Produktion gehenden SVGs bleiben davon unberührt (schwarz).
const inkSvg = zoomSvg.replace(/stroke="#0{3,6}"/gi, 'stroke="#1a2a6c"');
const url = URL.createObjectURL(new Blob([inkSvg], { type: 'image/svg+xml;charset=utf-8' }));
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((png) => {
if (png) {
const a = document.createElement('a');
a.href = URL.createObjectURL(png);
a.download = 'skrift-musterbild.png';
a.click();
URL.revokeObjectURL(a.href);
}
URL.revokeObjectURL(url);
}, 'image/png');
} else {
URL.revokeObjectURL(url);
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
// Nach einer neuen Generierung wieder vorn beginnen.
useEffect(() => { setIndex(0); }, [docs]);
// Vergrößerte Ansicht: Escape schließt, Resize misst die Fläche neu.
useEffect(() => {
if (!zoomSvg) return;
const zu = (e: KeyboardEvent) => e.key === 'Escape' && setZoomSvg(null);
const neu = () => setZoomBox(messeBox());
window.addEventListener('keydown', zu);
window.addEventListener('resize', neu);
return () => { window.removeEventListener('keydown', zu); window.removeEventListener('resize', neu); };
}, [zoomSvg]);
const aktuell = gruppen[index] ?? null;
const anzahl = gruppen.length || gesamtDokumente;
/** Ein einzelnes Dokument (Schriftstück oder Umschlag) mit Beschriftung. */
const DocBox = ({ label, svg }: { label: string; svg: string }) => (
<figure
className="sk-preview-doc"
onClick={() => openZoom(svg)}
role="button"
tabIndex={0}
title="Zum Vergrößern klicken"
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openZoom(svg); }}
>
<figcaption className="sk-preview-doc-label">{label}</figcaption>
{/* Das SVG stammt aus unserer eigenen Generierung, nicht aus Nutzereingaben. */}
<div className="sk-preview-doc-sheet" dangerouslySetInnerHTML={{ __html: svg }} />
</figure>
);
return (
<aside className="sk-preview">
<div className="sk-preview-head">
<div>
<div className="sk-preview-title">Vorschau: {titel || '—'}</div>
<div className="sk-preview-sub">{format || '—'}</div>
</div>
</div>
{laedt ? (
<div className="sk-preview-sheet"><p className="sk-note">Vorschau wird erzeugt …</p></div>
) : aktuell ? (
<div className="sk-preview-docs">
{aktuell.brief && <DocBox label={`Schriftstück${format ? ` · ${format}` : ''}`} svg={aktuell.brief.svg} />}
{aktuell.umschlag && <DocBox label="Umschlag" svg={aktuell.umschlag.svg} />}
</div>
) : (
<div className="sk-preview-sheet">
<p className="sk-note" style={{ textAlign: 'center' }}>Noch keine Vorschau erzeugt.</p>
</div>
)}
{anzahl > 0 && (
<div className="sk-preview-pager">
<button className="sk-pager-btn" aria-label="Vorheriges Dokument"
disabled={index === 0 || gruppen.length <= 1}
onClick={() => setIndex((i) => Math.max(0, i - 1))}>‹</button>
<span className="sk-pager-label">
Dokument {gruppen.length ? index + 1 : 0} von {anzahl}
</span>
<button className="sk-pager-btn" aria-label="Nächstes Dokument"
disabled={index >= gruppen.length - 1 || gruppen.length <= 1}
onClick={() => setIndex((i) => Math.min(gruppen.length - 1, i + 1))}>›</button>
</div>
)}
{fehler && <p className="sk-error">{fehler}</p>}
<div className="sk-preview-quota">
Verbleibende Vorschau-Anfragen: {verbleibend}/{maximal}
</div>
<button className="sk-btn sk-btn--ghost sk-btn--block" onClick={onGenerate}
disabled={!bereit || laedt || verbleibend <= 0}
title={!bereit ? 'Bitte zuerst Text angeben' : undefined}>
{laedt ? 'Wird erzeugt …' : 'Vorschau generieren'}
</button>
{zoomSvg && (
<div className="sk-lightbox" style={zoomBox} onClick={() => setZoomSvg(null)} role="dialog" aria-modal="true"
aria-label="Vergrößerte Vorschau">
<div className="sk-lightbox-sheet" style={{ aspectRatio: seitenverhaeltnis(zoomSvg) }}
onClick={(e) => e.stopPropagation()}
dangerouslySetInnerHTML={{ __html: zoomSvg }} />
<div className="sk-lightbox-actions">
<button className="sk-lightbox-close" onClick={(e) => { e.stopPropagation(); bildSpeichern(); }}>
Bild speichern
</button>
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
Schließen
</button>
</div>
</div>
)}
</aside>
);
}