Dialoge statt Blöcke, bessere Filter, ein Dashboard — und eine NAS-Anleitung

**Was etwas anlegt oder ändert, steht jetzt in einem Dialog.** Das Formular
zum Anlegen einer Mediathek nahm als Block in der Seite Platz für etwas weg,
das man einmal im Monat braucht — und sah aus wie eine Eingabe, die noch
aussteht. Dasselbe für Benutzer, Umbenennen, den Servernamen und die
Metadaten-Einstellungen.

Gebaut mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten: Escape
schliesst, der Hintergrund wird unbedienbar, der Tastaturfokus bleibt
gefangen, Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das
selbst baut, ist eine Zeile, die es schlechter kann. Scheitert das
Speichern, bleibt der Dialog offen — ihn zu schliessen hiesse, die Eingabe
wegzuwerfen und die Meldung dorthin zu schreiben, wo gerade niemand hinsieht.

**Die Filter sehen nicht mehr nach Eingabe aus.** Bei Plex stehen dort
Wörter mit einem kleinen Pfeil; ein umrandetes Auswahlfeld sagt „hier fehlt
noch etwas", und das stimmt nicht. Gesetzte Filter werden farbig, daneben
steht die Anzahl, und „Filter zurücksetzen" erscheint nur, wenn es etwas
zurückzusetzen gibt. Dazu neu: gesehen / noch nicht gesehen.

Das siebt vorerst in der Oberfläche, nicht am Server — er kann es noch
nicht. Der Preis steht in der Zahl daneben: sie zeigt dann „3 von 7".

---

**„Wird abgespielt" — wer gerade was schaut.**

Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen Anmeldung
am Spieler: jeder Client meldet beim Abspielen ohnehin alle zehn Sekunden,
wo er steht. Wer das als Lebenszeichen liest, braucht kein zweites
Verfahren, das ein Client vergessen könnte — bleibt die Meldung aus, läuft
der Eintrag nach einer Minute von selbst ab.

Im Arbeitsspeicher, nicht in der Datenbank: „wer schaut gerade was" ist eine
Frage über den Augenblick. Nach einem Neustart lautet die Antwort „niemand",
und das zu speichern hiesse, Wiedergaben anzuzeigen, die es nicht gibt.

Zwei Geräte desselben Menschen sind zwei Zeilen — derselbe Mensch kann auf
dem Fernseher und auf dem Telefon gleichzeitig etwas laufen lassen.
Geschlüsselt wird deshalb über das Gerätetoken, nicht über den Benutzer und
nicht über den Gerätenamen: zwei iPhones heissen beide „iPhone".

**Nur für Administratoren.** Wer wann welchen Film anschaut, ist eine
Auskunft über Menschen, nicht über den Server. In einem Haushalt mag das
harmlos sein; die Vorgabe eines offenen Projekts darf es trotzdem nicht sein.

Der Spieler meldet beim Schliessen ausdrücklich „beendet". Ohne das stünde
ein geschlossener Film noch eine Minute als „läuft" — und wer gerade den
Spieler zugemacht hat, sähe sich selbst beim Schauen zu.

---

**`docs/NAS-EINRICHTEN.md`** beantwortet die Frage, die offen war: **es ist
ein einziges Image.** Die Weboberfläche wird beim Bauen hineingelegt und vom
Server selbst ausgeliefert — ein Container, drei Volumes, kein Proxy, keine
Datenbank daneben.

Drei Wege aufs NAS: über die Container-Registry deines Gitea (die läuft —
nachgeprüft), als Datei über `docker save`, oder dort bauen. Dazu der
Schritt, den man am leichtesten überspringt und am teuersten bezahlt: die
Architektur. Ein Mac baut ohne Angabe arm64, ein Intel-NAS braucht amd64,
und `exec format error` sagt das nicht deutlich.

Eine Zahl darin war geraten und ist korrigiert: `docker save | gzip` ergibt
nicht 90 MB, sondern 230 — die Ebenen eines Images sind bereits gepackt.

378 Tests grün, sechs davon neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 08:03:07 +02:00
co-authored by Claude Opus 5
parent a7ec3a73ba
commit f2b5ee633b
17 changed files with 1394 additions and 194 deletions
+196
View File
@@ -0,0 +1,196 @@
import { icons } from './icons';
import { knopf } from './util';
/* 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';
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;
};
/** 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>;
}): 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 | HTMLSelectElement>();
(optionen.felder ?? []).forEach(f => {
const block = document.createElement('label');
block.className = 'dlg-feld';
const titel = document.createElement('span');
titel.className = 'dlg-beschriftung';
titel.textContent = f.beschriftung;
block.appendChild(titel);
if (f.auswahl) {
const wahl = document.createElement('select');
wahl.className = 'feld';
f.auswahl.forEach(o => {
const e = document.createElement('option');
e.value = o.wert; e.textContent = o.text;
wahl.appendChild(e);
});
if (f.wert) wahl.value = f.wert;
eingaben.set(f.name, wahl);
block.appendChild(wahl);
} 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 {
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');
fuss.append(abbrechen, los);
los.onclick = async () => {
fehlerplatz.innerHTML = '';
los.disabled = true;
const gesammelt = new Map<string, string>();
eingaben.forEach((e, name) => {
gesammelt.set(name, e instanceof HTMLInputElement && 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 = (fehler as Error).message;
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();
const erstes = koerper.querySelector<HTMLElement>('input, select');
erstes?.focus();
// Enter im Feld bestätigt — ausser im mehrzeiligen Fall, den es hier
// nicht gibt.
koerper.addEventListener('keydown', e => {
if (e.key === 'Enter' && !(e.target as HTMLElement).matches('select')) {
e.preventDefault();
los.click();
}
});
}
/** 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(),
});
}