Files
Flaro/web/app/src/main.ts
T
ScarriffleandClaude Opus 5 637e95fbb1 Die Verwaltung zeichnete sich endlos neu
Gemessen am laufenden Server: nach dem Scan lief eine Schleife mit zwei
Abrufen je Runde, sichtbar als vier dauernd drehende Spinner.

Der Weg dahin: verfolgen() lief bei jedem Aufbau der Mediathekenzeile, sah
den Zustand finished, meldete das über geaendert(), und geaendert() war
mediathekenNeuLaden — also neu zeichnen. Der Neuaufbau startete verfolgen()
erneut, das wieder finished sah. Dass der Lauf längst vorbei war, machte
keinen Unterschied; der Zustand bleibt im Speicher stehen.

Zwei Dinge dagegen. verfolgen() meldet nur noch, wenn es selbst einen Lauf
angestossen hat — beim Aufbau zeigt es den Stand an und schweigt. Und die
Verwaltung frischt über zaehlerAuffrischen() nur noch die Seitenleiste auf
statt die ganze Seite neu zu zeichnen.

Dazu zwei Befunde aus dem Plan, die sich beim Nachsehen als falsch
erwiesen: die Takte werden sehr wohl abbestellt, dafür gibt es seit je
einen MutationObserver, und das Dashboard tauscht seinen Inhalt erst nach
dem Abruf. Geblieben ist der Spinner in den Listen, der bei jedem
Auffrischen erschien — der kommt jetzt nur noch beim ersten Aufbau.

Dazu der Zwischenspeicher für Ansichten. Der Router hängt sie aus statt
sie zu zerstören; beim Zurückkommen stehen sie sofort wieder da, mitsamt
Bildlauf, offenem Reiter und gesetzten Filtern, und laden leise nach.
Gemessen: null Abrufe und 4 von 4 Kacheln beim Zurückkommen aus der
Verwaltung. Höchstens fünf Ansichten, Verfall nach fünf Minuten,
weggeworfen bei allem, was den Bestand ändert. Detailseite und Verwaltung
sind ausgenommen — beide zeigen Laufendes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:11:06 +02:00

242 lines
8.6 KiB
TypeScript

import './style.css';
import {
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken,
type Hinweis, type Bibliothek,
} from './api';
import { icons, icon } from './icons';
import { einrichtung } from './views/setup';
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 { 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[] = [];
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';
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 könnte der Router `#/m/{id}` nicht
// auflösen.
mediatheken = await bibliotheken();
kopfleisteBauen();
leisteBauen();
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((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');
}
}
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(): Promise<void> {
mediatheken = await bibliotheken();
leisteBauen();
}
/* ---------- 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 = '';
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');
mediatheken.forEach(b => eintrag(
b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount));
}
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); }
function gruppe(text: string): void {
const d = document.createElement('div');
d.className = 'nav-gruppe';
d.textContent = text;
leiste.appendChild(d);
}
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 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);
}
}
/* ---------- Router ---------- */
function zeichnen(): void {
const weg = location.hash.replace(/^#\/?/, '');
const teile = weg.split('/').filter(Boolean);
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
const ziel = (b as HTMLElement).dataset.ziel!;
b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`);
});
// **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] === 'verwaltung' && istAdmin()) {
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
// Signal und hört auf, sobald man weggeht.
einhaengen(weg, inhalt, signal => verwaltung(() => {
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
vergessen();
void zaehlerAuffrischen();
}, signal));
} else {
einhaengen(weg, inhalt, () => startseite(mediatheken, oeffnen, alleZeigen),
{ 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;
}