Wer etwas sieht, kann es jetzt auch sagen

Wer einen Server für andere betreibt, erfährt von Fehlern zuletzt: dass
die deutsche Tonspur nach einer halben Stunde aussetzt, merkt der, der den
Film sieht, und nicht der, der ihn eingeräumt hat. Bisher gab es dafür
keinen Weg ausser dem Zuruf im Wohnzimmer.

Gemeldet wird an einem Film, einer Serie, einer einzelnen Staffel und
einer einzelnen Folge: Art (Bild, Ton, Untertitel, Angaben, Sonstiges),
eine freiwillige Stelle wie 0:34:12 und der Kommentar. Pflicht ist nur der
Kommentar — die beiden anderen machen aus einer Liste von Sätzen etwas,
das man sortieren und nachstellen kann.

**Eine Staffel gibt es als Zeile nicht**, und das prägt den Entwurf. Sie
ist Serie plus Nummer, so wie überall sonst in diesem Server; die Meldung
trägt deshalb drei Fremdschlüssel und daneben eine Staffelnummer. Dass die
Nummer auf eine Staffel zeigt, die es gibt, prüft der Endpunkt beim
Schreiben — die Datenbank kann es nicht, es gibt nichts, worauf sie zeigen
könnte.

**Die Rechteprüfung steht nirgends.** Wer einen Titel nicht sehen darf,
findet ihn beim Melden nicht — das erledigt der Freigabefilter, ohne ein
einziges `Where`. Elegant und genau deshalb leicht zu zerstören, darum
steht dafür ein Test da.

**Die eigene Liste überlebt fremde Entscheidungen.** Titel werden
nachgeschlagen, nicht verbunden: zieht ein Administrator einen Titel
zurück, nachdem jemand ihn gemeldet hat, bleibt die Meldung beim Melder
stehen — nur ohne Namen. Ein Join hätte sie verschwinden lassen, und das
wäre jemandes eigene Eingabe, weggenommen wegen einer fremden Entscheidung.
`IgnoreQueryFilters` wäre die bequeme Abkürzung und gäbe den Namen eines
unveröffentlichten Titels über einen Weg heraus, an den beim nächsten Mal
niemand denkt.

**Erledigt heisst abgehakt, nicht gelöscht**, als Zeitpunkt und nicht als
Haken — wie `PublishAt` und `ChangedSince`. Daneben steht „Wieder öffnen";
ein Fehlgriff wäre sonst endgültig. Das Archiv wird erst auf Zuruf geholt:
es wächst nur, und für den seltenen Blick dauernd zu zahlen lohnt nicht.

**Der Abschnitt steht neben der Prüfliste, nicht darin.** Die versteckt
sich, sobald nichts auf Freigabe wartet — das ist der Normalfall, und die
Meldungen verschwänden mit ihr. Umgekehrt bleibt der Meldungsabschnitt
stehen, auch wenn nichts drinsteht: ein Kanal, der sich versteckt, solange
ihn niemand benutzt hat, bringt niemandem bei, dass es ihn gibt.

Die Zahl an der Leiste holt sich nur, wer sie nicht ohnehin hat: steht „Zu
prüfen" offen, meldet der Abschnitt sie selbst und nach jedem Abhaken.

Nebenbei zwei Dinge am Dialog, die hier auffielen. Es gab **kein
mehrzeiliges Feld** — und das Einsammeln der Werte hätte ein `<textarea>`
still verschluckt: es ist kein `HTMLInputElement` und wäre ins Auffangnetz
für Auswahlfelder gelaufen, also als `undefined` hinausgegangen. Der
Server hätte „ohne Beschreibung" geantwortet, auf einem Dialog, in dem
sichtbar etwas steht. Und `dialog()` meldete sich als einziger nicht beim
Seitenwechsel an: ein Dialog von der Detailseite überlebte den
Zurück-Knopf und stand danach über der Mediathek.

Neun Testfälle auf dem Server, zwölf Läufe. Für den Web-Client gibt es in
diesem Projekt keine Tests; dort ist von Hand geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 22:18:14 +02:00
co-authored by Claude Opus 5
parent 02978e482c
commit 7cd48f5528
18 changed files with 2588 additions and 20 deletions
+38 -9
View File
@@ -11,7 +11,8 @@ import { auswahl, type Auswahlfeld } from './auswahl';
* 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';
export type Feldart =
'text' | 'password' | 'number' | 'ordner' | 'zeitpunkt' | 'mehrzeilig';
export type Dialogfeld = {
name: string;
@@ -26,6 +27,8 @@ export type Dialogfeld = {
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 „".
@@ -74,7 +77,7 @@ export function dialog(optionen: {
koerper.appendChild(p);
}
const eingaben = new Map<string, HTMLInputElement | Auswahlfeld>();
const eingaben = new Map<string, HTMLInputElement | HTMLTextAreaElement | Auswahlfeld>();
(optionen.felder ?? []).forEach(f => {
// **Ein `<label>` nur dort, wo genau ein Bedienelement darin sitzt.**
@@ -106,6 +109,18 @@ export function dialog(optionen: {
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
@@ -182,6 +197,12 @@ export function dialog(optionen: {
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; }
gesammelt.set(name, e.type === 'checkbox' ? String(e.checked) : e.value.trim());
});
@@ -210,17 +231,25 @@ export function dialog(optionen: {
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, select');
const erstes = koerper.querySelector<HTMLElement>('input, textarea, select');
erstes?.focus();
// Enter im Feld bestätigt — ausser im mehrzeiligen Fall, den es hier
// nicht gibt.
// 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' && !(e.target as HTMLElement).matches('select')) {
e.preventDefault();
los.click();
}
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();
});
}