3 Commits

Author SHA1 Message Date
s4luorth
84427d8ca3 Motiv-/Datei-Upload: ueber oeffentlichen Proxy (Gaeste ohne Login)
All checks were successful
Build & Deploy / deploy (push) Successful in 45s
uploadFile schickt die Datei als Base64 an /skrift-orders/upload statt direkt an
Directus /files (das braucht einen Token, den Gaeste nicht haben).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:13:45 +02:00
s4luorth
04a04d6e86 /textmenge: volle App-Breite, Vorschau ohne Scroll + Vergroessern + Bild-Download
All checks were successful
Build & Deploy / deploy (push) Successful in 41s
- Wrapper .sk-konf (Breite 1:1 wie Konfigurator/neuer Auftrag).
- Vorschau skaliert (kein Inner-Scroll), Klick oeffnet Lightbox (Vergroessern).
- Buttons 'Vergroessern' und 'Bild herunterladen' (SVG->PNG).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 15:30:40 +02:00
s4luorth
267db7125a /textmenge: Design ueberarbeitet, eigener Vorschau-Button, einseitig
All checks were successful
Build & Deploy / deploy (push) Successful in 45s
- Eigener 'Vorschau generieren'-Button direkt unter dem Textfeld (nicht mehr am
  Vorschau-Kontingent haengend -> war der Grund, dass er nicht klickbar war).
- Checkbox 'Realistische Handschrift'.
- Einseitig: kein Seitenumbruch-Hinweis mehr.
- Mehr Layout-Abstand (nicht mehr alles oben), keine Gedankenstriche.
- Vorschau als Karte darunter mit Kugelschreiber-Optik bzw. Ueberlauf-Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 15:19:47 +02:00
2 changed files with 178 additions and 50 deletions

View File

