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>
527 lines
21 KiB
TypeScript
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;
|
|
}
|