Files
Flaro/web/app/src/main.ts
T
ScarriffleandClaude Opus 5 cdea8e3b0a Die Höhe des Aufmachers ist jetzt einstellbar
Vier Stufen unter Einstellungen → Verwaltung → Übersicht: niedrig, mittel,
hoch, sehr hoch. Mittel ist, was gestern fest verdrahtet war.

**Keine Pixelzahl.** Eine einzustellen hiesse, sie an jedem Bildschirm neu
einzustellen — der Aufmacher misst sich an der Fensterhöhe, nicht an einer
festen Grösse. Gemessen bei 800 Bildpunkten Fensterhöhe: 496, 576, 656,
736. Oben ist Schluss, bevor von der ersten Plakatreihe nichts mehr übrig
bleibt; sie soll zeigen, dass unten noch etwas kommt. Am Telefon bleibt es
flacher, dort wäre auch das weg.

**Sie reist mit der Systemauskunft mit**, nicht in einer eigenen Abfrage.
Die holt der Client ohnehin als Erstes, noch vor der Anmeldung; käme die
Höhe später, stünde der Aufmacher einen Augenblick in der Vorgabe und
spränge dann — genau das, was eine einstellbare Höhe vermeiden soll.
Gesetzt wird sie als Marke am `<html>`, das Regelwerk hängt vier Werte
daran.

Serverweit und nur für Admins, dieselbe Linie wie bei den
Wiedergaberegeln: eine Entscheidung des Gastgebers, nicht des Gastes.
Unbekanntes in der Spalte wird zur Vorgabe statt zu einem Fehler — eine
Startseite, die an einem alten Eintrag scheitert, wäre die schlechtere
Antwort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 18:32:10 +02:00

527 lines
21 KiB
TypeScript

