**Der Einstellungsknopf lag hinter der Abspielleiste.** Gemessen: `.pl`
steht auf Stufe 150, `#chrome` auf gar keiner, und die kleine Leiste spannte
über die ganze Breite — genau über die unteren 72 Bildpunkte der
Seitenleiste, wo „Einstellungen" klebt. Weil der Spieler bei jedem Wechsel
der Adresse minimiert, stand sie zuverlässig im Weg, sobald man dorthin
wollte.
Die Leiste beginnt jetzt hinter der Seitenleiste. Nicht über die Stufen
gelöst, und das ist der Punkt: `#chrome` höher zu setzen hiesse, die
Seitenleiste läge auch über dem grossen Spieler — es ist dieselbe Hülle.
Dass diese Leiste Platz wegnimmt statt sich darüberzulegen, stand für
Inhalt, Hinweise und Auswahlleiste ohnehin schon da; die Seitenleiste war
die vergessene vierte. Gemessen, 1400 Bildpunkte breit: Leiste ab 240,
Knopf bei 0, keine Überschneidung, und ein Treffertest in der Knopfmitte
trifft den Knopf.
**Ein Klick auf eine Kategorie kam nie an.** Dort sprang der Code in die
Mediathek und suchte danach `.filterzeile select`, um den Wert zu setzen.
Die Filterzeile hat kein `<select>` — `auswahl()` baut einen Knopf, mit
Absicht und begründet. Der Zweig lief also nie, und man landete in der
ungefilterten Mediathek. Der Kommentar daneben behauptete das Gegenteil.
Das Genre steht jetzt in der Adresse: `#/m/{id}/mediathek/Horror`. Das
löst zugleich das zweite Problem — die Mediathek wird unter ihrem Weg
gemerkt, und stünde das Genre nur im Kopf, zeigte ein zweiter Besuch die
Liste des ersten. Geprüft: Klick setzt den Weg und die Pille, Neuladen
behält beides, Zurück führt auf die Kategorien, Vorwärts wieder hinein.
**Zurückgezogene Einladungen blieben für immer stehen.** Der Knopfblock
steckte ganz in `if (!e.revokedAt)`, eine erledigte Zeile hatte also gar
keinen Knopf. Die Serverbegründung — eine Zeile wegzuwerfen hiesse,
hinterher nicht mehr zu wissen, dass es den Link je gab — bleibt richtig,
sie gilt nur nicht ewig: eine Liste, aus der nichts verschwindet, wird
unlesbar, und dann sieht man die eine Zeile nicht mehr, um die es ging.
Deshalb kein umgedeutetes DELETE, sondern `?purge=true`, und das greift nur
bei einem Link, der ohnehin keine Tür mehr öffnet. Ein gültiger wird mit
einem deutschen Satz abgewiesen.
**Poster in der falschen Sprache.** „365 Tage" stand mit polnischem Plakat
da. Kein fehlender Anbieter: für den Schriftzug gab es die sprachgewichtete
Wahl längst, fürs Plakat wurde das blanke `poster_path` der Hauptantwort
genommen — bei TMDB das Bild der Originalsprache, wenn in der gewünschten
keines eingetragen ist. Die sortierte Liste lag im selben Abruf schon vor
und wurde ausschliesslich für den Schriftzug benutzt.
Plakat und Hintergrund nehmen jetzt dieselbe Rangfolge: eingestellte
Sprache, dann sprachlos, dann Englisch. Sprachlos vor Englisch ist keine
Verlegenheit — ein Bild ohne Sprachkennung trägt keinen Text, und ein
Plakat ohne Titel ist für einen deutschen Bestand besser als dasselbe mit
englischem. Im Bilderdialog stehen die Kandidaten jetzt ebenso sortiert.
Bestehende Titel ändern sich beim nächsten Auffrischen; von Hand gesetzte
Plakate bleiben unangetastet.
676 Tests grün, davon zehn neue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
531 lines
21 KiB
TypeScript
531 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) {
|
|
// Das vierte Segment ist das Genre: `#/m/{id}/mediathek/Horror`. Es
|
|
// gehört zum Weg und damit auch zum gemerkten Aufbau — zwei Genres
|
|
// sind zwei Ansichten, nicht eine mit wechselndem Inhalt.
|
|
einhaengen(weg, inhalt,
|
|
() => mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen,
|
|
teile[3] ? decodeURIComponent(teile[3]) : ''),
|
|
{ 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;
|
|
}
|