fix: design critique findings, and the settings card that saved nothing

The critique found a functional bug I had shipped an hour earlier: the whole
e-mail configuration was never sent. `saveSettings` builds its body from a fixed
field list, and none of the smtp_* fields or share_default_days were in it, and
the password input had no state at all. Filling in the card and pressing save
lost everything while the toast said "Gespeichert." That card is the
precondition for password reset, job notifications and mailing share links, so
it would have looked like the whole feature was broken. Now sent, with the
password held separately like the other secrets, and a failed PATCH no longer
reports success.

The public page told recipients how to bypass "view only": the hint about
long-pressing to save was printed regardless of allow_download, two lines under
a caption saying "Nur ansehen". The backend takes that setting seriously enough
to withhold the original even for cutouts; the page undid it. Now the two cases
have separate copy, worded for Android as well as iOS, and the hint sits above
the grid where it is actually read rather than under twenty tiles.

The share dialog had two blue buttons in step two, the exact mistake DESIGN.md
records as having happened once before, and asked five questions before a link
existed when the common case ("one picture for grandma") needs none of them.
One blue button now, and the rarely-used settings collapse behind a summary
that states what currently applies.

/konto was unreachable on a phone: the only way in was the username in the top
bar, which is hidden below 480px. It is a tab bar entry now, with short labels
so eight items still fit at 390px (49x48px each).

Network failures rendered as content: a failed load showed "nothing shared yet"
alongside an invitation to share something, and /konto and /admin hung on
"loading" forever. All three have an error state with a retry now.

Also: the public page nested a second <main> inside the app shell and inherited
padding for a tab bar anonymous visitors never see; admin inputs were 14px, so
iOS zoomed on focus at tablet width; the admin had no media queries at all, so
"Postausgangsserver" got ~156px on a phone; "Jetzt umbenennen" was clickable
without the dry run PRODUCT.md requires; share_default_days did not reach the
dialog; partial mail success was coloured as failure by a regex on the message
text; the passphrase sheet ignored the home indicator; the privacy line claimed
"encrypted" for what is a salted hash.

Added PRODUCT.md and DESIGN.md, written from the code and the wiki rather than
invented, so future design work has something to be measured against.

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 03:41:10 +00:00
1 parent 1d78535662
commit 14dae8e550
9 files changed
+345 -60

No files matched your search

