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 { 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 = `

Flaro

` + `
Der Server antwortet nicht: ${esc((fehler as Error).message)}
` + `
Läuft er? Stimmt die Adresse?
`; 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 { 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 { 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 = `${esc(name.slice(0, 1).toUpperCase())}`; 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 = `${icons[bild]}` + `${esc(text)}` + (anzahl != null ? `${anzahl}` : ''); 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 = `TMDB`; d.append(a, document.createTextNode(h.text)); }); return d; }