Files
Flaro/web/app/src/dialog.ts
T
ScarriffleandClaude Opus 5 8e9da1355c Der Server schrieb deutsche Sätze an eine Stelle, die niemand liest
„Die Anfrage war so nicht zu gebrauchen" — das ist der Ersatzsatz des
Browsers für eine Ablehnung ohne Begründung. Dabei stand die Begründung
da: „Benutzername darf nicht leer sein, das Passwort braucht mindestens 8
Zeichen." Nur eben in `errors`, und beide Clients lesen `detail`.

Zwei Formen waren betroffen. `Results.ValidationProblem` legt den Satz
unter `errors` ab, `Results.BadRequest(new { fehler = … })` unter
`fehler` — in beiden Fällen kommt er nirgends an. Zusammen 36 Stellen,
darunter jede Zeile, die ich gestern für die Meldungen geschrieben habe.
Dass die Clients `title` und `errors` auslassen, ist dabei richtig: dort
stehen sonst die englischen Prüftexte des Rahmenwerks, und „Not Found" ist
keine Auskunft.

Jetzt eine Form, hinter `Einwand.Bitte` / `.Fehlt` / `.Belegt`. Dass es
drei benannte Türen sind statt eines Aufrufs mit Zahl, hat einen Grund:
man sieht am Wort, ob der Server um eine bessere Eingabe bittet, etwas
nicht findet oder auf etwas Bestehendes stösst.

**Auffallen konnte es nicht**, und das ist der eigentliche Fund. Die
vorhandenen Tests prüften den Rumpf als Zeichenkette — der Satz *stand* ja
drin, nur im falschen Feld. Der neue Test prüft deshalb, in welchem Feld,
und ein Wächter am Quelltext schlägt an, wenn eine der beiden alten Formen
zurückkommt. Er hat beim ersten Lauf gleich eine Stelle gefunden, die mein
Suchmuster übersehen hatte.

Dazu drei Kleinigkeiten, die beim Lesen auffielen:

**Passwörter wurden getrimmt.** Der Dialog schnitt jedem Feld die Ränder
ab, auch dem Passwort — `login.ts` sendet es ungeschoren. Ein Leerzeichen
am Ende, und das angelegte Konto war für immer unbetretbar.

**Beim Einrichten stapelten sich die Fehlerzeilen**, weil `.error`
weggeräumt wurde und `.fehler` entsteht. Drei Fehlversuche, drei rote
Kästen.

**Der Benutzerdialog versprach acht Zeichen und prüfte nichts.** Jetzt
sagt er es selbst, ohne den Umweg über die Leitung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:12:57 +02:00

486 lines
17 KiB
TypeScript

