Files
Flaro/web/app/src/views/einstellungen/index.ts
T
ScarriffleandClaude Opus 5 7cd48f5528 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>
2026-10-06 22:18:14 +02:00

174 lines
7.0 KiB
TypeScript

import { istAdmin, meldungen, type Bibliothek } from '../../api';
import { icon, type IconName } from '../../icons';
import { kontoseite } from './konto';
import { geschautseite } from './geschaut';
import { dashboard, serverbereich, speicherbereich } from './uebersicht';
import { bibliotheksbereich, vermisstenbereich } from './mediatheken';
import { pruefbereich, meldungsbereich } from './pruefung';
import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung';
import { wiedergabebereich } from './wiedergabe';
import { sprachbereich } from './metadaten';
import { benutzerbereich } from './benutzer';
import { protokollbereich } from './protokolle';
/** Welcher Teil der Einstellungen offen ist. */
export type Bereich =
| 'konto' | 'geschaut'
| 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'protokolle';
type Eintrag = { wert: Bereich; text: string; bild: IconName };
/** Was jedem gehört — das eigene Konto und die eigenen Zahlen. */
const MEINS: Eintrag[] = [
{ wert: 'konto', text: 'Mein Konto', bild: 'person' },
{ wert: 'geschaut', text: 'Geschaut', bild: 'hakenKreis' },
];
/** Was dem Server gehört. Nur Administratoren sehen diese Gruppe. */
const SERVER: Eintrag[] = [
{ wert: 'uebersicht', text: 'Übersicht', bild: 'verwaltung' },
{ wert: 'mediatheken', text: 'Mediatheken', bild: 'filme' },
{ wert: 'pruefung', text: 'Zu prüfen', bild: 'haken' },
{ wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' },
{ wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' },
{ wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
{ wert: 'benutzer', text: 'Benutzer', bild: 'serien' },
{ wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
];
/** Macht aus einem Adressteil einen Bereich.
*
* Unbekanntes und Verbotenes werden zum eigenen Konto: das hat jeder, und es
* ist die Seite, auf der man landen will, wenn man hier nichts zu suchen hat.
*/
export function alsBereich(wert: string | undefined, admin: boolean): Bereich {
if (MEINS.some(b => b.wert === wert)) return wert as Bereich;
if (admin && SERVER.some(b => b.wert === wert)) return wert as Bereich;
return 'konto';
}
/** Die Einstellungen.
*
* **Ein Ort, zwei Gruppen.** Oben steht, was einem selbst gehört — Bild,
* Sprache, die eigenen Zahlen. Darunter, nur für Administratoren, was dem
* Server gehört. Vorher hiess die ganze Seite „Verwaltung" und war für alle
* anderen gar nicht erreichbar; „Meine Sprache" lag dadurch hinter einer Tür,
* die den meisten verschlossen ist.
*
* **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block in
* der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg, das
* man einmal im Monat braucht — und sieht aus wie eine Eingabe, die noch
* aussteht.
*
* **Nur der offene Bereich wird gebaut.** Das ist nicht nur sparsamer — das
* Abspielfenster der Übersicht fragt im Takt von fünf Sekunden nach, und die
* Mediathekenliste beobachtet laufende Scans. Was nicht dasteht, fragt auch
* nicht.
*
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
export function einstellungenseite(
welcher: Bereich = 'konto',
geaendert: (bekannt?: Bibliothek[]) => void = () => {},
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const admin = istAdmin();
const wurzel = document.createElement('div');
wurzel.className = 'verwaltung';
/** Das Feld für die Zahl offener Meldungen — erst beim Bauen der Leiste da. */
let zahl: HTMLElement | null = null;
const leiste = document.createElement('nav');
leiste.className = 'verw-leiste';
leiste.setAttribute('aria-label', 'Bereiche der Einstellungen');
gruppe('Einstellungen', MEINS);
if (admin) gruppe('Verwaltung', SERVER);
const buehne = document.createElement('div');
buehne.className = 'verw-buehne';
inhalt().forEach(a => buehne.appendChild(a));
// **Die Zahl holt sich nur, wer sie nicht ohnehin hat.** Steht „Zu prüfen"
// offen, meldet der Meldungsabschnitt sie selbst — und zwar nach jedem
// Abhaken. Sonst eine Anfrage je Seitenaufbau, und nur für Administratoren.
//
// Verworfen: die Zahl an `/system/info` oder am Konto mitführen. Beide
// werden ohnehin geholt, wären also „umsonst" — und wären ab dem ersten
// Abhaken falsch.
//
// Scheitert sie, bleibt es still: ein Abzeichen, das nicht lädt, fehlt eben.
if (admin && welcher !== 'pruefung') {
void meldungen(false).then(m => abzeichen(m.length)).catch(() => {});
}
wurzel.append(leiste, buehne);
return wurzel;
/** Eine beschriftete Gruppe — die Beschriftung ist hier die halbe Aussage.
*
* „Verwaltung" über den unteren Einträgen sagt ohne weiteren Satz, dass
* diese dem Server gehören und nicht einem selbst. */
/** Die Zahl offener Meldungen auf „Zu prüfen". Leer statt „0": eine Null
* ist Rauschen. */
function abzeichen(offen: number): void {
if (zahl) zahl.textContent = offen > 0 ? String(offen) : '';
}
function gruppe(name: string, eintraege: Eintrag[]): void {
const kopf = document.createElement('div');
kopf.className = 'verw-gruppe';
kopf.textContent = name;
leiste.appendChild(kopf);
eintraege.forEach(b => {
const k = document.createElement('button');
k.type = 'button';
k.className = 'verw-eintrag' + (b.wert === welcher ? ' check' : '');
k.appendChild(icon(b.bild));
k.append(document.createTextNode(b.text));
if (b.wert === welcher) k.setAttribute('aria-current', 'page');
if (b.wert === 'pruefung') {
zahl = document.createElement('span');
zahl.className = 'zahl';
k.appendChild(zahl);
}
k.onclick = () => { location.hash = `#/einstellungen/${b.wert}`; };
leiste.appendChild(k);
});
}
function inhalt(): HTMLElement[] {
switch (welcher) {
case 'geschaut':
return [geschautseite()];
case 'mediatheken':
return [bibliotheksbereich(geaendert, signal), vermisstenbereich(geaendert)];
case 'pruefung': {
// **Die Prüfliste hört auf sich selbst.** Wird etwas freigegeben,
// ändert sich der Bestand — und die Liste darüber muss nachziehen.
const p = pruefbereich(() => { geaendert(); p.auffrischen(); });
// Zwei Sektionen, nicht eine: die Prüfliste versteckt sich, sobald
// nichts auf Freigabe wartet, und nähme die Meldungen sonst mit.
return [p.wurzel, meldungsbereich(abzeichen).wurzel];
}
case 'zuordnung':
return [ohneZuordnungBereich(), unzugeordnetbereich()];
case 'wiedergabe':
return [wiedergabebereich()];
case 'metadaten':
return [sprachbereich()];
case 'benutzer':
return [benutzerbereich()];
case 'protokolle':
return [protokollbereich()];
case 'uebersicht':
return [dashboard(signal), serverbereich(geaendert), speicherbereich()];
default:
return [kontoseite()];
}
}
}