Files
klarbild/src/components/SharesApp.tsx
T
tillandClaude Opus 5 14dae8e550 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
2026-09-01 03:41:10 +00:00

254 lines
14 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
/**
* Verwaltung der Teilen-Links: sehen, wie oft ein Link aufgerufen wurde,
* ihn ändern, abschalten oder löschen.
*
* Der Zähler ist bewusst schlicht: Aufrufe, Downloads und die Zahl der
* unterschiedlichen Besucher. Namen oder Adressen stehen nirgends: Gespeichert
* wird ein Hash der IP, gesalzen mit dem Server-Geheimnis **und** der Kennung
* des Links. Damit lässt sich derselbe Besucher innerhalb eines Links
* wiedererkennen, aber nicht über mehrere Links hinweg verfolgen.
* Ehrlich benannt ist das Pseudonymisierung, nicht Anonymisierung — wer den
* Server-Schlüssel hat, könnte den IPv4-Raum durchrechnen. Deshalb wird auch
* nur 90 Tage aufgehoben.
*/
interface Share {
id: string; slug: string; kind: string; title: string | null; note: string | null;
expires_at: string | null; revoked_at: string | null; allow_download: boolean;
password_hash: string | null; views: number; downloads: number;
last_seen_at: string | null; created_at: string; anzahl: number; besucher: number;
}
const TAGE = [
{ v: 7, t: '7 Tage ab jetzt' }, { v: 30, t: '30 Tage ab jetzt' },
{ v: 90, t: '3 Monate ab jetzt' }, { v: 365, t: '1 Jahr ab jetzt' }, { v: 0, t: 'unbegrenzt' },
];
const datum = (s: string | null) => s ? new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '';
const zeit = (s: string | null) => s ? new Date(s).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
type Zustand = 'aktiv' | 'abgelaufen' | 'aus';
const zustand = (s: Share): Zustand =>
s.revoked_at ? 'aus'
: s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? 'abgelaufen'
: 'aktiv';
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 () => {
// 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_]);
const sagen = (t: string) => { setMsg(t); setTimeout(() => setMsg(null), 4000); };
const patch = async (id: string, body: unknown) => {
const r = await fetch(`/api/shares/${id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
// `{ok:false}` heißt: Der Link gehört jemand anderem oder war schon in dem
// Zustand. Ohne Rückmeldung wirkt der Knopf kaputt.
if (r.error) sagen(r.error);
else if (r.ok === false) { sagen('Nichts zu ändern — vielleicht schon geschehen?'); await laden_(); }
else await laden_();
};
const loeschen = async (s: Share) => {
if (!confirm(`Den Link „${s.title || s.slug}" endgültig löschen?\n\nEr lässt sich danach nicht mehr öffnen und die Zugriffszahlen sind weg. `
+ `Nur abschalten reicht meistens.`)) return;
const r = await fetch(`/api/shares/${s.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({}));
if (r.ok) { sagen('Link gelöscht.'); await laden_(); } else sagen(r.error || 'Hat nicht geklappt.');
};
const kopieren = async (s: Share) => {
const url = `${location.origin}/s/${s.slug}`;
try { await navigator.clipboard.writeText(url); } catch { prompt('Link kopieren:', url); }
setKopiert(s.id); setTimeout(() => setKopiert(null), 2000);
};
const details = async (s: Share) => {
if (offen === s.id) { setOffen(null); return; }
setOffen(s.id);
// Bei jedem Aufklappen neu laden — sonst zeigt die Liste beim zweiten Mal
// einen alten Stand, ohne dass man es merkt.
const r = await fetch(`/api/shares/${s.id}`).then((x) => x.json()).catch(() => ({ zugriffe: [] }));
setZugriffe((z) => ({ ...z, [s.id]: r.zugriffe || [] }));
};
if (laden) return <p className="tw-leer">Einen Moment …</p>;
return (
<div className="tw">
{msg && <div className="tw-toast" role="status">{msg}</div>}
{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>
<p>In der <a href="/bibliothek">Bibliothek</a> auf <b>Teilen</b> tippen — dann entsteht hier
ein Eintrag, den du jederzeit wieder abschalten kannst.</p>
</div>
) : (
<div className="tw-liste">
{shares.map((s) => {
const z = zustand(s);
return (
<article key={s.id} className={`tw-karte ${z}`}>
<div className="tw-kopf">
<div className="tw-titel">
<h3>{s.title || (s.anzahl > 1 ? `${s.anzahl} Bilder` : 'Ein Bild')}</h3>
<span className={`tw-status ${z}`}>
{z === 'aktiv' ? 'aktiv' : z === 'aus' ? 'abgeschaltet' : 'abgelaufen'}
</span>
{s.password_hash && <span className="tw-schutz" title="Mit Zugangswort geschützt">geschützt</span>}
</div>
<div className="tw-zahlen">
<span><b>{s.views}</b> Aufrufe</span>
<span><b>{s.downloads}</b> Downloads</span>
<span><b>{s.besucher}</b> {s.besucher === 1 ? 'Besucher' : 'Besuchende'}</span>
</div>
</div>
<div className="tw-meta">
{s.anzahl} {s.anzahl === 1 ? 'Bild' : 'Bilder'} ·
{' '}{s.expires_at ? `gültig bis ${datum(s.expires_at)}` : 'ohne Ablauf'} ·
{' '}angelegt {datum(s.created_at)}
{s.last_seen_at && <> · zuletzt gesehen {zeit(s.last_seen_at)}</>}
{!s.allow_download && <> · nur ansehen</>}
</div>
<div className="tw-zeile">
<input className="tw-link" readOnly value={`${location.origin}/s/${s.slug}`}
onFocus={(e) => e.currentTarget.select()} aria-label="Teilen-Link" />
<button className="tw-btn" onClick={() => kopieren(s)}>{kopiert === s.id ? 'Kopiert ✓' : 'Kopieren'}</button>
<a className="tw-btn" href={`/s/${s.slug}`} target="_blank" rel="noopener">Ansehen</a>
</div>
<div className="tw-aktionen">
{z === 'aus'
? <button className="tw-btn stark" onClick={() => patch(s.id, { action: 'unrevoke' })}>Wieder freigeben</button>
: <button className="tw-btn" onClick={() => patch(s.id, { action: 'revoke' })}>Abschalten</button>}
<select className="tw-btn" value="" onChange={(e) => e.target.value !== '' && patch(s.id, { days: Number(e.target.value) })}
aria-label="Gültigkeit ändern">
<option value="" disabled>Gültigkeit ändern …</option>
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
</select>
<button className="tw-btn" onClick={() => patch(s.id, { allowDownload: !s.allow_download })}>
{s.allow_download ? 'Download sperren' : 'Download erlauben'}
</button>
<button className="tw-btn" onClick={() => {
const w = prompt(s.password_hash
? 'Neues Zugangswort (leer lassen = Schutz entfernen):'
: 'Zugangswort setzen:');
if (w !== null) patch(s.id, { password: w });
}}>{s.password_hash ? 'Wort ändern' : 'Wort setzen'}</button>
<button className="tw-btn" onClick={() => details(s)}>{offen === s.id ? 'Zugriffe zu' : 'Zugriffe'}</button>
<button className="tw-btn gefahr" onClick={() => loeschen(s)}>Löschen</button>
</div>
{offen === s.id && (
<div className="tw-zugriffe">
{!zugriffe[s.id]?.length ? <p className="tw-fein">Noch keine Zugriffe.</p> : (
<ul>
{zugriffe[s.id].slice(0, 30).map((v, i) => (
<li key={i}>
<span className="tw-art">{v.kind === 'download' ? 'Download' : 'Aufruf'}</span>
<span className="tw-wann">{zeit(v.at)}</span>
<span className="tw-ua">{(v.user_agent || '').slice(0, 60)}</span>
</li>
))}
</ul>
)}
<p className="tw-fein">Festgehalten wird, <b>wann</b> und mit welcher Art Gerät geöffnet
wurde — nicht <b>wer</b>. Von der IP-Adresse wird nur ein verschlüsselter Fingerabdruck
gespeichert, der außerdem an diesen einen Link gebunden ist; über mehrere Links hinweg
lässt sich damit niemand verfolgen. Nach 90 Tagen wird alles gelöscht.</p>
</div>
)}
</article>
);
})}
</div>
)}
<style>{`
.tw{display:grid;gap:14px;}
.tw-leer{color:var(--soft);}
.tw-leer-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:44px 24px;text-align:center;}
.tw-leer-box h2{margin:14px 0 8px;font-size:1.15rem;}
.tw-leer-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
.tw-toast{position:sticky;top:70px;z-index:5;background:var(--ink);color:var(--card);
padding:10px 14px;border-radius:var(--radius-sm);font-size:.9rem;}
.tw-liste{display:grid;gap:12px;}
.tw-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:16px;display:grid;gap:11px;}
.tw-karte.aus,.tw-karte.abgelaufen{background:var(--paper);}
.tw-kopf{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-start;justify-content:space-between;}
.tw-titel{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-width:0;}
.tw-titel h3{margin:0;font-size:1rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
.tw-status{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
padding:3px 8px;border-radius:20px;}
.tw-status.aktiv{background:oklch(60% .15 150 / .14);color:oklch(42% .13 150);}
.tw-status.aus{background:oklch(55% .19 25 / .12);color:var(--err);}
.tw-status.abgelaufen{background:var(--paper);color:var(--soft);border:1px solid var(--line);}
.tw-schutz{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
padding:3px 8px;border-radius:20px;background:oklch(72% .15 75 / .16);color:oklch(48% .13 75);}
.tw-zahlen{display:flex;gap:14px;font-size:.78rem;color:var(--soft);white-space:nowrap;}
.tw-zahlen b{color:var(--ink);font-size:.95rem;}
.tw-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
.tw-zeile{display:flex;gap:7px;}
.tw-link{flex:1;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);
background:var(--paper);font-family:var(--font-mono);font-size:12px;color:var(--ink);}
.tw-aktionen{display:flex;flex-wrap:wrap;gap:6px;}
.tw-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
padding:8px 11px;font-size:.82rem;cursor:pointer;font-family:inherit;color:var(--ink);
text-decoration:none;display:inline-block;white-space:nowrap;}
.tw-btn:hover{border-color:var(--accent);}
.tw-btn.stark{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
.tw-btn.gefahr{color:var(--err);}
.tw-zugriffe{border-top:1px solid var(--line);padding-top:11px;}
.tw-zugriffe ul{list-style:none;margin:0 0 10px;padding:0;display:grid;gap:5px;max-height:260px;overflow:auto;}
.tw-zugriffe li{display:grid;grid-template-columns:82px 118px minmax(0,1fr);gap:8px;
font-size:.76rem;align-items:baseline;}
.tw-art{font-weight:600;}
.tw-wann{font-family:var(--font-mono);font-size:10.5px;color:var(--soft);}
.tw-ua{color:var(--soft);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tw-fein{font-size:.76rem;color:var(--soft);line-height:1.5;margin:0;}
@media(max-width:640px){
.tw-kopf{flex-direction:column;gap:8px;}
.tw-zahlen{gap:12px;}
.tw-zeile{flex-wrap:wrap;}
.tw-link{flex-basis:100%;}
.tw-btn{flex:1;text-align:center;padding:11px 10px;}
.tw-zugriffe li{grid-template-columns:74px 1fr;}
.tw-ua{display:none;}
}
`}</style>
</div>
);
}