diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..f174082 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,96 @@ +# Klarbild — Gestaltung + +> Aus dem Code gelesen, nicht erfunden. Quelle der Wahrheit bleibt +> `src/styles/tokens.css`; diese Datei erklärt, **warum** die Werte so sind und wo sie +> hingehören. + +## Haltung + +Technisch-editorial. Das Vorbild ist der Andruckbogen, nicht das Dashboard: warmes +Papier, scharfe Ecken, feine Linien, eine Passermarke als Zeichen. Die Oberfläche soll +aussehen wie etwas, mit dem man **arbeitet** — nicht wie etwas, das verkauft werden will. + +Farbstrategie: **restrained**. Getönte Neutrale tragen die Fläche, ein einziges kräftiges +Blau markiert das, was man anfassen soll. Der Akzent bleibt deutlich unter 10 % der +Fläche. Bewusst: Auf dem Bildschirm liegen ständig **Fotos**, und die sollen die +einzigen bunten Dinge sein. + +## Theme + +**Hell, ohne Alternative.** Die Szene: Till oder Lea am Küchentisch oder im Büro, Tageslicht, +ein Foto von Phoenix auf dem Schirm, gleich soll es auf Papier. Wer Bilder beurteilt, die +auf hellem Papier landen, braucht einen hellen Untergrund — auf Dunkel wirkt jedes Bild +heller und wärmer, als es gedruckt wird. Ein Dunkelmodus wäre hier kein Komfort, sondern +eine Fehlerquelle. + +## Farben + +Alle Werte in OKLCH, alle Neutralen zum Papierton hin getönt (Hue 95, Chroma 0.004–0.01). +Kein `#000`, kein `#fff` — nirgends. + +| Rolle | Token | Verwendung | +|---|---|---| +| Papier | `--paper` | Seitengrund, vertiefte Flächen, Eingabefelder | +| Karte | `--card` | erhobene Flächen, Kopfleiste | +| Linie | `--line` | 1-px-Trennung; **nie** dicker als Zierde | +| Schrift | `--ink` / `--ink-soft` | Haupttext / Nebentext | +| Akzent | `--accent` | genau eine Sache je Ansicht: die Handlung, die weiterführt | +| Akzentgrund | `--accent-bg` | aktiver Zustand, 6 % Deckung | +| Zustände | `--ok` `--warn` `--err` | nur für Zustände, nie zur Zierde | + +**Regel für den Akzent:** Pro Ansicht ein blauer Knopf. Steht ein zweiter daneben, ist +einer davon falsch eingestuft. (Genau dieser Fehler stand kurz im Teilen-Dialog — +„Per E-Mail schicken" und „Fertig" waren beide blau.) + +## Typografie + +- **Space Grotesk** für alles Gelesene, **Space Mono** für Marken, Maße, Kennungen. + Beide selbst gehostet (`@fontsource`) — keine Anfrage an einen fremden Server. +- `.mono-label`: 10 px, `letter-spacing: .18em`, Großbuchstaben, `--soft`. Das ist die + **einzige** Art, Abschnitte zu betiteln. Sie kommt aus der Druckvorstufe und hält die + Oberfläche ruhig, weil sie nie mit dem Inhalt konkurriert. +- Fließtext 15–16 px, Zeilenhöhe 1.55–1.6, Zeilenlänge höchstens ~68ch. +- Überschriften mit `letter-spacing: -0.02em`; Stufenverhältnis mindestens 1.25. +- **16 px in jedem Eingabefeld** — darunter zoomt iOS beim Fokus hinein. Steht als Regel + in `tokens.css` und gilt ohne Ausnahme. + +## Form + +- Radius **4 px** (`--radius`), innen 3 px. Nie mehr. Weiche Ecken erzählen „App-Store", + scharfe erzählen „Werkzeug". +- Schatten praktisch unsichtbar (`0 1px 2px` bei 5 % Deckung). Tiefe entsteht über + Fläche und Linie, nicht über Weichzeichner. +- **Glasoptik nirgends.** Einzige Ausnahme: der Hintergrund hinter einem Dialog, damit + klar ist, was gerade nicht anfassbar ist. + +## Layout + +- `--maxw: 1060px` für Textseiten. Arbeitsansichten dürfen breiter werden, wenn sie den + Platz auch nutzen. +- Karten gruppieren, sie dekorieren nicht. **Nie verschachtelt.** +- Umbruchpunkte, die tatsächlich benutzt werden: **1200** (Einstellungen zweispaltig), + **1080** (eine Spalte, Vorschau nach vorn), **768** (Kopfleiste → Tab-Leiste unten), + **720** (Bildkarten werden zu Zeilen), **640/560** (Dialoge werden zu Blättern von unten). +- Geprüft wird bei **390 / 820 / 1440 / 1680 px**. Kein waagerechtes Scrollen, nirgends. + +## Bewegung + +Sparsam. `transition` auf Farbe und Hintergrund (0.15 s), `transform: scale(0.97)` beim +Drücken (0.06 s). Kein Federn, kein Nachwippen. `prefers-reduced-motion` schaltet alles ab. + +## Muster + +- **Dialog**: mittig am Rechner, **Blatt von unten** am Handy. Fokus wird eingefangen, + Hintergrund festgehalten, Escape schließt. +- **Zustandsmarke**: Mono, 9–10 px, Großbuchstaben, gerundete Pille, getönter Grund. + Für „aktiv / abgeschaltet / abgelaufen / geschützt". +- **Leerer Zustand**: Passermarke, eine Überschrift, ein Satz, der sagt, was zu tun ist, + und ein Weg dorthin. Nie nur „Keine Einträge". +- **Gefahr**: Rot **nur** in der Schrift, nie als gefüllter Knopf. Ein rotes Rechteck lädt + zum Draufklicken ein; genau das will man beim Löschen nicht. + +## Was hier nicht vorkommt + +Farbverläufe. Farbige Seitenstreifen an Karten. Glasoptik als Deko. Verlaufstext. +Illustrationen. Emoji in der Oberfläche. Gleich große Icon-Karten in Reihe. +Em-Gedankenstriche in der Oberflächensprache. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..e5f1c73 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,75 @@ +# Klarbild — Produktkontext + +> Zusammengetragen aus dem, was da ist: `README.md`, `src/styles/tokens.css`, dem Wiki +> (Sammlung „Klarbild") und dem Verhalten der Anwendung. Keine Wunschbeschreibung. + +**register:** product + +## Was es ist + +Eine selbst gehostete Bildwerkstatt für einen Ein-Personen-Digitalbetrieb mit Familie. +Zwei Hälften, die dieselbe Oberfläche teilen: + +1. **Mit KI** — Screenshots und Fundstücke bereinigen, freistellen, aufs Format bringen, + kombinieren, neu erzeugen. Läuft über OpenRouter, kostet Geld, dauert. +2. **Ohne KI** — Drucken: Bilder millimetergenau aufs physische Maß bringen und mehrere + davon in 100-%-Größe mit Schnittmarken auf einen Bogen setzen. Reine Geometrie, + kein Modell, keine Kosten, kein Warten. + +Fertige Bilder gehen an Picdrop (Kundenauslieferung), aufs NAS (zweite Sicherung) und +seit 08/2026 über **Teilen-Links** direkt an Menschen ohne Konto. + +## Wer es benutzt + +- **Till** — Betreiber, Admin, baut es selbst. Arbeitet am Rechner und am Handy, oft + zwischendurch. Erwartet, dass Maße stimmen und Dateien nicht verschwinden. +- **Lea** — nutzt es, ohne es warten zu wollen. Sagt Dinge wie „drucke das bitte in + zwanzig mal dreißig". Für sie muss der Weg vom Bild zum Ergebnis kurz und + selbsterklärend sein. +- **Empfänger von Teilen-Links** — Großeltern, Kunden. Bekommen einen Link per WhatsApp + oder Mail, haben **kein Konto**, sind oft am Handy, und sollen ohne Erklärung + verstehen, was zu tun ist. Können in Bezug auf Technik nichts vorausgesetzt bekommen. + +Auf vielen Bildern sind **Kinder**. Das ist keine Randnotiz, sondern der Grund, warum +Voreinstellungen zur Vorsicht neigen. + +## Ton + +Deutsch, per Du, sachlich und knapp. Erklärt **warum**, nicht nur was. Keine Ausrufezeichen, +keine Jubelsprache, keine Emoji in der Oberfläche (Telegram ausgenommen, dort tragen sie +Funktion). Fehlermeldungen sagen, was zu tun ist, statt was kaputt ist. + +Fachbegriffe werden benutzt, wo sie stimmen (Beschnittzugabe, Eckmarken, dpi) — und beim +ersten Auftreten in einem Halbsatz erklärt. Das Werkzeug spricht mit jemandem, der etwas +kann, aber nicht alles wissen muss. + +## Anti-Referenzen + +- **SaaS-Startseiten-Ästhetik.** Keine Farbverläufe, keine schwebenden Karten mit Schatten, + keine „Hero-Kennzahl" mit großer Zahl und kleinem Label, keine Kachelraster aus + Icon + Überschrift + drei Zeilen Text. +- **Adobe-Dichte.** Keine Werkzeugpaletten, keine verschachtelten Reiter, keine + Einstellung, die man nur mit Handbuch findet. +- **Verspieltes.** Keine abgerundeten Riesenradien, keine Illustrationen mit lachenden + Menschen, keine Konfetti-Animation nach dem Speichern. +- **Alles-ist-eine-Karte.** Karten nur, wo sie wirklich etwas gruppieren. Nie verschachtelt. + +## Grundsätze + +1. **Maße sind keine Meinung.** Wo etwas physisch wird, wird gerechnet und nachgemessen, + nicht geschätzt. Millimeter ist die führende Einheit; Pixel entstehen erst beim Rendern. +2. **Alle Wege können alles.** Web, Telegram und MCP sind gleichberechtigt. Eine neue + Funktion, die nur im Web geht, ist nicht fertig. +3. **Voreinstellungen neigen zur Vorsicht.** Teilen-Links laufen ab. Private Sitzungen + hinterlassen nichts. Beim Aufräumen fremder Dateien wird angehalten und gefragt. +4. **Sag, was passiert, bevor es passiert.** „Überstehen (−3 mm)" statt „geht nicht". + Probelauf vor der Umbenennung. Vorschau, die zeigt, was gedruckt wird. +5. **Fehler gehören ins Log, nicht ins Gesicht des Nutzers** — aber sie gehören + *irgendwohin*. Nichts still verschlucken. +6. **Es läuft auf einem Server.** Ein Node-Prozess, begrenzter Speicher. Teure Rechnung + braucht eine Obergrenze, bevor sie beginnt. + +## Register-Begründung + +Die Oberfläche **dient** der Arbeit, sie ist nicht das Produkt. Ausnahme: `/vorstellung` +ist eine Marketingseite und läuft im Brand-Register. diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index 30d6b27..2a9021e 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -12,6 +12,7 @@ export default function AdminApp() { const [storage, setStorage] = useState(null); const [rename, setRename] = useState(null); // Probelauf/Ergebnis der Umbenennung const [mailAn, setMailAn] = useState(''); // Adresse für die Testnachricht + const [smtpPw, setSmtpPw] = useState(''); // neues Postfach-Passwort (nie vorbelegt) const [targets, setTargets] = useState([]); const [nt, setNt] = useState({ protocol: 'sftp' }); const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; @@ -86,12 +87,21 @@ export default function AdminApp() { private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw, nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port, nas_user: s.nas_user, nas_base_path: s.nas_base_path, + // E-Mail und Teilen — ohne diese Zeilen wurde die ganze Karte stillschweigend + // verworfen und die Oberfläche meldete trotzdem „Gespeichert." + smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure !== false, + smtp_user: s.smtp_user, smtp_from: s.smtp_from, + share_default_days: s.share_default_days, }; if (orKey.trim()) body.openrouter_key = orKey.trim(); if (pdPw.trim()) body.picdrop_password = pdPw.trim(); if (nasPw.trim()) body.nas_password = nasPw.trim(); - await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - setOrKey(''); setPdPw(''); setNasPw(''); notify('Gespeichert.'); load(); + if (smtpPw.trim()) body.smtp_password = smtpPw.trim(); + const r = await fetch('/api/admin/settings', { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); + if (r?.error) { notify(r.error); return; } + setOrKey(''); setPdPw(''); setNasPw(''); setSmtpPw(''); notify('Gespeichert.'); load(); }; const testPicdrop = async () => { notify('Teste …'); @@ -151,6 +161,7 @@ export default function AdminApp() { : `Alle ${r.geprueft} Namen sind bereits eindeutig.`); }; const renameApply = async () => { + if (!rename) { notify('Erst „Bibliothek prüfen" — dann siehst du, was sich ändern würde.'); return; } if (!confirm('Alle Bilder der Bibliothek auf eindeutige Namen bringen?\n\nDie Dateien selbst bleiben unberührt — sie liegen unter ihrer ID im Speicher. ' + 'Bereits bei Picdrop abgelegte Kopien behalten ihren alten Namen; dafür gibt es danach „Erneut ausliefern".')) return; notify('Benenne um …'); @@ -338,8 +349,9 @@ export default function AdminApp() {
field('smtp_user', e.target.value)} />
- field('smtp_password', e.target.value)} />
+ setSmtpPw(e.target.value)} />
field('smtp_from', e.target.value)} /> @@ -573,7 +585,10 @@ function AdminStyles() { .zwei{display:grid;grid-template-columns:1fr 1fr;gap:12px;} .feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);margin-bottom:6px;} .feld em{text-transform:none;letter-spacing:0;color:var(--accent);font-style:normal;} - .input{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);} + /* 16 px, nicht 14: Darunter zoomt iOS beim Fokus ins Feld hinein — die + Notbremse in tokens.css greift nur bis 767 px, der Admin wird aber auch + auf dem iPad benutzt. */ + .input{width:100%;background:var(--card);border:1px solid var(--line);border-radius:3px;padding:10px 11px;font-family:inherit;font-size:16px;color:var(--ink);} .fein{font-size:11.5px;color:var(--soft);line-height:1.5;} .knopf{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;} .knopf.warn{background:var(--err);} @@ -584,6 +599,13 @@ 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;} + /* 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){ + .zwei{grid-template-columns:1fr;} + .mini{padding:11px 13px;font-size:13px;} + .reihe{flex-wrap:wrap;} + } /* Vorher/Nachher der Umbenennung — lange Namen dürfen die Karte nicht sprengen. */ .namensliste{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px;max-height:220px;overflow:auto;} .namensliste li{font-family:var(--font-mono);font-size:10.5px;line-height:1.5;overflow-wrap:anywhere;} diff --git a/src/components/KontoApp.tsx b/src/components/KontoApp.tsx index 5df300c..48b7708 100644 --- a/src/components/KontoApp.tsx +++ b/src/components/KontoApp.tsx @@ -16,10 +16,17 @@ export default function KontoApp() { const [email, setEmail] = useState(''); const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null); const [laeuft, setLaeuft] = useState(false); + const [ladefehler, setLadefehler] = useState(false); - useEffect(() => { - fetch('/api/me').then((r) => r.json()).then((m) => { setMe(m); setEmail(m.email || ''); }).catch(() => {}); - }, []); + const holen = () => { + setLadefehler(false); + fetch('/api/me') + .then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); }) + .then((m) => { setMe(m); setEmail(m.email || ''); }) + // Ohne diesen Zweig stand hier für immer „Einen Moment …". + .catch(() => setLadefehler(true)); + }; + useEffect(holen, []); const sagen = (t: string, ok: boolean) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); }; @@ -39,6 +46,15 @@ export default function KontoApp() { return true; }; + if (ladefehler) return ( +
+
+