import './style.css';
import {
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken,
reihenfolgeSetzen, bildAdresse, kontoAbgleichen,
type Hinweis, type Bibliothek,
} from './api';
import { icons } from './icons';
import { einrichtung } from './views/setup';
import { einladungsseite } from './views/einladung';
import { einhaengen, ablegen, vergessen } from './ansichten';
import { anmeldung } from './views/login';
import { startseite } from './views/home';
import { mediathek } from './views/library';
import { detailseite } from './views/detail';
import { einstellungenseite, alsBereich } from './views/einstellungen';
import { suchseite } from './views/search';
import { esc, meldung } from './util';
import { menueAnZiel } from './menue';
import { nachfragen } from './dialog';
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[] = [];
let mediatheken: Bibliothek[] = [];
let serverName = 'Flaro';
/* ---------- Start ---------- */
void hochfahren();
async function hochfahren(): Promise<void> {
try {
const auskunft = await systemauskunft();
hinweise = auskunft.attributions;
serverName = auskunft.serverName ?? 'Flaro';
// **So früh wie möglich.** Die Höhe des Aufmachers steht als Marke am
// `<html>`; käme sie später, stünde er einen Augenblick in der Vorgabe
// und spränge dann — genau das, was eine einstellbare Höhe vermeiden soll.
document.documentElement.dataset.aufmacher = auskunft.heroHeight ?? 'mittel';
if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; }
// **Die Einladung muss hierher, nicht in den Router.** Beide Zweige
// ringsum kehren zurück, bevor `zeichnen()` je läuft — ein Ausgeloggter
// mit Einladungslink sähe sonst schlicht die Anmeldekarte, und nach dem
// Anmelden landete er auf der Startseite, ohne je erfahren zu haben,
// wozu er eingeladen war.
//
// Nach dem Einrichten, damit ein nagelneuer Server zuerst seinen
// Administrator bekommt; vor der Anmeldung, weil genau die ja fehlt.
const [erstes, zweites] = location.hash.replace(/^#\/?/, '').split('/');
if (erstes === 'einladung' && zweites) {
zeigeAnmeldung(einladungsseite(decodeURIComponent(zweites), () => {
// Der Hash muss weg, sonst landet das Neuladen wieder hier.
location.hash = '#/';
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 könnte der Router `#/m/{id}` nicht
// auflösen.
// **Das Konto frisch holen, bevor die Kopfleiste steht.** Sie zeigt Bild
// und Namen aus der gemerkten Sitzung, und die stammt von der Anmeldung —
// ein Bild, das anderswo gesetzt wurde, käme hier nie an.
await Promise.all([kontoAbgleichen(), (async () => {
mediatheken = await bibliotheken();
})()]);
kopfleisteBauen();
leisteBauen();
// Das Profil meldet, wenn sich am Konto etwas geändert hat — dann zeigt
// die Kopfleiste das neue Bild sofort und nicht erst nach dem Neuladen.
window.addEventListener('flaro:konto', kopfleisteBauen);
window.addEventListener('hashchange', zeichnen);
zeichnen();
} catch (fehler) {
// Der Server ist nicht erreichbar. Die Anmeldemaske zu zeigen wäre eine
// Lüge — sie würde bei jedem Versuch scheitern.
anmeldeflaeche.innerHTML =
`<div class="login-card"><h1>Flaro</h1>`
+ `<div class="fehler">Der Server antwortet nicht: ${esc(meldung(fehler))}</div>`
+ `<div class="hinweis">Läuft er? Stimmt die Adresse?</div></div>`;
anmeldeflaeche.classList.remove('hidden');
kopfleiste.classList.add('hidden');
app.classList.add('hidden');
}
}
function zeigeAnmeldung(karte: HTMLElement): void {
anmeldeflaeche.innerHTML = '';
anmeldeflaeche.appendChild(karte);
anmeldeflaeche.classList.remove('hidden');
kopfleiste.classList.add('hidden');
app.classList.add('hidden');
}
export async function mediathekenNeuLaden(): Promise<void> {
mediatheken = await bibliotheken();
leisteBauen();
zeichnen();
}
/** Frischt nur die Seitenleiste auf — ohne die Ansicht neu zu zeichnen.
*
* **Der Unterschied ist wichtiger, als er aussieht.** Die Verwaltung meldete
* nach jedem Scan „fertig" und liess neu zeichnen; der Neuaufbau startete die
* Verfolgung erneut, die sah wieder „fertig" und meldete wieder — eine
* Endlosschleife mit zwei Abrufen je Runde, die sich als vier dauernd
* drehende Spinner zeigte. Ein neuer Zählwert in der Seitenleiste braucht
* keinen Neuaufbau der Seite. */
export async function zaehlerAuffrischen(bekannt?: Bibliothek[]): Promise<void> {
// **Denselben Stand zeigen, nicht denselben Takt haben.**
//
// Beide Anzeigen fragten getrennt nach — und zeigten damit zwei Momente:
// in der Seitenleiste stand 95, in der Verwaltung 84. Während die
// Metadaten laufen, legt der Server Doppelte zusammen, die Zahl fällt also
// laufend. Wer hier eine Liste mitgibt, hat sie gerade selbst geholt; dann
// wird sie benutzt statt eine zweite zu ziehen.
const frisch = bekannt ?? await bibliotheken();
// **Nur die Zahlen, solange die Liste dieselbe ist.**
//
// `leisteBauen()` ersetzt die ganze Seitenleiste. Im Dreisekundentakt eines
// laufenden Scans heisst das: sie verschwindet sichtbar und kommt wieder,
// der Mauszeiger steht plötzlich über einem anderen Eintrag, und ein
// gerade gezogener Eintrag wird mitten in der Bewegung weggerissen.
const gleich = frisch.length === mediatheken.length
&& frisch.every((b, i) => b.id === mediatheken[i]!.id && b.name === mediatheken[i]!.name);
mediatheken = frisch;
if (!gleich) { leisteBauen(); return; }
frisch.forEach(b => {
const zeile = leiste.querySelector<HTMLElement>(`[data-ziel="#/m/${b.id}"] .zahl`);
if (zeile) zeile.textContent = String(b.itemCount ?? '');
});
}
/* ---------- 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';
// **Das eigene Bild führt zum eigenen Konto, nicht aus dem Server heraus.**
//
// Hier stand `abmelden()` — ein Klick, keine Rückfrage, kein Weg zurück,
// hinter einer Geste, die überall sonst „mein Konto" heisst. Abmelden gibt
// es weiterhin, auf der Profilseite und mit Nachfrage.
const konto = document.createElement('button');
konto.className = 'konto';
const name = sitzung.wer?.userName ?? '';
const bild = bildAdresse(sitzung.wer?.avatarImage ?? null);
if (bild) {
konto.innerHTML = `<img class="kreis" src="${bild}" alt="">`;
// Kommt es nicht an, tritt der Buchstabe an seine Stelle.
konto.querySelector('img')!.onerror = () => {
konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
};
} else {
konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
}
konto.title = name;
konto.setAttribute('aria-label', `${name} — Konto`);
// **Ein kurzes Menü, kein Sprung.** Erst meldete der Klick ab — ohne
// Rückfrage. Dann führte er auf die eigene Seite, und zum Abmelden musste
// man erst dorthin. Beides sind zwei Punkte; ein Menü mit zwei Punkten ist
// die ehrlichere Form als eine Tür, hinter der eine zweite liegt.
konto.onclick = () => menueAnZiel(konto, [
{
text: 'Einstellungen',
icon: 'verwaltung',
tun: () => { location.hash = '#/einstellungen/konto'; },
},
{
text: 'Abmelden',
icon: 'abmelden',
gefaehrlich: true,
tun: () => nachfragen({
titel: 'Abmelden',
text: `Du meldest dich als „${name}“ von diesem Gerät ab. `
+ 'Sehstände und Favoriten bleiben auf dem Server.',
bestaetigen: 'Abmelden',
tun: async () => { abmelden(); location.reload(); },
}),
},
]);
rechts.appendChild(konto);
kopfleiste.append(marke, suche, rechts);
}
/* ---------- Seitenleiste ---------- */
/** Ob die Seitenleiste gerade umsortiert wird. */
let leisteBearbeiten = false;
function leisteBauen(): void {
leiste.innerHTML = '';
eintrag('Start', '#/', 'start');
// **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', istAdmin() && mediatheken.length > 1);
// **Ein eigener Behälter für die Mediatheken.** Beim Ziehen wird gerechnet,
// die wievielte Zeile unter dem Zeiger liegt; stünden Überschriften und
// „Start" dazwischen, ginge diese Rechnung schief.
const liste = document.createElement('div');
liste.className = 'nav-liste';
leiste.appendChild(liste);
mediatheken.forEach((b, i) => eintrag(
b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i, liste));
}
// **Für jeden, nicht nur für Administratoren.** Hier stand „Verwaltung",
// und die sah nur, wer Rechte hat — mitsamt „Meine Sprache", die jedem
// gehört. Was dem Server gehört, steht jetzt als eigene Gruppe *in* den
// Einstellungen und bleibt Administratoren vorbehalten.
//
// **Unten, ohne Überschrift.** Eine Gruppenüberschrift über einem einzigen
// Eintrag ist eine Überschrift über nichts; am Fuss der Leiste steht er
// dort, wo man Einstellungen sucht.
const fuss = document.createElement('div');
fuss.className = 'nav-fuss';
leiste.appendChild(fuss);
eintrag('Einstellungen', '#/einstellungen', 'verwaltung', undefined, undefined, fuss);
/** Eine Gruppenüberschrift — bei den Mediatheken mit dem Stift daneben.
*
* **Der Griff sitzt dort, wo die Reihenfolge wirkt.** Vorher standen die
* Pfeile in den Einstellungen: man schob dort etwas und musste danach in
* die Seitenleiste sehen, ob es das war, was man wollte.
*/
function gruppe(text: string, mitStift = false): void {
const d = document.createElement('div');
d.className = 'nav-gruppe';
const t = document.createElement('span');
t.textContent = text;
d.appendChild(t);
if (mitStift) {
const stift = document.createElement('button');
stift.className = 'nav-bearbeiten' + (leisteBearbeiten ? ' an' : '');
stift.innerHTML = leisteBearbeiten
? '<span class="icon">' + icons.haken + '</span>'
: '<span class="icon">' + icons.stift + '</span>';
stift.title = leisteBearbeiten ? 'Fertig' : 'Seitenleiste bearbeiten';
stift.setAttribute('aria-label', stift.title);
stift.onclick = () => { leisteBearbeiten = !leisteBearbeiten; leisteBauen(); };
d.appendChild(stift);
}
leiste.appendChild(d);
}
function eintrag(
text: string, ziel: string, bild: keyof typeof icons,
anzahl?: number | null, platz?: number, behaelter?: HTMLElement,
): void {
const b = document.createElement('button');
b.dataset.ziel = ziel;
b.innerHTML = `<span class="icon">${icons[bild]}</span>`
+ `<span class="name">${esc(text)}</span>`
+ (anzahl != null && !leisteBearbeiten ? `<span class="zahl">${anzahl}</span>` : '');
if (leisteBearbeiten && platz != null && behaelter) {
b.classList.add('ziehbar');
// Der Griff links statt eines Knopfes rechts: er sagt „hier anfassen",
// und er nimmt die Zeile nicht als Ganzes in Beschlag.
const griff = document.createElement('span');
griff.className = 'nav-griff icon';
griff.innerHTML = icons.griff;
b.prepend(griff);
// **Die Tastatur bleibt bedienbar.** Ziehen geht damit nicht, und eine
// Funktion, die nur mit der Maus erreichbar ist, ist für den, der keine
// benutzt, gar keine. Sichtbare Pfeile braucht es dafür nicht.
b.setAttribute('aria-roledescription', 'Verschiebbarer Eintrag');
b.title = `„${text}" ziehen — oder mit den Pfeiltasten verschieben`;
b.onkeydown = e => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
void schieben(platz, platz + (e.key === 'ArrowDown' ? 1 : -1));
};
ziehbarMachen(b, platz, behaelter);
} else {
b.onclick = () => { location.hash = ziel; };
}
(behaelter ?? leiste).appendChild(b);
}
}
/** Macht eine Zeile ziehbar.
*
* **Pointer Events statt HTML5-Drag.** Letzteres kennt kein Telefon, zeichnet
* ein Geisterbild, das sich nicht gestalten lässt, und liefert während des
* Ziehens keine brauchbaren Koordinaten. Pointer Events bedienen Maus, Finger
* und Stift mit demselben Code.
*
* **Mit Schwelle.** Erst nach sechs Bildpunkten gilt es als Ziehen — sonst
* verschiebt jeder leicht wackelige Klick die Reihenfolge.
*/
function ziehbarMachen(zeile: HTMLElement, platz: number, behaelter: HTMLElement): void {
zeile.addEventListener('pointerdown', start);
function start(e: PointerEvent): void {
// Nur die linke Maustaste; rechts gehört dem Kontextmenü.
if (e.button !== 0) return;
e.preventDefault();
const zeilen = [...behaelter.querySelectorAll<HTMLElement>('.ziehbar')];
const hoehe = zeile.getBoundingClientRect().height
+ parseFloat(getComputedStyle(behaelter).rowGap || '0');
const startY = e.clientY;
let zieht = false;
let ziel = platz;
zeile.setPointerCapture(e.pointerId);
const bewegen = (m: PointerEvent) => {
const weg = m.clientY - startY;
if (!zieht) {
if (Math.abs(weg) < 6) return;
zieht = true;
behaelter.classList.add('zieht');
zeile.classList.add('wird-gezogen');
}
// Wohin die Zeile fiele, wenn man jetzt losliesse.
ziel = Math.max(0, Math.min(zeilen.length - 1, platz + Math.round(weg / hoehe)));
zeile.style.transform = `translateY(${weg}px)`;
// Die anderen machen Platz — nach oben oder unten, je nachdem, auf
// welcher Seite sie liegen.
zeilen.forEach((andere, i) => {
if (andere === zeile) return;
let versatz = 0;
if (platz < ziel && i > platz && i <= ziel) versatz = -hoehe;
else if (platz > ziel && i >= ziel && i < platz) versatz = hoehe;
andere.style.transform = versatz ? `translateY(${versatz}px)` : '';
});
};
const beenden = () => {
zeile.removeEventListener('pointermove', bewegen);
zeile.removeEventListener('pointerup', beenden);
zeile.removeEventListener('pointercancel', beenden);
behaelter.classList.remove('zieht');
zeilen.forEach(z => { z.style.transform = ''; z.classList.remove('wird-gezogen'); });
// Ohne Ziehen war es ein Klick — und der führt zur Mediathek, auch im
// Bearbeitungsmodus. Sonst wäre die Leiste dort unbenutzbar.
if (!zieht) {
const weg = zeile.dataset.ziel;
if (weg) location.hash = weg;
return;
}
if (ziel !== platz) void schieben(platz, ziel);
};
zeile.addEventListener('pointermove', bewegen);
zeile.addEventListener('pointerup', beenden);
zeile.addEventListener('pointercancel', beenden);
}
}
/** Schiebt eine Mediathek und schickt die ganze neue Reihenfolge.
*
* **Die ganze Liste, nicht „eins hoch".** Zwei Benutzer, die gleichzeitig
* schieben, kämen sonst in einen Zustand, den keiner von beiden wollte.
*/
async function schieben(von: number, nach: number): Promise<void> {
if (nach < 0 || nach >= mediatheken.length) return;
const neu = [...mediatheken];
const [eintrag] = neu.splice(von, 1);
neu.splice(nach, 0, eintrag!);
// Sofort zeichnen, damit der Klick sich sofort anfühlt; der Server bekommt
// dieselbe Liste und widerspricht beim nächsten Laden, falls doch etwas
// schiefging.
mediatheken = neu;
leisteBauen();
try { await reihenfolgeSetzen(neu.map(b => b.id)); }
catch { await zaehlerAuffrischen(); }
}
/* ---------- Router ---------- */
function zeichnen(): void {
const weg = location.hash.replace(/^#\/?/, '');
const teile = weg.split('/').filter(Boolean);
// **Zwei Segmente, sonst eines.** Eine Mediathek heisst `#/m/{id}` und
// braucht beide; die Verwaltung heisst `#/verwaltung` und hat seit der
// zweiten Leiste einen Bereich dahinter. Verglich man stur zwei Segmente,
// verlor „Verwaltung" seine Markierung, sobald man darin etwas anklickte.
const zweiTeilig = `#/${teile.slice(0, 2).join('/')}`;
const einTeilig = `#/${teile.slice(0, 1).join('/')}`;
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
const ziel = (b as HTMLElement).dataset.ziel!;
b.classList.toggle('check', ziel === zweiTeilig || ziel === einTeilig);
});
// **Aushängen, nicht leeren.** `innerHTML = ''` zerstörte die Ansicht —
// mitsamt Bildlauf, offenem Reiter und gesetzten Filtern.
ablegen();
inhalt.innerHTML = '';
const oeffnen = (art: 'filme' | 'serien', id: string) => { location.hash = `#/${art}/${id}`; };
const alleZeigen = (b: Bibliothek) => { location.hash = `#/m/${b.id}`; };
const art = teile[0] === 'filme' || teile[0] === 'serien' ? teile[0] : null;
const kennung = teile[1];
if (art && kennung) {
// **Die Detailseite wird nicht gemerkt.** Sie ist billig gebaut, und ein
// veralteter Sehfortschritt darauf wäre ärgerlicher als der Neuaufbau.
einhaengen(weg, inhalt, () => detailseite(art, kennung, () => history.back()));
} else if (teile[0] === 'm' && kennung) {
const b = mediatheken.find(x => x.id === kennung);
// 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) {
einhaengen(weg, inhalt,
() => mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen),
{ merken: true });
} else location.hash = '#/';
} else if (teile[0] === 'suche' && kennung) {
einhaengen(weg, inhalt, () => suchseite(decodeURIComponent(kennung), oeffnen),
{ merken: true });
} else if (teile[0] === 'profil' || teile[0] === 'verwaltung') {
// **Die alten Adressen bleiben gültig.** Beides ist in „Einstellungen"
// aufgegangen; ein Lesezeichen soll davon nichts merken.
location.hash = teile[0] === 'profil'
? '#/einstellungen/konto'
: `#/einstellungen/${teile[1] ?? 'uebersicht'}`;
return;
} else if (teile[0] === 'einstellungen') {
// **Nicht gemerkt** — sie zeigt Laufendes. Ihr Takt hängt am Signal und
// hört auf, sobald man weggeht.
einhaengen(weg, inhalt, signal => einstellungenseite(alsBereich(teile[1], istAdmin()), bekannt => {
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
vergessen();
// `bekannt` ist der Stand, den die Verwaltung gerade geholt hat. Ihn
// weiterzureichen heisst: beide zeigen dieselbe Zahl, nicht nur
// ähnliche.
void zaehlerAuffrischen(bekannt);
}, signal));
} else {
einhaengen(weg, inhalt,
signal => startseite(mediatheken, oeffnen, alleZeigen, signal),
{ merken: true });
}
inhalt.appendChild(hinweisleiste());
}
/** Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, nicht Höflichkeit. */
function hinweisleiste(): HTMLElement {
const d = document.createElement('div');
d.className = 'attribution';
hinweise.forEach(h => {
const a = document.createElement('a');
a.href = h.url;
a.target = '_blank';
a.rel = 'noreferrer';
a.innerHTML = `<img src="${esc(h.logoUrl)}" alt="TMDB">`;
d.append(a, document.createTextNode(h.text));
});
return d;
}