@@ -481,17 +481,21 @@ export const paypalClientId = (s: PublicSettings | null) =>
/** Lädt eine Datei nach Directus und liefert deren ID. */ /** Lädt eine Datei nach Directus und liefert deren ID. */
export async function uploadFile(datei: File): Promise<string> { export async function uploadFile(datei: File): Promise<string> {
const form = new FormData(); // Als Base64 (Data-URL) an den öffentlichen Upload-Proxy schicken – so können
form.append('file', datei); // auch Gäste (Bestellung ohne Login) Motive/Quelldateien hochladen. Der direkte
// Directus-/files-Upload braucht einen Token, den Gäste nicht haben.
const session = await ensureFreshToken(); const data_base64 = await new Promise<string>((resolve, reject) => {
const res = await fetch(`${BASE}/files`, { const r = new FileReader();
method: 'POST', r.onload = () => resolve(String(r.result || ''));
headers: session ? { Authorization: `Bearer ${session.access_token}` } : undefined, r.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
body: form, r.readAsDataURL(datei);
}); });
const data = await parse(res); const data = await request('/skrift-orders/upload', {
return data?.data?.id; method: 'POST',
auth: false,
body: { filename: datei.name, content_type: datei.type || 'application/octet-stream', data_base64 },
});
return data?.id;
} }
/* ── Angebotsanfrage ──────────────────────────────────────────────────────── */ /* ── Angebotsanfrage ──────────────────────────────────────────────────────── */

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { fetchFormats, fetchSettings, generatePreview } from '../lib/api'; import { fetchFormats, fetchSettings, generatePreview } from '../lib/api';
import type { Format, PublicSettings, PreviewDoc, PreviewOverflow } from '../lib/api'; import type { Format, PublicSettings, PreviewDoc, PreviewOverflow } from '../lib/api';
import PreviewPanel from '../components/PreviewPanel';
/** Schriftarten wie im Konfigurator/manuellen Druck. */ /** Schriftarten wie im Konfigurator/manuellen Druck. */
const SCHRIFTEN = [ const SCHRIFTEN = [
@@ -10,6 +9,54 @@ const SCHRIFTEN = [
{ key: 'ellie', name: 'Ellie' }, { key: 'ellie', name: 'Ellie' },
]; ];
/** Kugelschreiber-Optik (identisch zum PreviewPanel): blaue Tinte, dezenter Wobble. */
let inkSeq = 0;
const inkify = (svg: string): string => {
if (typeof svg !== 'string') return svg;
const id = `skink${inkSeq++}`;
return svg
.replace(/stroke="#0{3,6}"/gi, 'stroke="#1a2a6c" stroke-opacity="0.95"')
.replace(/stroke-width="0\.6"/gi, 'stroke-width="1.4"')
.replace(/(<svg[^>]*>)/i, `$1<defs><filter id="${id}" x="-4%" y="-4%" width="108%" height="108%" color-interpolation-filters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="1" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale="1.3" xChannelSelector="R" yChannelSelector="G"/></filter></defs>`)
.replace(/(<rect[^>]*fill="#FFFFFF"[^>]*\/>)/i, `$1<g filter="url(#${id})">`)
.replace(/<\/svg>\s*$/i, '</g></svg>');
};
/** Musterbild als PNG herunterladen (SVG → Canvas), wie im PreviewPanel. */
function bildSpeichern(svg: string) {
const m = svg.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 inkSvg = inkify(svg);
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;
canvas.height = h;
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-textmenge.png';
a.click();
URL.revokeObjectURL(a.href);
}
URL.revokeObjectURL(url);
}, 'image/png');
} else {
URL.revokeObjectURL(url);
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
/** Stabile anonyme Kennung fürs Vorschau-Kontingent (wie im Konfigurator). */ /** Stabile anonyme Kennung fürs Vorschau-Kontingent (wie im Konfigurator). */
function clientId(): string { function clientId(): string {
const KEY = 'skrift.client'; const KEY = 'skrift.client';
@@ -32,24 +79,24 @@ function overflowMeldung(overflow?: PreviewOverflow[]) {
} }
/** /**
* Textmengen-Tool (/textmenge): Format- und Schriftwahl, Texteingabe mit denselben * Textmengen-Tool (/textmenge): Format- und Schriftwahl, Texteingabe (Tabulator,
* Möglichkeiten wie sonst (Tabulator, gesperrte Zeichen, [[seitenumbruch]]). Über * gesperrte Zeichen) und „Vorschau generieren" → rendert EIN Schriftstück
* „Vorschau generieren" wird das Schriftstück gerendert (Kugelschreiber-Optik) – * (Kugelschreiber-Optik). Passt der Text nicht auf eine Seite, kommt die Fehlermeldung.
* so sieht man, ob der Text passt, sonst kommt eine Fehlermeldung. * Bewusst einseitig: kein Seitenumbruch.
*/ */
export default function Textmenge() { export default function Textmenge() {
const [formate, setFormate] = useState<Format[]>([]); const [formate, setFormate] = useState<Format[]>([]);
const [settings, setSettings] = useState<PublicSettings | null>(null); const [settings, setSettings] = useState<PublicSettings | null>(null);
const [format, setFormat] = useState('a4'); const [format, setFormat] = useState('a4');
const [font, setFont] = useState('tilda'); const [font, setFont] = useState('tilda');
const [realistic, setRealistic] = useState(true);
const [text, setText] = useState(''); const [text, setText] = useState('');
const [hinweis, setHinweis] = useState(''); const [hinweis, setHinweis] = useState('');
const [docs, setDocs] = useState<PreviewDoc[]>([]); const [doc, setDoc] = useState<PreviewDoc | null>(null);
const [laedt, setLaedt] = useState(false); const [laedt, setLaedt] = useState(false);
const [fehler, setFehler] = useState<string | null>(null); const [fehler, setFehler] = useState<string | null>(null);
const [verbleibend, setVerbleibend] = useState(0); const [gross, setGross] = useState(false);
const [maximal, setMaximal] = useState(0);
useEffect(() => { useEffect(() => {
fetchFormats() fetchFormats()
@@ -61,6 +108,14 @@ export default function Textmenge() {
fetchSettings().then(setSettings).catch(() => {}); fetchSettings().then(setSettings).catch(() => {});
}, []); }, []);
// Escape schließt die Großansicht.
useEffect(() => {
if (!gross) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setGross(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [gross]);
// Gesperrte Zeichen wie im Konfigurator (Zeilenumbruch/Tab bleiben erlaubt). // Gesperrte Zeichen wie im Konfigurator (Zeilenumbruch/Tab bleiben erlaubt).
const verbotenRe = useMemo(() => { const verbotenRe = useMemo(() => {
const wl = settings?.char_whitelist; const wl = settings?.char_whitelist;
@@ -82,14 +137,15 @@ export default function Textmenge() {
return v.replace(verbotenRe, ''); return v.replace(verbotenRe, '');
}; };
// Neuer Text/Format/Schrift → alte Vorschau verwerfen (nicht live neu laden). // Änderung an Eingaben → alte Vorschau verwerfen.
useEffect(() => { useEffect(() => {
setDocs([]); setDoc(null);
setFehler(null); setFehler(null);
}, [text, font, format]); setGross(false);
}, [text, font, format, realistic]);
async function generieren() { async function generieren() {
if (!text.trim()) return; if (!text.trim() || laedt) return;
setLaedt(true); setLaedt(true);
setFehler(null); setFehler(null);
try { try {
@@ -98,18 +154,17 @@ export default function Textmenge() {
text, text,
font, font,
format, format,
realistic,
entries: [{}], entries: [{}],
}); });
setVerbleibend(ergebnis.remaining ?? 0);
setMaximal(ergebnis.limit ?? 0);
if (ergebnis.has_overflow) { if (ergebnis.has_overflow) {
setDocs([]); setDoc(null);
setFehler(overflowMeldung(ergebnis.overflow)); setFehler(overflowMeldung(ergebnis.overflow));
} else { } else {
setDocs(ergebnis.docs ?? []); setDoc((ergebnis.docs ?? []).find((d) => d.art === 'brief') ?? ergebnis.docs?.[0] ?? null);
} }
} catch (e) { } catch (e) {
setDocs([]); setDoc(null);
setFehler((e as Error)?.message || 'Vorschau fehlgeschlagen.'); setFehler((e as Error)?.message || 'Vorschau fehlgeschlagen.');
} finally { } finally {
setLaedt(false); setLaedt(false);
@@ -117,15 +172,17 @@ export default function Textmenge() {
} }
return ( return (
<div className="sk-page" style={{ maxWidth: 980, margin: '0 auto' }}> <div className="sk-konf">
<h1 style={{ margin: '0 0 6px' }}>Textmenge prüfen</h1> <div>
<p style={{ color: 'var(--sk-muted, #666)', margin: '0 0 20px' }}> <h1 style={{ margin: '0 0 8px', fontSize: 28 }}>Textmenge prüfen</h1>
Text eingeben, Format und Schrift wählen und eine Vorschau erzeugen – so siehst du, ob der Text auf ein Schriftstück passt. <p style={{ color: 'var(--sk-muted, #666)', margin: 0, lineHeight: 1.6 }}>
</p> Text eingeben, Format und Schrift wählen, dann eine Vorschau erzeugen. So siehst du, ob der Text auf ein Schriftstück passt.
</p>
</div>
<div className="sk-card"> <div className="sk-card">
<div className="sk-card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div className="sk-card-body" style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<label className="sk-field" style={{ flex: '1 1 200px' }}> <label className="sk-field" style={{ flex: '1 1 200px' }}>
<span className="sk-label">Format</span> <span className="sk-label">Format</span>
<select className="sk-input" value={format} onChange={(e) => setFormat(e.target.value)}> <select className="sk-input" value={format} onChange={(e) => setFormat(e.target.value)}>
@@ -145,6 +202,11 @@ export default function Textmenge() {
</label> </label>
</div> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 15 }}>
<input type="checkbox" checked={realistic} onChange={(e) => setRealistic(e.target.checked)} style={{ width: 18, height: 18 }} />
Realistische Handschrift (natürliche Variation)
</label>
<label className="sk-field"> <label className="sk-field">
<span className="sk-label">Text · {text.length} Zeichen</span> <span className="sk-label">Text · {text.length} Zeichen</span>
<textarea <textarea
@@ -152,10 +214,9 @@ export default function Textmenge() {
rows={12} rows={12}
value={text} value={text}
placeholder="Text eingeben oder einfügen …" placeholder="Text eingeben oder einfügen …"
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', resize: 'vertical' }} style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', resize: 'vertical', lineHeight: 1.6 }}
onChange={(e) => setText(saeubere(e.target.value))} onChange={(e) => setText(saeubere(e.target.value))}
onKeyDown={(e) => { onKeyDown={(e) => {
// Tab fügt einen echten Tabulator ein (z. B. Datum an den rechten Rand).
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return; if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault(); e.preventDefault();
const ta = e.currentTarget; const ta = e.currentTarget;
@@ -168,27 +229,90 @@ export default function Textmenge() {
}} }}
/> />
</label> </label>
<button
type="button"
className="sk-btn sk-btn--primary"
disabled={!text.trim() || laedt}
onClick={generieren}
style={{ alignSelf: 'flex-start', opacity: !text.trim() || laedt ? 0.6 : 1 }}
>
{laedt ? 'Vorschau wird erzeugt …' : 'Vorschau generieren'}
</button>
<p style={{ color: 'var(--sk-muted, #666)', fontSize: 12, margin: 0, lineHeight: 1.5 }}> <p style={{ color: 'var(--sk-muted, #666)', fontSize: 12, margin: 0, lineHeight: 1.5 }}>
Tabulator (↹) rückt Text ein · <code>[[seitenumbruch]]</code> erzwingt eine neue Seite · gesperrte Zeichen werden entfernt. Tabulator (↹) rückt Text ein. Gesperrte Zeichen werden automatisch entfernt.
</p> </p>
{hinweis && <p style={{ color: 'var(--sk-danger, #c62828)', fontSize: 13, margin: 0 }}>{hinweis}</p>} {hinweis && <p style={{ color: 'var(--sk-danger, #c62828)', fontSize: 13, margin: 0 }}>{hinweis}</p>}
</div> </div>
</div> </div>
<div style={{ marginTop: 16 }}> {/* Vorschau / Ergebnis */}
<PreviewPanel <div className="sk-card">
titel="Vorschau" <div className="sk-card-body">
format={format} {fehler ? (
docs={docs} <div
gesamtDokumente={docs.length} style={{
verbleibend={verbleibend} display: 'flex', gap: 10, alignItems: 'flex-start', color: '#c62828',
maximal={maximal} background: 'rgba(198,40,40,.08)', border: '1px solid rgba(198,40,40,.25)',
laedt={laedt} borderRadius: 10, padding: '14px 16px', fontSize: 15,
fehler={fehler} }}
onGenerate={generieren} >
bereit={!!text.trim()} <span style={{ fontSize: 18, lineHeight: 1 }}>⚠</span>
/> <span>{fehler}</span>
</div>
) : doc ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', width: '100%' }}>
<span
style={{
fontSize: 13, fontWeight: 600, padding: '4px 12px', borderRadius: 999,
background: 'rgba(46,125,50,.12)', color: '#2e7d32',
}}
>
✓ Passt auf eine Seite
</span>
<span style={{ flex: 1 }} />
<button type="button" className="sk-btn" onClick={() => setGross(true)}>🔍 Vergrößern</button>
<button type="button" className="sk-btn sk-btn--primary" onClick={() => bildSpeichern(doc.svg)}>⬇ Bild herunterladen</button>
</div>
<div
className="sk-preview-doc-sheet"
onClick={() => setGross(true)}
style={{ width: '100%', maxWidth: 460, cursor: 'zoom-in' }}
dangerouslySetInnerHTML={{ __html: inkify(doc.svg) }}
/>
</div>
) : (
<p style={{ color: 'var(--sk-muted, #666)', margin: 0, textAlign: 'center', padding: '40px 0' }}>
Noch keine Vorschau. Text eingeben und „Vorschau generieren" drücken.
</p>
)}
</div>
</div> </div>
{/* Großansicht (Lightbox) */}
{gross && doc && (
<div
onClick={() => setGross(false)}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,.72)', zIndex: 1000,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 16, padding: 24,
}}
>
<div style={{ display: 'flex', gap: 10 }} onClick={(e) => e.stopPropagation()}>
<button type="button" className="sk-btn sk-btn--primary" onClick={() => bildSpeichern(doc.svg)}>⬇ Bild herunterladen</button>
<button type="button" className="sk-btn" onClick={() => setGross(false)}>Schließen</button>
</div>
<div
className="sk-preview-doc-sheet"
onClick={(e) => e.stopPropagation()}
style={{ width: 'min(92vw, 62vh)', maxHeight: '86vh', overflow: 'auto' }}
dangerouslySetInnerHTML={{ __html: inkify(doc.svg) }}
/>
</div>
)}
</div> </div>
); );
} }