Die Oberfläche nach Plex' Vorbild — Hintergrund, Schriftzug, Besetzung

Nach den Bildern deiner Plex-Installation umgebaut. Übernommen ist der
Aufbau, nicht die Farbe: Plex ist bernsteingelb, Flaro bleibt Orchidee.

**Kopfleiste oben** mit Marke, Suche und Konto. Die Suche gehört dorthin und
nicht in jede Mediathek: wer einen Titel sucht, weiss oft gar nicht, in
welcher er liegt. Sie geht quer durch alles.

**Seitenleiste mit Icons** — echte SVG, keine Zeichen aus einer Schriftart.
Ein ▶ aus Unicode sieht auf jedem System anders aus, lässt sich nicht
einfärben und springt in der Grundlinie. Die aktive Zeile bekommt einen
schmalen Balken links statt eines leuchtenden Blocks; das liest sich in
einer langen Liste ruhiger.

**Die Kachel steht jetzt an einer Stelle** statt an fünfen. Sie trägt den
Haken für Gesehenes in der Ecke — wie bei Plex, ein Blick über das Raster
genügt —, die Zahl offener Folgen an derselben Stelle, den Fortschrittsbalken
unten und beim Überfahren einen Abspielknopf. Folgen sind dreizeilig: Serie,
Folgentitel, „S14 · F22".

**Reihen haben Pfeile.** Zwanzig Kacheln passen nicht in den Bildschirm, und
quer zu scrollen geht mit dem Rad nur mit Umschalttaste. Die Überschrift
nennt die Mediathek beim Namen: „Zuletzt in Dokumentationen hinzugefügt".

**Mediatheken haben drei Reiter** — Empfehlungen, Mediathek, Kategorien.
Darunter eine Filterzeile mit Genre, Sortierung und der Anzahl. Die
Kategorien sind farbige Kacheln; die Farbe kommt aus dem Namen, nicht aus
einer festen Liste, die jedes Genre kennen müsste, das TMDB je erfindet.

**Die Detailseite** hat jetzt den unscharfen Hintergrund über die ganze
Seite, den **Schriftzug des Films** statt der Systemschrift, eine Zeile mit
Jahr, Genres, Laufzeit, Altersfreigabe und Bewertung, runde Knöpfe für
gesehen/Favorit/weglegen, die Medienangaben als Tabelle — Datei, Video, Ton,
Untertitel — und zuletzt Besetzung & Stab mit runden Porträts.

**Dazu am Server:** ein Name. Ohne ihn zeigten die Clients den Hostnamen,
und die App schrieb „localhost" über die Anmeldung. Vorgabe ist der
Rechnername des Containers.

---

**Der Fehler, den das erste Bild zeigte:** die Lupe in der Suchleiste war
dreihundert Bildpunkte gross und lag über der halben Seite. Ein rohes
`<svg>` ohne Breite füllt seinen Container; die Regel galt nur für `.icon`.
Jetzt für jedes.