import { icons } from './icons';
import { knopf, meldung } from './util';
import { ordner, type Ordnerstand } from './api';
import { auswahl, type Auswahlfeld } from './auswahl';
/* Dialoge über dem Inhalt statt Blöcken darin.
*
* **Mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten.** Der
* kann, was ein `<div>` erst lernen müsste: Escape schliesst, der
* Hintergrund wird unbedienbar, der Tastaturfokus bleibt gefangen, und
* Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst
* baut, ist eine Zeile, die es schlechter kann. */
export type Feldart =
'text' | 'password' | 'number' | 'ordner' | 'zeitpunkt' | 'mehrzeilig';
export type Dialogfeld = {
name: string;
beschriftung: string;
art?: Feldart;
wert?: string;
platzhalter?: string;
hinweis?: string;
/** Eine Auswahl statt eines Feldes. */
auswahl?: { wert: string; text: string }[];
/** Ein Schalter statt eines Feldes. */
schalter?: boolean;
/** Frühester erlaubter Zeitpunkt — nur bei `art: 'zeitpunkt'`. */
min?: string;
/** Wie hoch es aufmacht — nur bei `art: 'mehrzeilig'`. */
zeilen?: number;
};
/** Liest einen Feldwert — nie `undefined`, ein unbekannter Name ergibt „".
*
* Als Funktion statt als Objekt: ein Tippfehler im Feldnamen gäbe sonst
* stillschweigend `undefined` zurück und landete so im Server. */
export type Dialogwerte = (name: string) => string;
/** Zeigt einen Dialog mit Feldern und gibt zurück, was eingetippt wurde.
*
* `null` heisst abgebrochen — nicht „leer". Die beiden zu verwechseln hiesse,
* ein Abbrechen wie ein Speichern zu behandeln. */
export function dialog(optionen: {
titel: string;
text?: string;
felder?: Dialogfeld[];
bestaetigen: string;
/** Färbt den Knopf rot — für alles, was etwas wegnimmt. */
gefaehrlich?: boolean;
/** Wird mit den Werten gerufen; wirft sie, bleibt der Dialog offen. */
tun: (wert: Dialogwerte) => Promise<void>;
/** Ein dritter Knopf links, für den Weg zurück — etwa „Wieder automatisch". */
weiterer?: { text: string; tun: () => Promise<void> };
}): void {
const d = document.createElement('dialog');
d.className = 'dlg';
const kopf = document.createElement('div');
kopf.className = 'dlg-kopf';
const h = document.createElement('h2');
h.textContent = optionen.titel;
const zu = document.createElement('button');
zu.className = 'dlg-zu';
zu.innerHTML = icons.schliessen;
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => d.close();
kopf.append(h, zu);
const koerper = document.createElement('div');
koerper.className = 'dlg-koerper';
if (optionen.text) {
const p = document.createElement('p');
p.className = 'hinweis';
p.textContent = optionen.text;
koerper.appendChild(p);
}
const eingaben = new Map<string, HTMLInputElement | HTMLTextAreaElement | Auswahlfeld>();
(optionen.felder ?? []).forEach(f => {
// **Ein `<label>` nur dort, wo genau ein Bedienelement darin sitzt.**
// Umschliesst es zusätzlich einen Knopf, löst jeder Klick darauf auch
// das Label aus und schiebt den Fokus ins Textfeld zurück.
const block = document.createElement(f.art === 'ordner' ? 'div' : 'label');
block.className = 'dlg-feld';
const titel = document.createElement('span');
titel.className = 'dlg-beschriftung';
titel.textContent = f.beschriftung;
block.appendChild(titel);
if (f.auswahl) {
// **Eigenes Listenfeld statt `select`.** Dessen Liste zeichnet das
// Betriebssystem; mitten im dunklen Dialog erschien ein helles
// Systemmenü, das sich nicht gestalten lässt.
const wahl = auswahl(f.auswahl, f.wert ?? f.auswahl[0]?.wert ?? '');
wahl.element.classList.add('feld');
eingaben.set(f.name, wahl);
block.appendChild(wahl.element);
} else if (f.schalter) {
const e = document.createElement('input');
e.type = 'checkbox';
e.checked = f.wert === 'true';
eingaben.set(f.name, e);
// Der Schalter steht vor seiner Beschriftung, nicht darunter.
block.classList.add('waagerecht');
block.prepend(e);
titel.remove();
block.append(titel);
} else if (f.art === 'mehrzeilig') {
// **Ein `<textarea>`, kein hohes Textfeld.** „Der Ton setzt bei 0:30
// aus" passt in eine Zeile; „… und ab 0:47 ist das Bild schwarz, aber
// nur in der 4K-Fassung" nicht. Ein einzeiliges Feld schiebt sich dann
// waagerecht weg, und beim Absenden sieht man nicht, was dasteht.
const e = document.createElement('textarea');
e.className = 'feld';
e.rows = f.zeilen ?? 4;
e.value = f.wert ?? '';
if (f.platzhalter) e.placeholder = f.platzhalter;
eingaben.set(f.name, e);
block.appendChild(e);
} else if (f.art === 'zeitpunkt') {
// **Der Wähler des Browsers, nicht ein Textfeld mit Formvorschrift.**
// Vorher stand dort „Form: 2026-12-24 20:15" als Hinweis — und jeder
// Tippfehler darin war erst nach dem Absenden zu sehen. `datetime-local`
// bringt Kalender und Uhr mit, kennt die Zeitzone des Geräts und lässt
// sich mit den Pfeiltasten bedienen.
const e = document.createElement('input');
e.className = 'feld';
e.type = 'datetime-local';
e.value = f.wert ?? '';
if (f.min) e.min = f.min;
eingaben.set(f.name, e);
block.appendChild(e);
} else if (f.art === 'ordner') {
const e = document.createElement('input');
e.className = 'feld';
e.type = 'text';
e.value = f.wert ?? '';
e.placeholder = f.platzhalter ?? '';
eingaben.set(f.name, e);
block.appendChild(ordnerfeld(e));
} else {
const e = document.createElement('input');
e.className = 'feld';
e.type = f.art ?? 'text';
e.value = f.wert ?? '';
e.placeholder = f.platzhalter ?? '';
eingaben.set(f.name, e);
block.appendChild(e);
}
if (f.hinweis) {
const kleingedrucktes = document.createElement('span');
kleingedrucktes.className = 'winzig';
kleingedrucktes.textContent = f.hinweis;
block.appendChild(kleingedrucktes);
}
koerper.appendChild(block);
});
const fehlerplatz = document.createElement('div');
koerper.appendChild(fehlerplatz);
const fuss = document.createElement('div');
fuss.className = 'dlg-fuss';
const abbrechen = knopf('Abbrechen', 'btn');
abbrechen.onclick = () => d.close();
const los = knopf(optionen.bestaetigen,
optionen.gefaehrlich ? 'btn btn-gefahr' : 'btn btn-primary');
// Der dritte Knopf steht links aussen, abgesetzt von Abbrechen und
// Bestätigen — er gehört zu keinem von beiden.
if (optionen.weiterer) {
const dritter = knopf(optionen.weiterer.text, 'btn dlg-weiterer');
dritter.onclick = async () => {
dritter.disabled = true;
try { await optionen.weiterer!.tun(); d.close(); }
catch (fehler) {
const m = document.createElement('div');
m.className = 'fehler';
m.textContent = meldung(fehler);
fehlerplatz.appendChild(m);
dritter.disabled = false;
}
};
fuss.appendChild(dritter);
}
fuss.append(abbrechen, los);
los.onclick = async () => {
fehlerplatz.innerHTML = '';
los.disabled = true;
const gesammelt = new Map<string, string>();
eingaben.forEach((e, name) => {
// **Vor der Auswahl geprüft.** Die Zeile darunter ist ein Auffangnetz
// für alles, was kein `<input>` ist — ein `<textarea>` liefe hinein und
// ergäbe `undefined`. Das wäre der stille Fehler: der Dialog schickte
// ein leeres Feld los, und der Server antwortete „ohne Beschreibung",
// obwohl man sichtbar etwas geschrieben hat.
if (e instanceof HTMLTextAreaElement) { gesammelt.set(name, e.value.trim()); return; }
if (!(e instanceof HTMLInputElement)) { gesammelt.set(name, e.wert); return; }
// **Ein Passwort wird nicht beschnitten.** Überall sonst sind Ränder
// ein Versehen; in einem Passwort sind sie Inhalt. Getrimmt legte der
// Verwaltungsdialog ein Konto an, in das sich niemand anmelden konnte —
// `login.ts` sendet es ungeschoren, und die beiden Zeichenketten waren
// dann verschieden.
if (e.type === 'password') { gesammelt.set(name, e.value); return; }
gesammelt.set(name, e.type === 'checkbox' ? String(e.checked) : e.value.trim());
});
try {
await optionen.tun(name => gesammelt.get(name) ?? '');
d.close();
} catch (fehler) {
// **Offen bleiben.** Einen Dialog bei einem Fehler zu schliessen
// hiesse, die Eingabe wegzuwerfen und die Meldung dorthin zu
// schreiben, wo gerade niemand hinsieht.
const m = document.createElement('div');
m.className = 'fehler';
m.textContent = meldung(fehler);
fehlerplatz.appendChild(m);
los.disabled = false;
}
};
d.append(kopf, koerper, fuss);
// Der Dialog räumt sich selbst weg — sonst sammeln sich im Dokument
// beliebig viele geschlossene an.
d.addEventListener('close', () => d.remove());
// Ein Klick auf den Hintergrund schliesst, ein Klick in den Dialog nicht.
d.addEventListener('click', e => { if (e.target === d) d.close(); });
document.body.appendChild(d);
d.showModal();
// **Ein Seitenwechsel nimmt ihn mit.** Alle Dialoge, die sich selbst bauen,
// melden sich hier an; `dialog()` tat es als einziger nicht — ein Dialog von
// der Detailseite überlebte damit den Zurück-Knopf und stand danach über der
// Mediathek.
beimSeitenwechselSchliessen(d);
const erstes = koerper.querySelector<HTMLElement>('input, textarea, select');
erstes?.focus();
// Enter im Feld bestätigt — im mehrzeiligen Feld macht es eine Zeile, und
// abgeschickt wird dort mit Strg bzw. ⌘. Diesen Fall gab es bis jetzt
// nicht; der Satz, der hier stand, stimmte deshalb.
koerper.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const ziel = e.target as HTMLElement;
if (ziel.matches('select')) return;
if (ziel.matches('textarea') && !(e.ctrlKey || e.metaKey)) return;
e.preventDefault();
los.click();
});
}
/** Ein Textfeld mit Ordnerauswahl daneben.
*
* **Das Feld bleibt tippbar.** Wer den Pfad kennt, ist damit schneller als
* mit jedem Durchklicken — und bei einem tiefen Baum ist es die einzige
* geduldschonende Möglichkeit. Der Knopf ist ein Angebot, keine Pflicht.
*
* **Die Auswahl klappt im selben Dialog auf**, statt einen zweiten darüber
* zu legen. Zwei gestapelte Dialoge sind beim Schliessen immer eine Frage
* zu viel: welcher geht jetzt zu?
*/
function ordnerfeld(eingabe: HTMLInputElement): HTMLElement {
const huelle = document.createElement('div');
huelle.className = 'ordnerfeld';
const zeile = document.createElement('div');
zeile.className = 'ordnerzeile';
const auf = knopf('Durchsuchen', 'btn');
auf.type = 'button';
auf.prepend(rahmenIcon(icons.ordner));
zeile.append(eingabe, auf);
const baum = document.createElement('div');
baum.className = 'ordnerbaum hidden';
const kopf = document.createElement('div');
kopf.className = 'ordnerkopf';
const hoch = document.createElement('button');
hoch.type = 'button';
hoch.className = 'rund';
hoch.innerHTML = icons.hoch;
hoch.setAttribute('aria-label', 'Eine Ebene höher');
const pfad = document.createElement('div');
pfad.className = 'ordnerpfad';
kopf.append(hoch, pfad);
const liste = document.createElement('div');
liste.className = 'ordnerliste';
const fuss = document.createElement('div');
fuss.className = 'ordnerfuss';
const waehlen = knopf('Diesen Ordner wählen', 'btn btn-primary');
waehlen.type = 'button';
fuss.appendChild(waehlen);
baum.append(kopf, liste, fuss);
huelle.append(zeile, baum);
let hier: Ordnerstand | null = null;
/** Welcher Abruf gerade gilt — ältere Antworten werden verworfen. */
let lauf = 0;
async function gehe(ziel?: string): Promise<void> {
const dieser = ++lauf;
liste.innerHTML = '<div class="spinner"></div>';
// **Nach zehn Sekunden sagen, dass es dauert.** Ein Spinner, der ewig
// dreht, sagt nichts — und genau so stand er, als ein Ordner nicht
// antwortete. Jetzt steht wenigstens da, worauf gewartet wird.
const langsam = window.setTimeout(() => {
if (dieser !== lauf) return;
liste.innerHTML = '';
liste.appendChild(hinweiszeile(
`„${ziel ?? 'Start'}" antwortet seit zehn Sekunden nicht. `
+ 'Schläft die Platte, dauert der erste Zugriff — sonst stimmt etwas nicht.'));
liste.appendChild(spinnerzeile());
}, 10000);
try {
hier = await ordner(ziel);
} catch (fehler) {
if (dieser !== lauf) return;
liste.innerHTML = '';
liste.appendChild(hinweiszeile(`Konnte nicht gelesen werden: ${meldung(fehler)}`));
return;
} finally {
window.clearTimeout(langsam);
}
// Ein überholter Abruf darf nicht das Bild des neueren überschreiben.
if (dieser !== lauf) return;
try {
zeichne();
} catch (fehler) {
// **Auch ein Fehler beim Zeichnen muss sichtbar werden.** Vorher blieb
// dann der Spinner stehen, und es sah aus, als käme keine Antwort.
liste.innerHTML = '';
liste.appendChild(hinweiszeile(`Anzeige gescheitert: ${meldung(fehler)}`));
}
}
function spinnerzeile(): HTMLElement {
const d = document.createElement('div');
d.className = 'spinner';
return d;
}
function zeichne(): void {
if (!hier) return;
hoch.disabled = hier.parent === null;
waehlen.textContent = `„${hier.path}" wählen`;
// Der Pfad in anklickbaren Stücken — ein Sprung nach oben ist damit
// einer, nicht fünf.
pfad.innerHTML = '';
const teile = hier.path.split('/').filter(t => t.length > 0);
// Die Wurzel trägt ihren Strich selbst und bekommt deshalb keinen
// Trenner angehängt — sonst steht dort „/ / media".
const wurzel = pfadstueck('/', '/');
wurzel.classList.add('wurzel');
pfad.appendChild(wurzel);
let bisher = '';
teile.forEach(t => {
bisher += '/' + t;
pfad.appendChild(pfadstueck(t, bisher));
});
liste.innerHTML = '';
if (hier.entries.length === 0) {
liste.appendChild(hinweiszeile(hier.note ?? 'Keine Unterordner.'));
return;
}
hier.entries.forEach(e => {
const z = document.createElement('button');
z.type = 'button';
z.className = 'ordnereintrag';
z.innerHTML = icons.ordner;
const name = document.createElement('span');
name.textContent = e.name;
z.appendChild(name);
z.onclick = () => void gehe(e.path);
liste.appendChild(z);
});
}
function pfadstueck(text: string, ziel: string): HTMLElement {
const b = document.createElement('button');
b.type = 'button';
b.className = 'pfadstueck';
b.textContent = text;
b.onclick = () => void gehe(ziel);
return b;
}
function hinweiszeile(text: string): HTMLElement {
const d = document.createElement('div');
d.className = 'winzig';
d.textContent = text;
return d;
}
auf.onclick = () => {
const offen = !baum.classList.toggle('hidden');
auf.textContent = offen ? 'Zuklappen' : 'Durchsuchen';
auf.prepend(rahmenIcon(icons.ordner));
// Da anfangen, wo im Feld schon etwas steht — sonst beim Server.
if (offen && !hier) void gehe(eingabe.value.trim() || undefined);
};
hoch.onclick = () => { if (hier?.parent) void gehe(hier.parent); };
waehlen.onclick = () => {
if (!hier) return;
eingabe.value = hier.path;
baum.classList.add('hidden');
auf.textContent = 'Durchsuchen';
auf.prepend(rahmenIcon(icons.ordner));
eingabe.focus();
};
// **Enter darf hier nicht den Dialog abschicken.** Der Dialog lauscht
// auf seinem Körper und drückt bei Enter den Bestätigen-Knopf; in der
// Ordnerauswahl soll Enter den fokussierten Ordner öffnen und sonst
// nichts.
baum.addEventListener('keydown', e => { if (e.key === 'Enter') e.stopPropagation(); });
return huelle;
}
/** Ein Icon als Element — `innerHTML` würde den Text des Knopfes löschen. */
function rahmenIcon(svg: string): HTMLElement {
const s = document.createElement('span');
s.className = 'icon';
s.innerHTML = svg;
return s;
}
/** Fragt einmal nach — für alles, was etwas wegnimmt. */
export function nachfragen(optionen: {
titel: string;
text: string;
bestaetigen: string;
tun: () => Promise<void>;
}): void {
dialog({
titel: optionen.titel,
text: optionen.text,
bestaetigen: optionen.bestaetigen,
gefaehrlich: true,
tun: () => optionen.tun(),
});
}
/** Schliesst einen Dialog, wenn die Seite gewechselt wird.
*
* **Ein `<dialog>` hängt an `document.body`, nicht am Inhaltsbereich.** Der
* Router tauscht darunter die Seite aus, und das Fenster bleibt darüber
* stehen — über einer Seite, zu der es nicht mehr gehört, und mit einem
* Hintergrund, der sich nicht bedienen lässt. Gesehen beim Prüfen: ein
* Bearbeiten-Dialog stand noch über der Verwaltung.
*
* Wird beim Schliessen von selbst wieder abgemeldet — sonst sammelt sich je
* geöffnetem Dialog ein Zuhörer am Fenster an.
*/
export function beimSeitenwechselSchliessen(d: HTMLDialogElement): void {
const zu = (): void => d.close();
window.addEventListener('hashchange', zu);
window.addEventListener('popstate', zu);
d.addEventListener('close', () => {
window.removeEventListener('hashchange', zu);
window.removeEventListener('popstate', zu);
});
}