Dein Konto ließ sich gerade nicht laden — vielleicht war die + Verbindung kurz weg.

+ +
+
+ ); if (!me) return

Einen Moment …

; return ( diff --git a/src/components/ShareDialog.tsx b/src/components/ShareDialog.tsx index 6474557..a13627d 100644 --- a/src/components/ShareDialog.tsx +++ b/src/components/ShareDialog.tsx @@ -28,17 +28,22 @@ export default function ShareDialog( const [wort, setWort] = useState(''); const [link, setLink] = useState<{ slug: string; id: string } | null>(null); const [laeuft, setLaeuft] = useState(false); - const [msg, setMsg] = useState(null); + const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null); const [mailAn, setMailAn] = useState(''); const [mailText, setMailText] = useState(''); - const [mailBereit, setMailBereit] = useState(false); + const [mailBereit, setMailBereit] = useState(null); const [kopiert, setKopiert] = useState(false); const box = useRef(null); const url = link ? `${location.origin}/s/${link.slug}` : ''; useEffect(() => { - fetch('/api/me').then((r) => r.json()).then((m) => setMailBereit(!!m.mail_ready)).catch(() => {}); + fetch('/api/me').then((r) => r.json()).then((m) => { + setMailBereit(!!m.mail_ready); + // Voreinstellung aus dem Admin übernehmen — sonst verspricht die Einstellung + // dort etwas, das hier nicht ankommt. + if (typeof m.share_default_days === 'number') setTage(m.share_default_days); + }).catch(() => setMailBereit(null)); }, []); // Fokus einfangen und Hintergrund festhalten, solange der Dialog offen ist. @@ -69,7 +74,7 @@ export default function ShareDialog( }), }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); setLaeuft(false); - if (r.error) { setMsg(r.error); return; } + if (r.error) { setMsg({ t: r.error, ok: false }); return; } setLink({ slug: r.share.slug, id: r.share.id }); onFertig?.(); }; @@ -87,17 +92,19 @@ export default function ShareDialog( const waHref = `https://wa.me/?text=${encodeURIComponent(waText)}`; const mailen = async () => { - if (!mailAn.trim()) { setMsg('Bitte eine Empfängeradresse eintragen.'); return; } + if (!mailAn.trim()) { setMsg({ t: 'Bitte eine Empfängeradresse eintragen.', ok: false }); return; } setLaeuft(true); setMsg(null); const r = await fetch('/api/shares/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shareId: link!.id, to: mailAn, message: mailText }), }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); setLaeuft(false); - if (r.error) { setMsg(r.error); return; } + if (r.error) { setMsg({ t: r.error, ok: false }); return; } + // Teilweiser Erfolg ist Erfolg: Vorher färbte eine Textmustererkennung + // „2 verschickt, bei 1 hat es nicht geklappt" komplett rot. setMsg(r.fehler?.length - ? `${r.ok} verschickt, bei ${r.fehler.length} hat es nicht geklappt.` - : `Verschickt an ${r.ok === 1 ? 'die Adresse' : `${r.ok} Adressen`}.`); + ? { t: `${r.ok} verschickt, bei ${r.fehler.length} hat es nicht geklappt.`, ok: r.ok > 0 } + : { t: `Verschickt an ${r.ok === 1 ? 'die Adresse' : `${r.ok} Adressen`}.`, ok: true }); setMailAn(''); }; @@ -121,29 +128,40 @@ export default function ShareDialog( placeholder={mehrere ? 'z. B. Kita-Fotos August' : 'z. B. Phoenix im Garten'} maxLength={120} /> -