368 Tests grün. Am Container nachgesehen: „Der Untergang" mit eigenem
Schriftzug, FSK 12, Bewertung 7,9, Genres Drama/Historie/Kriegsfilm, beide
Tonspuren in der Tabelle, 22 Porträts darunter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 01:04:27 +02:00
co-authored by Claude Opus 5
parent ede4069cd4
commit 8c395ac051
15 changed files with 1428 additions and 693 deletions
+70 -29
View File
@@ -3,22 +3,25 @@ import {
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken,
type Hinweis, type Bibliothek,
} from './api';
import { icons, icon } from './icons';
import { einrichtung } from './views/setup';
import { anmeldung } from './views/login';
import { startseite } from './views/home';
import { mediathek } from './views/library';
import { detailseite } from './views/detail';
import { verwaltung } from './views/admin';
import { suchseite } from './views/search';
import { esc } from './util';
const anmeldeflaeche = document.getElementById('login')!;
const kopfleiste = document.getElementById('topbar')!;
const app = document.getElementById('app')!;
const leiste = document.getElementById('chrome')!;
const inhalt = document.getElementById('content')!;
let hinweise: Hinweis[] = [];
/** Die Mediatheken, einmal geholt — die Seitenleiste steht auf ihnen. */
let mediatheken: Bibliothek[] = [];
let serverName = 'Flaro';
/* ---------- Start ---------- */
@@ -28,18 +31,21 @@ async function hochfahren(): Promise<void> {
try {
const auskunft = await systemauskunft();
hinweise = auskunft.attributions;
serverName = auskunft.serverName ?? 'Flaro';
if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; }
if (!angemeldet()) { zeigeAnmeldung(anmeldung(() => location.reload())); return; }
anmeldeflaeche.classList.add('hidden');
kopfleiste.classList.remove('hidden');
app.classList.remove('hidden');
// **Vor dem ersten Zeichnen.** Die Seitenleiste führt jede Mediathek
// einzeln auf; ohne die Liste gäbe es nichts zu zeichnen und der Router
// könnte `#/m/{id}` nicht auflösen.
// einzeln auf; ohne die Liste könnte der Router `#/m/{id}` nicht
// auflösen.
mediatheken = await bibliotheken();
kopfleisteBauen();
leisteBauen();
window.addEventListener('hashchange', zeichnen);
zeichnen();
@@ -48,9 +54,10 @@ async function hochfahren(): Promise<void> {
// Lüge — sie würde bei jedem Versuch scheitern.
anmeldeflaeche.innerHTML =
`<div class="login-card"><h1>Flaro</h1>`
+ `<div class="error">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
+ `<div class="hint">Läuft er? Stimmt die Adresse?</div></div>`;
+ `<div class="fehler">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
+ `<div class="hinweis">Läuft er? Stimmt die Adresse?</div></div>`;
anmeldeflaeche.classList.remove('hidden');
kopfleiste.classList.add('hidden');
app.classList.add('hidden');
}
}
@@ -59,45 +66,74 @@ function zeigeAnmeldung(karte: HTMLElement): void {
anmeldeflaeche.innerHTML = '';
anmeldeflaeche.appendChild(karte);
anmeldeflaeche.classList.remove('hidden');
kopfleiste.classList.add('hidden');
app.classList.add('hidden');
}
/** Lädt die Mediathekenliste neu — nach dem Anlegen oder Löschen einer. */
export async function mediathekenNeuLaden(): Promise<void> {
mediatheken = await bibliotheken();
leisteBauen();
zeichnen();
}
/* ---------- Kopfleiste ---------- */
function kopfleisteBauen(): void {
kopfleiste.innerHTML = '';
const marke = document.createElement('div');
marke.className = 'marke';
marke.textContent = 'Flaro';
// **Die Suche gehört nach oben, nicht in jede Mediathek.** Bei Plex steht
// sie in der Kopfleiste und geht quer durch alles; wer einen Titel sucht,
// weiss oft gar nicht, in welcher Mediathek er liegt.
const suche = document.createElement('form');
suche.className = 'suchfeld';
suche.innerHTML = icons.suche;
const eingabe = document.createElement('input');
eingabe.type = 'search';
eingabe.placeholder = 'Suchen';
eingabe.setAttribute('aria-label', 'Mediathek durchsuchen');
suche.appendChild(eingabe);
suche.onsubmit = e => {
e.preventDefault();
const text = eingabe.value.trim();
if (text) location.hash = `#/suche/${encodeURIComponent(text)}`;
};
const rechts = document.createElement('div');
rechts.className = 'rechts';
const konto = document.createElement('button');
konto.className = 'konto';
const name = sitzung.wer?.userName ?? '';
konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
konto.title = `${name} — abmelden`;
konto.appendChild(icon('abmelden'));
konto.onclick = () => { abmelden(); location.reload(); };
rechts.appendChild(konto);
kopfleiste.append(marke, suche, rechts);
}
/* ---------- Seitenleiste ---------- */
function leisteBauen(): void {
leiste.innerHTML = '';
const marke = document.createElement('div');
marke.className = 'marke';
marke.textContent = 'Flaro';
leiste.appendChild(marke);
eintrag('Start', '#/', 'start');
eintrag('Start', '#/');
// **Jede Mediathek ihre eigene Zeile**, mit Namen und Zahl. Eine Zeile
// „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme die
// Entscheidung zurück, sie zu trennen.
// **Jede Mediathek ihre eigene Zeile**, mit eigenem Icon und Zahl — eine
// Zeile „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme
// die Entscheidung zurück, sie zu trennen.
if (mediatheken.length) {
gruppe('Mediatheken');
mediatheken.forEach(b => eintrag(b.name, `#/m/${b.id}`, b.itemCount));
mediatheken.forEach(b => eintrag(
b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount));
}
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung'); }
const fuss = document.createElement('div');
fuss.className = 'nav-fuss';
const ab = document.createElement('button');
ab.textContent = `Abmelden · ${sitzung.wer?.userName ?? ''}`;
ab.onclick = () => { abmelden(); location.reload(); };
fuss.appendChild(ab);
leiste.appendChild(fuss);
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); }
function gruppe(text: string): void {
const d = document.createElement('div');
@@ -106,10 +142,13 @@ function leisteBauen(): void {
leiste.appendChild(d);
}
function eintrag(text: string, ziel: string, anzahl?: number | null): void {
function eintrag(
text: string, ziel: string, bild: keyof typeof icons, anzahl?: number | null,
): void {
const b = document.createElement('button');
b.dataset.ziel = ziel;
b.innerHTML = `<span>${esc(text)}</span>`
b.innerHTML = `<span class="icon">${icons[bild]}</span>`
+ `<span class="name">${esc(text)}</span>`
+ (anzahl != null ? `<span class="zahl">${anzahl}</span>` : '');
b.onclick = () => { location.hash = ziel; };
leiste.appendChild(b);
@@ -124,7 +163,7 @@ function zeichnen(): void {
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
const ziel = (b as HTMLElement).dataset.ziel!;
b.classList.toggle('check', ziel === `#/${teile.join('/')}`);
b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`);
});
inhalt.innerHTML = '';
@@ -143,8 +182,10 @@ function zeichnen(): void {
// Eine Mediathek, die es nicht mehr gibt — etwa, weil sie in einem
// anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger
// als eine Fehlermeldung über eine Kennung, die niemand gelesen hat.
if (b) inhalt.appendChild(mediathek(b, oeffnen));
if (b) inhalt.appendChild(mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen));
else location.hash = '#/';
} else if (teile[0] === 'suche' && kennung) {
inhalt.appendChild(suchseite(decodeURIComponent(kennung), oeffnen));
} else if (teile[0] === 'verwaltung' && istAdmin()) {
inhalt.appendChild(verwaltung(mediathekenNeuLaden));
} else {