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:
9 files changed
+345
-60
No files matched your search
@@ -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.
|
||||||
+75
@@ -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.
|
||||||
@@ -12,6 +12,7 @@ export default function AdminApp() {
|
|||||||
const [storage, setStorage] = useState<any>(null);
|
const [storage, setStorage] = useState<any>(null);
|
||||||
const [rename, setRename] = useState<any>(null); // Probelauf/Ergebnis der Umbenennung
|
const [rename, setRename] = useState<any>(null); // Probelauf/Ergebnis der Umbenennung
|
||||||
const [mailAn, setMailAn] = useState(''); // Adresse für die Testnachricht
|
const [mailAn, setMailAn] = useState(''); // Adresse für die Testnachricht
|
||||||
|
const [smtpPw, setSmtpPw] = useState(''); // neues Postfach-Passwort (nie vorbelegt)
|
||||||
const [targets, setTargets] = useState<any[]>([]);
|
const [targets, setTargets] = useState<any[]>([]);
|
||||||
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
|
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
|
||||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
|
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,
|
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_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,
|
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 (orKey.trim()) body.openrouter_key = orKey.trim();
|
||||||
if (pdPw.trim()) body.picdrop_password = pdPw.trim();
|
if (pdPw.trim()) body.picdrop_password = pdPw.trim();
|
||||||
if (nasPw.trim()) body.nas_password = nasPw.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) });
|
if (smtpPw.trim()) body.smtp_password = smtpPw.trim();
|
||||||
setOrKey(''); setPdPw(''); setNasPw(''); notify('Gespeichert.'); load();
|
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 () => {
|
const testPicdrop = async () => {
|
||||||
notify('Teste …');
|
notify('Teste …');
|
||||||
@@ -151,6 +161,7 @@ export default function AdminApp() {
|
|||||||
: `Alle ${r.geprueft} Namen sind bereits eindeutig.`);
|
: `Alle ${r.geprueft} Namen sind bereits eindeutig.`);
|
||||||
};
|
};
|
||||||
const renameApply = async () => {
|
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. '
|
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;
|
+ 'Bereits bei Picdrop abgelegte Kopien behalten ihren alten Namen; dafür gibt es danach „Erneut ausliefern".')) return;
|
||||||
notify('Benenne um …');
|
notify('Benenne um …');
|
||||||
@@ -338,8 +349,9 @@ export default function AdminApp() {
|
|||||||
<div className="feld"><label>Benutzername</label>
|
<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>
|
<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>
|
<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'}
|
<input className="input" type="password" value={smtpPw} autoComplete="new-password"
|
||||||
onChange={(e) => field('smtp_password', e.target.value)} /></div>
|
placeholder={s.smtp_password_set ? 'unverändert lassen' : 'Postfach-Passwort'}
|
||||||
|
onChange={(e) => setSmtpPw(e.target.value)} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="feld"><label>Absender</label>
|
<div className="feld"><label>Absender</label>
|
||||||
<input className="input" placeholder="Klarbild <mail@deine-domain.de>" value={s.smtp_from ?? ''} onChange={(e) => field('smtp_from', e.target.value)} />
|
<input className="input" placeholder="Klarbild <mail@deine-domain.de>" 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;}
|
.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 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;}
|
.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;}
|
.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{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);}
|
.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 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);}
|
.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;}
|
.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. */
|
/* 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{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;}
|
.namensliste li{font-family:var(--font-mono);font-size:10.5px;line-height:1.5;overflow-wrap:anywhere;}
|
||||||
|
|||||||
@@ -16,10 +16,17 @@ export default function KontoApp() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
|
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
|
||||||
const [laeuft, setLaeuft] = useState(false);
|
const [laeuft, setLaeuft] = useState(false);
|
||||||
|
const [ladefehler, setLadefehler] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const holen = () => {
|
||||||
fetch('/api/me').then((r) => r.json()).then((m) => { setMe(m); setEmail(m.email || ''); }).catch(() => {});
|
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); };
|
const sagen = (t: string, ok: boolean) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); };
|
||||||
|
|
||||||
@@ -39,6 +46,15 @@ export default function KontoApp() {
|
|||||||
return true;
|
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>;
|
if (!me) return <p className="ko-fein">Einen Moment …</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,17 +28,22 @@ export default function ShareDialog(
|
|||||||
const [wort, setWort] = useState('');
|
const [wort, setWort] = useState('');
|
||||||
const [link, setLink] = useState<{ slug: string; id: string } | null>(null);
|
const [link, setLink] = useState<{ slug: string; id: string } | null>(null);
|
||||||
const [laeuft, setLaeuft] = useState(false);
|
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 [mailAn, setMailAn] = useState('');
|
||||||
const [mailText, setMailText] = useState('');
|
const [mailText, setMailText] = useState('');
|
||||||
const [mailBereit, setMailBereit] = useState(false);
|
const [mailBereit, setMailBereit] = useState<boolean | null>(null);
|
||||||
const [kopiert, setKopiert] = useState(false);
|
const [kopiert, setKopiert] = useState(false);
|
||||||
const box = useRef<HTMLDivElement>(null);
|
const box = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const url = link ? `${location.origin}/s/${link.slug}` : '';
|
const url = link ? `${location.origin}/s/${link.slug}` : '';
|
||||||
|
|
||||||
useEffect(() => {
|
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.
|
// 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.' }));
|
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
|
||||||
setLaeuft(false);
|
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 });
|
setLink({ slug: r.share.slug, id: r.share.id });
|
||||||
onFertig?.();
|
onFertig?.();
|
||||||
};
|
};
|
||||||
@@ -87,17 +92,19 @@ export default function ShareDialog(
|
|||||||
const waHref = `https://wa.me/?text=${encodeURIComponent(waText)}`;
|
const waHref = `https://wa.me/?text=${encodeURIComponent(waText)}`;
|
||||||
|
|
||||||
const mailen = async () => {
|
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);
|
setLaeuft(true); setMsg(null);
|
||||||
const r = await fetch('/api/shares/send', {
|
const r = await fetch('/api/shares/send', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ shareId: link!.id, to: mailAn, message: mailText }),
|
body: JSON.stringify({ shareId: link!.id, to: mailAn, message: mailText }),
|
||||||
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
|
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
|
||||||
setLaeuft(false);
|
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
|
setMsg(r.fehler?.length
|
||||||
? `${r.ok} verschickt, bei ${r.fehler.length} hat es nicht geklappt.`
|
? { t: `${r.ok} verschickt, bei ${r.fehler.length} hat es nicht geklappt.`, ok: r.ok > 0 }
|
||||||
: `Verschickt an ${r.ok === 1 ? 'die Adresse' : `${r.ok} Adressen`}.`);
|
: { t: `Verschickt an ${r.ok === 1 ? 'die Adresse' : `${r.ok} Adressen`}.`, ok: true });
|
||||||
setMailAn('');
|
setMailAn('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,29 +128,40 @@ export default function ShareDialog(
|
|||||||
placeholder={mehrere ? 'z. B. Kita-Fotos August' : 'z. B. Phoenix im Garten'} maxLength={120} />
|
placeholder={mehrere ? 'z. B. Kita-Fotos August' : 'z. B. Phoenix im Garten'} maxLength={120} />
|
||||||
</label>
|
</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
|
<label className="tl-feld">Gültig
|
||||||
<select value={tage} onChange={(e) => setTage(Number(e.target.value))}>
|
<select value={tage} onChange={(e) => setTage(Number(e.target.value))}>
|
||||||
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
|
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="tl-check">
|
{/* Der häufige Fall ist „ein Bild an Oma" — der braucht nichts davon.
|
||||||
<input type="checkbox" checked={download} onChange={(e) => setDownload(e.target.checked)} />
|
Zugeklappt, mit einer Zusammenfassung, die den aktuellen Stand nennt. */}
|
||||||
<span><b>Herunterladen erlauben</b><em>Ohne Haken kann man die Bilder nur ansehen.</em></span>
|
<details className="tl-mehr">
|
||||||
</label>
|
<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>
|
<label className="tl-check">
|
||||||
<input type="text" value={wort} onChange={(e) => setWort(e.target.value)}
|
<input type="checkbox" checked={download} onChange={(e) => setDownload(e.target.checked)} />
|
||||||
placeholder="Leer = jeder mit dem Link kommt rein" maxLength={100} />
|
<span><b>Herunterladen erlauben</b><em>Ohne Haken kann man die Bilder nur ansehen.</em></span>
|
||||||
<span className="tl-fein">Schick das Wort auf einem anderen Weg als den Link.</span>
|
</label>
|
||||||
</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">
|
<div className="tl-fuss">
|
||||||
<button className="tl-zweit" onClick={onClose}>Abbrechen</button>
|
<button className="tl-zweit" onClick={onClose}>Abbrechen</button>
|
||||||
<button className="tl-haupt" onClick={erzeugen} disabled={laeuft}>
|
<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>
|
<div className="tl-trenn"><span>oder direkt von hier verschicken</span></div>
|
||||||
|
|
||||||
{mailBereit ? (
|
{mailBereit === null ? null : mailBereit ? (
|
||||||
<>
|
<>
|
||||||
<label className="tl-feld">An
|
<label className="tl-feld">An
|
||||||
<input value={mailAn} onChange={(e) => setMailAn(e.target.value)} type="email"
|
<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}
|
<textarea value={mailText} onChange={(e) => setMailText(e.target.value)} rows={2}
|
||||||
placeholder="Ein paar Zeilen an den Empfänger" maxLength={1000} />
|
placeholder="Ein paar Zeilen an den Empfänger" maxLength={1000} />
|
||||||
</label>
|
</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'}
|
{laeuft ? 'Wird verschickt …' : 'Per E-Mail schicken'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -202,7 +220,7 @@ export default function ShareDialog(
|
|||||||
</p>
|
</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">
|
<div className="tl-fuss">
|
||||||
<a className="tl-zweit" href="/teilen">Alle Links verwalten</a>
|
<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);}
|
font-size:12.5px;color:var(--ink);}
|
||||||
.tl-haupt{border:none;border-radius:var(--radius-sm);background:var(--accent);color:#fff;
|
.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;}
|
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-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);
|
.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;
|
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.wa{border-color:#25D366;}
|
||||||
.tl-weg-t{font-weight:600;font-size:.88rem;}
|
.tl-weg-t{font-weight:600;font-size:.88rem;}
|
||||||
.tl-weg-f{font-size:.75rem;color:var(--soft);line-height:1.4;}
|
.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);
|
.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;}
|
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);}
|
.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;}
|
.tl-fuss{display:flex;gap:8px;justify-content:flex-end;padding-top:4px;}
|
||||||
@media(max-width:560px){
|
@media(max-width:560px){
|
||||||
.tl-hinter{padding:0;align-items:flex-end;}
|
.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-wege{grid-template-columns:1fr;}
|
||||||
.tl-fuss{flex-direction:column-reverse;}
|
.tl-fuss{flex-direction:column-reverse;}
|
||||||
.tl-fuss>*{width:100%;}
|
.tl-fuss>*{width:100%;}
|
||||||
|
|||||||
@@ -37,14 +37,22 @@ const zustand = (s: Share): Zustand =>
|
|||||||
export default function SharesApp() {
|
export default function SharesApp() {
|
||||||
const [shares, setShares] = useState<Share[]>([]);
|
const [shares, setShares] = useState<Share[]>([]);
|
||||||
const [laden, setLaden] = useState(true);
|
const [laden, setLaden] = useState(true);
|
||||||
|
const [ladefehler, setLadefehler] = useState(false);
|
||||||
const [offen, setOffen] = useState<string | null>(null);
|
const [offen, setOffen] = useState<string | null>(null);
|
||||||
const [zugriffe, setZugriffe] = useState<Record<string, any[]>>({});
|
const [zugriffe, setZugriffe] = useState<Record<string, any[]>>({});
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
const [kopiert, setKopiert] = useState<string | null>(null);
|
const [kopiert, setKopiert] = useState<string | null>(null);
|
||||||
|
|
||||||
const laden_ = useCallback(async () => {
|
const laden_ = useCallback(async () => {
|
||||||
const r = await fetch('/api/shares').then((x) => x.json()).catch(() => ({ shares: [] }));
|
// Ein Netzfehler darf nicht als „nichts geteilt" erscheinen — sonst denkt
|
||||||
setShares(r.shares || []); setLaden(false);
|
// 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_]);
|
useEffect(() => { laden_(); }, [laden_]);
|
||||||
|
|
||||||
@@ -89,7 +97,14 @@ export default function SharesApp() {
|
|||||||
<div className="tw">
|
<div className="tw">
|
||||||
{msg && <div className="tw-toast" role="status">{msg}</div>}
|
{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">
|
<div className="tw-leer-box">
|
||||||
<span className="regmark" />
|
<span className="regmark" />
|
||||||
<h2>Noch nichts geteilt</h2>
|
<h2>Noch nichts geteilt</h2>
|
||||||
|
|||||||
+25
-11
@@ -11,14 +11,17 @@ const user = Astro.locals.user;
|
|||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const active = (href: string) => href === '/' ? path === '/' : path.startsWith(href);
|
const active = (href: string) => href === '/' ? path === '/' : path.startsWith(href);
|
||||||
|
|
||||||
|
// `kurz` ist die Beschriftung in der unteren Leiste am Handy: Acht Einträge sind
|
||||||
|
// auf 390 px je rund 49 px breit — „Warteschlange" passt dort nicht.
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: '/', label: 'Studio', icon: 'studio' },
|
{ href: '/', label: 'Studio', kurz: 'Studio', icon: 'studio' },
|
||||||
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
|
{ href: '/bibliothek', label: 'Bibliothek', kurz: 'Bilder', icon: 'library' },
|
||||||
{ href: '/drucken', label: 'Drucken', icon: 'print' },
|
{ href: '/drucken', label: 'Drucken', kurz: 'Drucken', icon: 'print' },
|
||||||
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
|
{ href: '/warteschlange', label: 'Warteschlange', kurz: 'Läuft', icon: 'queue' },
|
||||||
{ href: '/teilen', label: 'Geteilt', icon: 'share' },
|
{ href: '/teilen', label: 'Geteilte Links', kurz: 'Geteilt', icon: 'share' },
|
||||||
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
|
{ href: '/anleitung', label: 'Hilfe', kurz: 'Hilfe', icon: 'help' },
|
||||||
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
|
{ href: '/konto', label: 'Mein Konto', kurz: 'Konto', icon: 'konto' },
|
||||||
|
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', kurz: 'Admin', icon: 'admin' }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const S = 'fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"';
|
const S = 'fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"';
|
||||||
@@ -29,6 +32,7 @@ const ICONS: Record<string, string> = {
|
|||||||
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
|
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
|
||||||
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
|
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
|
||||||
share: `<svg viewBox="0 0 24 24" ${S}><circle cx="18" cy="5.5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="18.5" r="2.6"/><path d="M8.4 10.8 15.6 6.9M8.4 13.2l7.2 3.9"/></svg>`,
|
share: `<svg viewBox="0 0 24 24" ${S}><circle cx="18" cy="5.5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="18.5" r="2.6"/><path d="M8.4 10.8 15.6 6.9M8.4 13.2l7.2 3.9"/></svg>`,
|
||||||
|
konto: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="8.5" r="3.6"/><path d="M4.8 20c.7-3.6 3.7-5.6 7.2-5.6s6.5 2 7.2 5.6"/></svg>`,
|
||||||
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
|
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
@@ -50,20 +54,24 @@ const ICONS: Record<string, string> = {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<form method="post" action="/api/auth/logout" class="logout">
|
<form method="post" action="/api/auth/logout" class="logout">
|
||||||
<a class="uname" href="/konto" title="Mein Konto">{user.name}</a>
|
<span class="uname">{user.name}</span>
|
||||||
<button type="submit" aria-label="Abmelden">Abmelden</button>
|
<button type="submit" aria-label="Abmelden">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main class="wrap"><slot /></main>
|
{/* Ohne Anmeldung gibt es weder Kopf- noch Tab-Leiste — dann braucht es auch
|
||||||
|
keinen Rahmen mit Platz dafür. Die öffentlichen Seiten (/s/…, /login,
|
||||||
|
/vorstellung, /passwort) bringen ihr eigenes Layout mit; ein zweites
|
||||||
|
<main> darum wäre außerdem für Screenreader mehrdeutig. */}
|
||||||
|
{user ? <main class="wrap"><slot /></main> : <slot />}
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<nav class="tabbar" aria-label="Hauptnavigation">
|
<nav class="tabbar" aria-label="Hauptnavigation">
|
||||||
{nav.map((n) => (
|
{nav.map((n) => (
|
||||||
<a href={n.href} class:list={['tab', { on: active(n.href) }]}>
|
<a href={n.href} class:list={['tab', { on: active(n.href) }]}>
|
||||||
<span class="ticon" set:html={ICONS[n.icon]} />
|
<span class="ticon" set:html={ICONS[n.icon]} />
|
||||||
<span class="tlabel">{n.label}</span>
|
<span class="tlabel">{n.kurz}</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -128,7 +136,13 @@ const ICONS: Record<string, string> = {
|
|||||||
.tab.on { color: var(--accent); }
|
.tab.on { color: var(--accent); }
|
||||||
.tab .ticon { width: 22px; height: 22px; display: grid; place-items: center; }
|
.tab .ticon { width: 22px; height: 22px; display: grid; place-items: center; }
|
||||||
.tab .ticon svg { width: 22px; height: 22px; }
|
.tab .ticon svg { width: 22px; height: 22px; }
|
||||||
.tlabel { line-height: 1; }
|
.tlabel { line-height: 1; white-space: nowrap; }
|
||||||
|
/* Bei acht Einträgen wird es eng — Beschriftung und Symbol etwas kleiner,
|
||||||
|
die Tippfläche bleibt über 44 px hoch. */
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.tab { padding: 8px 2px 7px; font-size: 9.5px; }
|
||||||
|
.tab .ticon, .tab .ticon svg { width: 20px; height: 20px; }
|
||||||
|
}
|
||||||
@media (min-width: 768px) { .tabbar { display: none; } }
|
@media (min-width: 768px) { .tabbar { display: none; } }
|
||||||
|
|
||||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,8 @@ const json = (b: unknown, s = 200) =>
|
|||||||
|
|
||||||
export const GET: APIRoute = async ({ locals }) => {
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
const s = await one<{ private_allowed: boolean }>('SELECT private_allowed FROM settings WHERE id=1');
|
const s = await one<{ private_allowed: boolean; share_default_days: number }>(
|
||||||
|
'SELECT private_allowed, share_default_days FROM settings WHERE id=1');
|
||||||
const u = await one<{ private_forced: boolean; email: string | null; notify_jobs: boolean; display_name: string | null; username: string }>(
|
const u = await one<{ private_forced: boolean; email: string | null; notify_jobs: boolean; display_name: string | null; username: string }>(
|
||||||
'SELECT private_forced, email, notify_jobs, display_name, username FROM users WHERE id=$1', [locals.user.uid]);
|
'SELECT private_forced, email, notify_jobs, display_name, username FROM users WHERE id=$1', [locals.user.uid]);
|
||||||
return json({
|
return json({
|
||||||
@@ -21,6 +22,7 @@ export const GET: APIRoute = async ({ locals }) => {
|
|||||||
email: u?.email ?? null,
|
email: u?.email ?? null,
|
||||||
notify_jobs: !!u?.notify_jobs,
|
notify_jobs: !!u?.notify_jobs,
|
||||||
mail_ready: await mailReady(),
|
mail_ready: await mailReady(),
|
||||||
|
share_default_days: s?.share_default_days ?? 30,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const meldung: Record<string, { h: string; t: string }> = {
|
|||||||
{status === 'ok' && share ? (
|
{status === 'ok' && share ? (
|
||||||
<>
|
<>
|
||||||
<header class="kopf">
|
<header class="kopf">
|
||||||
|
<p class="marke"><span class="regmark"></span> geteilt über Klarbild</p>
|
||||||
<h1>{titel}</h1>
|
<h1>{titel}</h1>
|
||||||
{share.note && <p class="notiz">{share.note}</p>}
|
{share.note && <p class="notiz">{share.note}</p>}
|
||||||
<p class="mono-label">
|
<p class="mono-label">
|
||||||
@@ -75,8 +76,21 @@ const meldung: Record<string, { h: string; t: string }> = {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{share.allow_download ? (
|
||||||
|
<p class="hinweis">
|
||||||
|
<b>Herunterladen</b> speichert die Datei in voller Auflösung.
|
||||||
|
Am Handy geht es auch so: lange auf ein Bild drücken und speichern.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p class="hinweis">
|
||||||
|
Diese Bilder sind zum Ansehen gedacht. Wenn du eine Datei brauchst, frag bei
|
||||||
|
der Person nach, die dir den Link geschickt hat.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{bilder.length === 0 ? (
|
{bilder.length === 0 ? (
|
||||||
<p class="leer">Zu diesem Link gibt es gerade keine Bilder.</p>
|
<p class="leer">Hier ist gerade nichts zu sehen. Vielleicht wurden die Bilder inzwischen
|
||||||
|
entfernt — frag am besten noch einmal bei der Person nach, die dir den Link geschickt hat.</p>
|
||||||
) : (
|
) : (
|
||||||
<div class={bilder.length === 1 ? 'raster einzeln' : 'raster'}>
|
<div class={bilder.length === 1 ? 'raster einzeln' : 'raster'}>
|
||||||
{bilder.map((b) => (
|
{bilder.map((b) => (
|
||||||
@@ -99,14 +113,10 @@ const meldung: Record<string, { h: string; t: string }> = {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p class="hinweis">
|
|
||||||
Am Handy: Bild antippen, dann lange darauf drücken und „Zu Fotos hinzufügen" wählen.
|
|
||||||
{share.allow_download && <> Über <b>Herunterladen</b> kommt die Datei in voller Auflösung.</>}
|
|
||||||
</p>
|
|
||||||
<p class="datenschutz">
|
<p class="datenschutz">
|
||||||
Wer dir diesen Link geschickt hat, sieht, <b>wann</b> und mit welcher Art Gerät er geöffnet
|
Wer dir diesen Link geschickt hat, sieht, <b>wann</b> und mit welcher Art Gerät er geöffnet
|
||||||
wurde — nicht, <b>wer</b> das war. Deine Adresse wird nur verschlüsselt abgelegt und nach
|
wurde. Nicht, <b>wer</b> das war: Von deiner Internet-Adresse wird nur ein unkenntlicher
|
||||||
90 Tagen gelöscht.
|
Zahlenwert gespeichert, und der wird nach 90 Tagen gelöscht.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : status === 'passwort' ? (
|
) : status === 'passwort' ? (
|
||||||
@@ -132,6 +142,9 @@ const meldung: Record<string, { h: string; t: string }> = {
|
|||||||
<style>
|
<style>
|
||||||
.teilen { max-width: 1000px; margin: 0 auto; padding: 40px 20px 72px; }
|
.teilen { max-width: 1000px; margin: 0 auto; padding: 40px 20px 72px; }
|
||||||
.kopf { margin-bottom: 26px; }
|
.kopf { margin-bottom: 26px; }
|
||||||
|
.marke { display: flex; align-items: center; gap: 8px; margin: 0 0 10px;
|
||||||
|
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: var(--soft); }
|
||||||
.kopf h1 { font-size: clamp(1.5rem, 4vw, 2rem); margin: 0 0 8px; letter-spacing: -0.02em; }
|
.kopf h1 { font-size: clamp(1.5rem, 4vw, 2rem); margin: 0 0 8px; letter-spacing: -0.02em; }
|
||||||
.notiz { margin: 0 0 10px; color: var(--ink-soft); max-width: 60ch; line-height: 1.6; }
|
.notiz { margin: 0 0 10px; color: var(--ink-soft); max-width: 60ch; line-height: 1.6; }
|
||||||
|
|
||||||
@@ -146,9 +159,12 @@ const meldung: Record<string, { h: string; t: string }> = {
|
|||||||
padding: 8px 14px; border-radius: 3px; white-space: nowrap; }
|
padding: 8px 14px; border-radius: 3px; white-space: nowrap; }
|
||||||
.fein { font-size: 12px; color: var(--soft); }
|
.fein { font-size: 12px; color: var(--soft); }
|
||||||
.leer { color: var(--soft); }
|
.leer { color: var(--soft); }
|
||||||
.hinweis { margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--line);
|
/* Steht über den Bildern: Unter zwanzig Kacheln würde ihn niemand lesen. */
|
||||||
|
.hinweis { margin: 0 0 20px; padding: 11px 13px; border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm); background: var(--card);
|
||||||
font-size: 13px; color: var(--ink-soft); line-height: 1.6; max-width: 62ch; }
|
font-size: 13px; color: var(--ink-soft); line-height: 1.6; max-width: 62ch; }
|
||||||
.datenschutz { margin: 10px 0 0; font-size: 12px; color: var(--soft); line-height: 1.6; max-width: 62ch; }
|
.datenschutz { margin: 30px 0 0; padding-top: 16px; border-top: 1px solid var(--line);
|
||||||
|
font-size: 12px; color: var(--soft); line-height: 1.6; max-width: 62ch; }
|
||||||
|
|
||||||
.tor { max-width: 460px; margin: 8vh auto 0; text-align: center; }
|
.tor { max-width: 460px; margin: 8vh auto 0; text-align: center; }
|
||||||
.tor h1 { font-size: 1.4rem; margin: 0 0 12px; }
|
.tor h1 { font-size: 1.4rem; margin: 0 0 12px; }
|
||||||
|
|||||||
Reference in new issue
Block a user