+27 -5
View File
@@ -12,6 +12,7 @@ export default function AdminApp() {
const [storage, setStorage] = useState<any>(null);
const [rename, setRename] = useState<any>(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<any[]>([]);
const [nt, setNt] = useState<any>({ 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() {
<div className="feld"><label>Benutzername</label>
<input className="input" placeholder="mail@deine-domain.de" value={s.smtp_user ?? ''} onChange={(e) => field('smtp_user', e.target.value)} /></div>
<div className="feld"><label>Passwort {s.smtp_password_set ? <span className="fein">· gesetzt</span> : null}</label>
<input className="input" type="password" placeholder={s.smtp_password_set ? 'unverändert lassen' : 'Postfach-Passwort'}
onChange={(e) => field('smtp_password', e.target.value)} /></div>
<input className="input" type="password" value={smtpPw} autoComplete="new-password"
placeholder={s.smtp_password_set ? 'unverändert lassen' : 'Postfach-Passwort'}
onChange={(e) => setSmtpPw(e.target.value)} /></div>
</div>
<div className="feld"><label>Absender</label>
<input className="input" placeholder="Klarbild &lt;mail@deine-domain.de&gt;" value={s.smtp_from ?? ''} onChange={(e) => 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;}
+19 -3
View File
@@ -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 (
<div className="ko">
<section className="ko-karte"><div className="ko-inhalt">
<p className="ko-fein">Dein Konto ließ sich gerade nicht laden — vielleicht war die
Verbindung kurz weg.</p>
<button className="ko-btn stark" onClick={holen}>Noch einmal versuchen</button>
</div></section>
</div>
);
if (!me) return <p className="ko-fein">Einen Moment …</p>;
return (
+57 -28
View File
@@ -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<string | null>(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<boolean | null>(null);
const [kopiert, setKopiert] = useState(false);
const box = useRef<HTMLDivElement>(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} />
</label>
<label className="tl-feld"><span className="tl-kopfzeile">Nachricht auf der Seite <em>optional</em></span>
<textarea value={notiz} onChange={(e) => setNotiz(e.target.value)} rows={2}
placeholder="Steht über den Bildern" maxLength={500} />
</label>
<label className="tl-feld">Gültig
<select value={tage} onChange={(e) => setTage(Number(e.target.value))}>
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
</select>
</label>
<label className="tl-check">
<input type="checkbox" checked={download} onChange={(e) => setDownload(e.target.checked)} />
<span><b>Herunterladen erlauben</b><em>Ohne Haken kann man die Bilder nur ansehen.</em></span>
</label>
{/* Der häufige Fall ist „ein Bild an Oma" — der braucht nichts davon.
Zugeklappt, mit einer Zusammenfassung, die den aktuellen Stand nennt. */}
<details className="tl-mehr">
<summary>
<span>Mehr einstellen</span>
<em>{download ? 'Herunterladen erlaubt' : 'nur ansehen'}
{wort ? ' · mit Zugangswort' : ' · ohne Zugangswort'}</em>
</summary>
<div className="tl-mehr-inhalt">
<label className="tl-feld">Nachricht auf der Seite
<textarea value={notiz} onChange={(e) => setNotiz(e.target.value)} rows={2}
placeholder="Steht über den Bildern" maxLength={500} />
</label>
<label className="tl-feld"><span className="tl-kopfzeile">Zugangswort <em>optional</em></span>
<input type="text" value={wort} onChange={(e) => setWort(e.target.value)}
placeholder="Leer = jeder mit dem Link kommt rein" maxLength={100} />
<span className="tl-fein">Schick das Wort auf einem anderen Weg als den Link.</span>
</label>
<label className="tl-check">
<input type="checkbox" checked={download} onChange={(e) => setDownload(e.target.checked)} />
<span><b>Herunterladen erlauben</b><em>Ohne Haken kann man die Bilder nur ansehen.</em></span>
</label>
{msg && <p className="tl-msg err">{msg}</p>}
<label className="tl-feld">Zugangswort
<input type="text" value={wort} onChange={(e) => setWort(e.target.value)}
placeholder="Leer = jeder mit dem Link kommt rein" maxLength={100} />
<span className="tl-fein">Schick das Wort auf einem anderen Weg als den Link.</span>
</label>
</div>
</details>
{msg && <p className={`tl-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
<div className="tl-fuss">
<button className="tl-zweit" onClick={onClose}>Abbrechen</button>
<button className="tl-haupt" onClick={erzeugen} disabled={laeuft}>
@@ -180,7 +198,7 @@ export default function ShareDialog(
<div className="tl-trenn"><span>oder direkt von hier verschicken</span></div>
{mailBereit ? (
{mailBereit === null ? null : mailBereit ? (
<>
<label className="tl-feld">An
<input value={mailAn} onChange={(e) => setMailAn(e.target.value)} type="email"
@@ -191,7 +209,7 @@ export default function ShareDialog(
<textarea value={mailText} onChange={(e) => setMailText(e.target.value)} rows={2}
placeholder="Ein paar Zeilen an den Empfänger" maxLength={1000} />
</label>
<button className="tl-haupt breit" onClick={mailen} disabled={laeuft}>
<button className="tl-zweit breit" onClick={mailen} disabled={laeuft}>
{laeuft ? 'Wird verschickt …' : 'Per E-Mail schicken'}
</button>
</>
@@ -202,7 +220,7 @@ export default function ShareDialog(
</p>
)}
{msg && <p className={`tl-msg ${/nicht|Fehler|klappt/.test(msg) ? 'err' : 'ok'}`}>{msg}</p>}
{msg && <p className={`tl-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
<div className="tl-fuss">
<a className="tl-zweit" href="/teilen">Alle Links verwalten</a>
@@ -243,7 +261,7 @@ export default function ShareDialog(
font-size:12.5px;color:var(--ink);}
.tl-haupt{border:none;border-radius:var(--radius-sm);background:var(--accent);color:#fff;
padding:11px 16px;font-weight:600;font-size:.92rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
.tl-haupt.breit{width:100%;}
.tl-haupt.breit,.tl-zweit.breit{width:100%;}
.tl-haupt:disabled{background:var(--paper);color:var(--soft);border:1px solid var(--line);cursor:not-allowed;}
.tl-zweit{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
color:var(--ink);padding:11px 16px;font-size:.92rem;cursor:pointer;font-family:inherit;
@@ -255,6 +273,16 @@ export default function ShareDialog(
.tl-weg.wa{border-color:#25D366;}
.tl-weg-t{font-weight:600;font-size:.88rem;}
.tl-weg-f{font-size:.75rem;color:var(--soft);line-height:1.4;}
.tl-mehr{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--paper);}
.tl-mehr>summary{list-style:none;cursor:pointer;display:flex;align-items:baseline;
justify-content:space-between;gap:10px;padding:12px;font-size:.88rem;}
.tl-mehr>summary>span{white-space:nowrap;}
.tl-mehr>summary::-webkit-details-marker{display:none;}
.tl-mehr>summary::after{content:'▾';color:var(--soft);font-size:11px;transition:transform .15s;}
.tl-mehr[open]>summary::after{transform:rotate(180deg);}
.tl-mehr>summary em{font-style:normal;font-size:.76rem;color:var(--soft);
margin-left:auto;text-align:right;line-height:1.4;}
.tl-mehr-inhalt{display:grid;gap:12px;padding:0 12px 12px;}
.tl-trenn{display:flex;align-items:center;gap:10px;color:var(--soft);
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;}
.tl-trenn::before,.tl-trenn::after{content:'';flex:1;height:1px;background:var(--line);}
@@ -266,7 +294,8 @@ export default function ShareDialog(
.tl-fuss{display:flex;gap:8px;justify-content:flex-end;padding-top:4px;}
@media(max-width:560px){
.tl-hinter{padding:0;align-items:flex-end;}
.tl-box{width:100%;max-height:92vh;border-radius:var(--radius) var(--radius) 0 0;}
.tl-box{width:100%;max-height:92vh;border-radius:var(--radius) var(--radius) 0 0;
padding-bottom:env(safe-area-inset-bottom);}
.tl-wege{grid-template-columns:1fr;}
.tl-fuss{flex-direction:column-reverse;}
.tl-fuss>*{width:100%;}
+18 -3
View File
@@ -37,14 +37,22 @@ const zustand = (s: Share): Zustand =>
export default function SharesApp() {
const [shares, setShares] = useState<Share[]>([]);
const [laden, setLaden] = useState(true);
const [ladefehler, setLadefehler] = useState(false);
const [offen, setOffen] = useState<string | null>(null);
const [zugriffe, setZugriffe] = useState<Record<string, any[]>>({});
const [msg, setMsg] = useState<string | null>(null);
const [kopiert, setKopiert] = useState<string | null>(null);
const laden_ = useCallback(async () => {
const r = await fetch('/api/shares').then((x) => x.json()).catch(() => ({ shares: [] }));
setShares(r.shares || []); setLaden(false);
// Ein Netzfehler darf nicht als „nichts geteilt" erscheinen — sonst denkt
// man, die eigenen Links seien weg.
try {
const r = await fetch('/api/shares');
if (!r.ok) throw new Error(String(r.status));
const d = await r.json();
setShares(d.shares || []); setLadefehler(false);
} catch { setLadefehler(true); }
finally { setLaden(false); }
}, []);
useEffect(() => { laden_(); }, [laden_]);
@@ -89,7 +97,14 @@ export default function SharesApp() {
<div className="tw">
{msg && <div className="tw-toast" role="status">{msg}</div>}
{!shares.length ? (
{ladefehler ? (
<div className="tw-leer-box">
<span className="regmark" />
<h2>Die Liste ließ sich nicht laden</h2>
<p>Vielleicht war die Verbindung kurz weg. Deine Links sind davon nicht betroffen.</p>
<p><button className="tw-btn stark" onClick={() => { setLaden(true); laden_(); }}>Noch einmal versuchen</button></p>
</div>
) : !shares.length ? (
<div className="tw-leer-box">
<span className="regmark" />
<h2>Noch nichts geteilt</h2>