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:
co-authored by
Claude Opus 5
parent
ede4069cd4
commit
8c395ac051
+70
-29
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user