Ein Klick, der nichts tat, und niemand sagte warum

Eine Serienkachel kannte der Server als Titel nicht: „als gesehen markieren"
lief in eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
sichtbar gar nichts. Dasselbe beim Favoriten. UserItemState bekommt eine
Serienkennung, das Häkchen zieht alle Folgen mit, und was wirft, erscheint als
Meldung statt in der Browserkonsole.

Dazu eine Mehrfachauswahl in der Mediathek — gesehen, ungesehen, Metadaten
neu holen für das Gewählte, in einer Anfrage statt in zweihundert. Ein neues
Plakat steht sofort auf der Kachel, und ein Titel, den es nicht mehr gibt,
endet nicht mehr in einer Sackgasse.

Gemessen: ein POST auf /refresh ohne Rumpf antwortete nach der Erweiterung mit
404 — also genau der Aufruf „alles auffrischen", den es länger gibt als die
Auswahl. Der Rumpf wird jetzt von Hand gelesen, und beide Formen stehen in
einer Prüfung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-05 18:28:33 +02:00
co-authored by Claude Opus 5
parent c854c4a09a
commit 557708b769
21 changed files with 2085 additions and 78 deletions
+21 -2
View File
@@ -56,6 +56,8 @@ export type Serie = {
id: string; title: string; year: number | null; overview: string | null;
posterImage: string | null; backdropImage: string | null;
episodeCount: number; seasons: number[]; unwatchedCount: number;
/** Favorit, Bewertung, Häkchen — zur Serie als Ganzem, neben `unwatchedCount`. */
state: Stand | null;
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
@@ -468,6 +470,19 @@ export const gesehen = (id: string, wert: boolean) =>
method: 'PUT', body: JSON.stringify({ watched: wert }),
});
/** Was aus einer Sammelmarkierung geworden ist. */
export type Sammelergebnis = { changed: number; unknown: string[] };
/** Viele auf einmal abhaken.
*
* **Eine Anfrage, nicht zweihundert.** Bei einer Mediathek mit 455 Titeln
* wären Einzelanfragen ein Sturm, bei dem mittendrin etwas scheitert und
* niemand weiss, was durchging. */
export const gesehenSammel = (ids: string[], wert: boolean) =>
anfrage<Sammelergebnis>('/items/watched', {
method: 'PUT', body: JSON.stringify({ ids, watched: wert }),
});
/** Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen. */
export const weglegen = (id: string) =>
anfrage<Stand>(`/items/${id}/dismiss`, { method: 'POST' });
@@ -490,8 +505,12 @@ export const genresVon = (id: string) => anfrage<string[]>(`/libraries/${id}/gen
export const empfehlungen = (take = 20) =>
anfrage<Seite<Film>>(`/home/suggestions${abfrage({ take })}`);
export const metadatenAuffrischen = (id: string) =>
anfrage<void>(`/libraries/${id}/refresh`, { method: 'POST' });
/** Metadaten neu holen — für die ganze Mediathek oder nur für eine Auswahl. */
export const metadatenAuffrischen = (id: string, ids?: string[]) =>
anfrage<void>(`/libraries/${id}/refresh`, {
method: 'POST',
body: ids && ids.length > 0 ? JSON.stringify({ ids }) : undefined,
});
/** Eine laufende Wiedergabe — nur für Administratoren sichtbar. */
export type Wiedergabe = {
+21 -3
View File
@@ -18,7 +18,21 @@ const ARTEN: { art: Bildart; titel: string; verhaeltnis: string }[] = [
* **Die Kandidaten kosten nichts.** TMDB liefert sie beim Einzelabruf ohnehin
* mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
*/
export function bilddialog(art: 'movies' | 'series', id: string, titel: string): void {
/** Bilder eines Titels austauschen.
*
* **`fertig` wird gerufen, wenn sich wirklich etwas geändert hat** — und erst
* beim Schliessen, nicht nach jeder einzelnen Wahl. Wer drei Bilder tauscht,
* löst nicht dreimal einen Neuaufbau der Kachel aus. Wer nur hineinschaut und
* wieder zumacht, gar keinen.
*
* **Warum es den Rückruf überhaupt gibt.** Ohne ihn zeigte die Kachel das
* alte Plakat weiter, bis man die ganze Oberfläche neu lud — und verlor dabei
* Bildlauf, Filter und offene Reiter.
*/
export function bilddialog(
art: 'movies' | 'series', id: string, titel: string, fertig?: () => void,
): void {
let geaendert = false;
const d = document.createElement('dialog');
d.className = 'dlg dlg-breit';
@@ -38,7 +52,7 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
koerper.innerHTML = '<div class="spinner"></div>';
d.append(kopf, koerper);
d.addEventListener('close', () => d.remove());
d.addEventListener('close', () => { d.remove(); if (geaendert) fertig?.(); });
d.addEventListener('click', e => { if (e.target === d) d.close(); });
document.body.appendChild(d);
d.showModal();
@@ -189,7 +203,11 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
b.onclick = async () => {
b.disabled = true;
try { await bildSetzen(art, id, bildart, kandidat!.url); await laden(); }
try {
await bildSetzen(art, id, bildart, kandidat!.url);
geaendert = true;
await laden();
}
catch (fehler) {
b.after(fehlerzeile(meldung(fehler)));
b.disabled = false;
+42 -16
View File
@@ -23,6 +23,8 @@ export type Kachel = {
stand?: Stand | null;
/** Die Zahl offener Folgen, als Plakette oben rechts. */
offen?: number | null;
/** Wie viele Folgen es überhaupt sind — um die Plakette wiederherzustellen. */
gesamt?: number | null;
/** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */
freigabe?: string | null;
};
@@ -35,8 +37,9 @@ export const alsKachel = (e: Film | Serie): Kachel => ({
titel: e.title,
zweite: e.year ? String(e.year) : null,
bild: e.posterImage,
stand: 'state' in e ? e.state : null,
stand: e.state,
offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
gesamt: 'episodeCount' in e ? e.episodeCount : null,
freigabe: e.publishAt,
});
@@ -67,6 +70,11 @@ export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episod
export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) => void): HTMLElement {
const knopf = document.createElement('button');
knopf.className = 'kachel';
// **Die Kennung steht am Knoten.** So kann ein Auswahlmodus über dem Raster
// arbeiten, ohne dass jede der fünf Stellen, die Kacheln baut, davon wissen
// muss — sie liest sie vom angeklickten Element ab.
knopf.dataset.id = k.id;
knopf.dataset.art = k.art;
knopf.onclick = () => oeffnen(k.art, k.id);
// **Das Kontextmenü an jeder Kachel, überall.** Gesehen, Favorit und
@@ -248,11 +256,14 @@ function kachelpunkte(
text: istGesehen ? 'Als ungesehen markieren' : 'Als gesehen markieren',
icon: 'hakenKreis',
tun: async () => {
await gesehen(k.id, !istGesehen);
// **Die Antwort des Servers gilt, nicht die Annahme hier.** Bei einer
// Serie zieht das Häkchen alle Folgen mit; was dabei herauskommt,
// weiss nur der Server.
k.stand = await gesehen(k.id, !istGesehen);
// **Die Kachel spiegelt den neuen Zustand selbst.** Die Liste neu zu
// laden verlöre Bildlauf und Filter — für einen Haken.
if (k.stand) k.stand.watched = !istGesehen;
hakenZeigen(knopf, !istGesehen);
k.offen = istGesehen ? (k.gesamt ?? k.offen) : 0;
hakenZeigen(knopf, k, !istGesehen);
},
},
{
@@ -260,8 +271,7 @@ function kachelpunkte(
icon: 'stern',
tun: async () => {
const neuerWert = !k.stand?.favourite;
await favorit(k.id, neuerWert);
if (k.stand) k.stand.favourite = neuerWert;
k.stand = await favorit(k.id, neuerWert);
},
},
];
@@ -286,7 +296,9 @@ function kachelpunkte(
punkte.push({
text: 'Bilder bearbeiten',
icon: 'stift',
tun: () => bilddialog(art, k.id, k.titel),
// **Die Kachel holt sich das neue Plakat selbst.** Vorher blieb das
// alte stehen, bis man die ganze Oberfläche neu lud.
tun: () => bilddialog(art, k.id, k.titel, () => void kachelAuffrischen(k, knopf)),
});
punkte.push({
text: 'Titel ändern',
@@ -354,18 +366,32 @@ async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise<void> {
}
}
/** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen. */
function hakenZeigen(knopf: HTMLElement, an: boolean): void {
/** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen.
*
* **Die Ecke zeigt eines von beidem.** Haken oder Zahl offener Folgen — beides
* nebeneinander wäre ein Widerspruch. Wird eine Serie abgehakt, geht die Zahl;
* wird das Häkchen genommen, kommt sie zurück.
*/
function hakenZeigen(knopf: HTMLElement, k: Kachel, an: boolean): void {
const plakat = knopf.querySelector('.plakat');
if (!plakat) return;
plakat.querySelector('.ecke.haken')?.remove();
plakat.querySelector('.ecke')?.remove();
knopf.querySelector('.fortschritt')?.remove();
if (!an) return;
const ecke = document.createElement('span');
ecke.className = 'ecke haken';
ecke.title = 'Gesehen';
ecke.innerHTML = icons.haken;
plakat.appendChild(ecke);
if (an) {
const ecke = document.createElement('span');
ecke.className = 'ecke haken';
ecke.title = 'Gesehen';
ecke.innerHTML = icons.haken;
plakat.appendChild(ecke);
return;
}
if (k.offen == null || k.offen <= 0) return;
const plakette = document.createElement('span');
plakette.className = 'ecke zahl';
plakette.textContent = String(k.offen);
plakette.title = `${k.offen} Folgen offen`;
plakat.appendChild(plakette);
}
+11 -1
View File
@@ -1,4 +1,5 @@
import { icons } from './icons';
import { hinweisZeigen, meldung } from './util';
export type Menuepunkt = {
text: string;
@@ -112,7 +113,16 @@ function oeffnen(
const t = document.createElement('span');
t.textContent = p.text;
b.appendChild(t);
b.onclick = async () => { menueSchliessen(); await p.tun(); };
// **Ein Fehler darf nicht verschwinden.** Vorher stand hier
// `await p.tun()` ohne Auffangnetz: wirft die Aktion — und eine abgelehnte
// Anfrage tut das —, wird daraus eine unbehandelte Zusage. Die landet in
// der Konsole des Browsers und sonst nirgends. Für den Benutzer sah es
// aus, als täte der Klick einfach nichts.
b.onclick = async () => {
menueSchliessen();
try { await p.tun(); }
catch (fehler) { hinweisZeigen(meldung(fehler), 'fehler'); }
};
menue.appendChild(b);
});
+100
View File
@@ -1186,3 +1186,103 @@ input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover {
@media (hover: none) {
.kachel-mehr { opacity: .85; }
}
/* ---------- Kurze Meldungen am unteren Rand ----------
*
* **Für alles, was keinen Ort auf der Seite hat.** Eine Aktion aus einem
* Kontextmenü wirkt, nachdem das Menü schon zu ist — scheitert sie, gäbe es
* sonst nirgends Platz dafür. Vorher verschwand so ein Fehler spurlos.
*
* Über allem ausser den Dialogen (`z-index: 200`): eine Meldung, die hinter
* einem Dialog aufgeht, hat niemand gesehen. Dialoge liegen in der obersten
* Ebene des Browsers und bleiben dennoch darüber — deshalb erscheinen
* Meldungen aus einem Dialog weiterhin in seiner eigenen Fehlerzeile. */
.hinweise {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
z-index: 200; display: flex; flex-direction: column; gap: 8px;
align-items: center; pointer-events: none; max-width: min(520px, calc(100vw - 32px));
}
.hinweiszeile {
background: var(--raised); color: var(--text);
border: 1px solid var(--line); border-radius: var(--r-control);
padding: 10px 16px; font-size: 14px; line-height: 1.4;
box-shadow: 0 8px 24px rgba(0, 0, 0, .45);
animation: hinweis-auf .18s ease-out;
}
.hinweiszeile.fehler {
background: #3A1B1E; border-color: rgba(229, 72, 77, .45); color: #FFB3B5;
}
.hinweiszeile.geht { opacity: 0; transform: translateY(6px); transition: all .2s ease-in; }
@keyframes hinweis-auf { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) {
.hinweiszeile { animation: none; }
.hinweiszeile.geht { transition: none; }
}
/* ---------- Mehrfachauswahl in der Mediathek ----------
*
* **Die Marke sitzt oben links**, gegenüber von Haken und Folgenzahl. Beide
* in dieselbe Ecke zu legen hiesse, dass die Auswahl verdeckt, was man
* auswählt. */
.wahlmarke {
position: absolute; top: 8px; left: 8px; z-index: 4;
width: 26px; height: 26px; border-radius: 50%;
display: grid; place-items: center;
background: rgba(10, 10, 12, .62);
border: 2px solid rgba(255, 255, 255, .72);
backdrop-filter: blur(4px);
transition: background .15s ease-out, border-color .15s ease-out;
}
.wahlmarke svg { width: 15px; height: 15px; color: #fff; opacity: 0; transition: opacity .15s; }
.kachel.gewaehlt .wahlmarke {
background: var(--accent); border-color: var(--accent);
}
.kachel.gewaehlt .wahlmarke svg { opacity: 1; }
/* **Im Auswahlmodus kein Abspielknopf und kein Griff.** Beide führten
woandershin, und ein Klick darauf wählt jetzt nur noch aus. */
.raster.auswahl .schweber,
.raster.auswahl .kachel-mehr { display: none; }
.raster.auswahl .kachel { cursor: pointer; }
.raster.auswahl .kachel:hover .plakat { transform: scale(1.02); }
/* Das Gewählte deutlich abheben — Rahmen und leichtes Abdunkeln, damit es
auch ohne Farbwahrnehmung zu erkennen ist. */
.kachel.gewaehlt .plakat {
outline: 3px solid var(--accent); outline-offset: -3px;
}
.kachel.gewaehlt .plakat img { filter: brightness(.72); }
/* **Die Leiste klebt unten**, über dem Inhalt, damit sie beim Scrollen durch
vierhundert Kacheln nicht verschwindet. */
.auswahlleiste {
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
z-index: 90; display: flex; align-items: center; gap: var(--s-regular);
flex-wrap: wrap; justify-content: center;
max-width: min(760px, calc(100vw - 24px));
padding: 10px 16px;
background: var(--elevated); border: 1px solid var(--line);
border-radius: var(--r-karte);
box-shadow: 0 10px 32px rgba(0, 0, 0, .55);
}
.auswahlzahl { font-size: 14px; font-weight: 600; white-space: nowrap; }
@media (max-width: 640px) {
.auswahlleiste { left: 12px; right: 12px; transform: none; max-width: none; }
.auswahlleiste .knopfreihe { width: 100%; }
.auswahlleiste .btn { flex: 1 1 auto; justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
.wahlmarke, .wahlmarke svg { transition: none; }
}
/* **Die Meldung weicht der Auswahlleiste aus.** Beide wollen den unteren
Rand; die Meldung lag zwar davor, verdeckte aber genau die Knöpfe, die man
als Nächstes drückt. */
body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
@media (max-width: 640px) {
body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 148px; }
}
+37
View File
@@ -110,3 +110,40 @@ export function meldung(fehler: unknown): string {
return text;
}
/** Eine kurze Meldung am unteren Rand, die von selbst wieder geht.
*
* **Warum es das braucht.** Eine Aktion aus einem Kontextmenü hat keinen Ort,
* an dem ein Fehler stehen könnte: das Menü ist in dem Augenblick schon zu.
* Vorher verschwand eine abgelehnte Anfrage deshalb spurlos — der Klick tat
* sichtbar nichts, und nichts sagte warum.
*
* **Sie nimmt den Fokus nicht.** `aria-live="polite"` liest sie vor, sobald
* der Benutzer eine Pause macht; ihn mitten im Tun zu unterbrechen wäre für
* eine Randnotiz zu viel.
*/
export function hinweisZeigen(text: string, art: 'gut' | 'fehler' = 'gut'): void {
let bereich = document.querySelector<HTMLElement>('.hinweise');
if (!bereich) {
bereich = document.createElement('div');
bereich.className = 'hinweise';
bereich.setAttribute('aria-live', 'polite');
document.body.appendChild(bereich);
}
const zeile = document.createElement('div');
zeile.className = `hinweiszeile ${art}`;
zeile.textContent = text;
bereich.appendChild(zeile);
// Vier Sekunden: lang genug zum Lesen, kurz genug, um nicht im Weg zu
// stehen. Das Wegblenden darf nicht springen, deshalb erst die Klasse,
// dann das Entfernen nach dem Übergang.
window.setTimeout(() => {
zeile.classList.add('geht');
window.setTimeout(() => {
zeile.remove();
if (bereich && bereich.childElementCount === 0) bereich.remove();
}, 200);
}, 4000);
}
+1 -1
View File
@@ -340,7 +340,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
{ text: 'Titel ändern', icon: 'stift',
tun: () => titeldialog(art, e.id, e.title, () => void laden()) },
{ text: 'Bilder bearbeiten', icon: 'stift',
tun: () => bilddialog(art, e.id, e.title) },
tun: () => bilddialog(art, e.id, e.title, () => void laden()) },
{ text: 'Zuordnung korrigieren', icon: 'suche',
// Ohne Jahr — siehe card.ts: das der falschen Zuordnung grenzt falsch ein.
tun: () => zuordnungsdialog(art, e.id, e.title, null, () => void laden()) },
+65 -17
View File
@@ -4,9 +4,12 @@ import { ApiFehler,
type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand,
type Mitwirkender, aehnliche,
} from '../api';
import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung } from '../util';
import {
esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen,
} from '../util';
import { icons, icon } from '../icons';
import { kachel, alsKachel, reihe } from '../card';
import { vergessen } from '../ansichten';
type Art = 'filme' | 'serien';
@@ -41,7 +44,9 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
wurzel.append(
hintergrund(eintrag.backdropImage),
zurueckKnopf(),
kopfbereich(eintrag, null, null),
// **Mit Zustand.** Hier stand `null`, und darum wusste die
// Knopfreihe einer Serie nie, ob sie abgehakt ist.
kopfbereich(eintrag, eintrag.state, null),
await staffeln(eintrag),
besetzung(eintrag.credits),
zuordnungsbereich(eintrag.title, eintrag.year));
@@ -49,12 +54,45 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
} catch (fehler) {
wurzel.innerHTML = '';
// Am Status, nicht am Text: der Text ist jetzt ein deutscher Satz.
wurzel.appendChild(fehler instanceof ApiFehler && fehler.status === 404
? leer(art === 'filme' ? 'Diesen Film gibt es nicht mehr.' : 'Diese Serie gibt es nicht mehr.')
: fehlerzeile(meldung(fehler)));
if (fehler instanceof ApiFehler && fehler.status === 404) wurzel.appendChild(verschwunden());
else wurzel.appendChild(fehlerzeile(meldung(fehler)));
}
}
/** Was dasteht, wenn es den Titel nicht mehr gibt.
*
* **Vorher war das eine Sackgasse**: ein Satz, sonst nichts. Dabei ist der
* häufigste Grund kein Verlust, sondern ein Zusammenlegen — zwei Einträge
* derselben Serie wurden beim Scan zu einem, und die Liste, aus der man
* gerade kam, zeigte noch die alte Kennung. Der Weg heraus ist, die
* gemerkten Listen wegzuwerfen und zurückzugehen; dann steht der Titel
* unter seiner neuen Kennung wieder da.
*/
function verschwunden(): HTMLElement {
vergessen();
const kasten = document.createElement('div');
kasten.className = 'leer';
const satz = document.createElement('p');
satz.textContent = art === 'filme'
? 'Diesen Film gibt es nicht mehr.'
: 'Diese Serie gibt es nicht mehr.';
const warum = document.createElement('p');
warum.className = 'hinweis';
warum.textContent = 'Entweder wurde der Eintrag beim letzten Scan mit einem '
+ 'anderen zusammengelegt, oder die Dateien dazu sind weg. Die Liste '
+ 'wurde aufgefrischt — unter dem neuen Eintrag ist er vermutlich da.';
const raus = knopf('Zurück zur Liste', 'btn btn-primary');
raus.prepend(icon('links'));
raus.onclick = zurueck;
kasten.append(satz, warum, raus);
return kasten;
}
function zurueckKnopf(): HTMLElement {
const b = knopf('Zurück', 'btn');
b.prepend(icon('links'));
@@ -216,19 +254,22 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
// **Runde Knöpfe ohne Beschriftung, wie bei Plex.** Gesehen, Favorit,
// weglegen — drei Dinge, die man oft tut und nie liest.
if ('files' in eintrag) {
reihe.appendChild(rundKnopf('hakenKreis', stand?.watched ?? false,
stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren',
async () => { await gesehen(eintrag.id, !stand?.watched); await laden(); }));
//
// **Auch bei einer Serie.** Vorher stand hier `'files' in eintrag`, und
// weil eine Serie keine Dateien hat, fehlte auf ihrer Seite jeder dieser
// Knöpfe. Was eine Serie nicht hat, ist eine Stelle — abhaken und zu den
// Favoriten legen lässt sie sich sehr wohl.
reihe.appendChild(rundKnopf('hakenKreis', stand?.watched ?? false,
stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren',
async () => { await gesehen(eintrag.id, !stand?.watched); await laden(); }));
reihe.appendChild(rundKnopf('stern', stand?.favourite ?? false,
stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten',
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
reihe.appendChild(rundKnopf('stern', stand?.favourite ?? false,
stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten',
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) {
reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen',
async () => { await weglegen(eintrag.id); await laden(); }));
}
if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) {
reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen',
async () => { await weglegen(eintrag.id); await laden(); }));
}
const huelle = document.createElement('div');
@@ -244,7 +285,14 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
b.innerHTML = icons[bild];
b.title = titel;
b.setAttribute('aria-label', titel);
b.onclick = async () => { b.disabled = true; try { await tun(); } catch { b.disabled = false; } };
// **Ein leeres `catch` war hier zu wenig.** Scheiterte die Anfrage, ging
// der Knopf wieder an und sonst geschah nichts — ununterscheidbar davon,
// dass man danebengeklickt hat.
b.onclick = async () => {
b.disabled = true;
try { await tun(); }
catch (fehler) { b.disabled = false; hinweisZeigen(meldung(fehler), 'fehler'); }
};
return b;
}
+225 -8
View File
@@ -1,7 +1,10 @@
import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api';
import { leer, fehlerzeile, meldung } from '../util';
import {
inhalt, genresVon, empfehlungen as holeEmpfehlungen, gesehenSammel, metadatenAuffrischen,
istAdmin, type Bibliothek, type Ordnung, type Sammelergebnis,
} from '../api';
import { leer, fehlerzeile, meldung, knopf, hinweisZeigen } from '../util';
import { icons } from '../icons';
import { kachel, alsKachel, reihe } from '../card';
import { kachel, alsKachel, reihe, type Kachel } from '../card';
import { beiRueckkehr } from '../ansichten';
import { auswahl, type Auswahlfeld } from '../auswahl';
@@ -156,13 +159,191 @@ export function mediathek(
};
suchhuelle.appendChild(suchfeld);
// **Mehrere auf einmal.** „Die Staffel hab ich gesehen" hiess bisher
// zwanzigmal Kontextmenü. Der Knopf steht in der Filterzeile, weil er zu
// dem gehört, was man mit der Liste tut — nicht zu einer einzelnen Kachel.
const auswahlKnopf = document.createElement('button');
auswahlKnopf.className = 'zuruecksetzen';
auswahlKnopf.textContent = 'Auswählen';
auswahlKnopf.onclick = () => auswahlmodusSetzen(!auswahlmodus);
zeile.append(genrePille.huelle, standPille.huelle, sortierHuelle,
anzahl, zuruecksetzen, suchhuelle);
anzahl, zuruecksetzen, auswahlKnopf, suchhuelle);
const gitter = document.createElement('div');
gitter.className = 'raster';
ziel.append(zeile, gitter);
/* ---------- Auswahl ---------- */
let auswahlmodus = false;
const gewaehlt = new Set<string>();
/** Was hinter jeder Kachel steht — nach Kennung, für Sammelaktionen. */
const bekannt = new Map<string, Kachel>();
const leiste = auswahlleiste();
ziel.appendChild(leiste.element);
/** Hängt einer Kachel die Wahlmarke an — oder nimmt sie wieder weg. */
function markeAn(k: HTMLElement): void {
const plakat = k.querySelector('.plakat');
if (!plakat || plakat.querySelector('.wahlmarke')) return;
const m = document.createElement('span');
m.className = 'wahlmarke';
m.innerHTML = icons.haken;
plakat.appendChild(m);
}
function auswahlmodusSetzen(an: boolean): void {
auswahlmodus = an;
gitter.classList.toggle('auswahl', an);
auswahlKnopf.textContent = an ? 'Auswahl beenden' : 'Auswählen';
auswahlKnopf.classList.toggle('gesetzt', an);
if (an) {
gitter.querySelectorAll<HTMLElement>('.kachel').forEach(markeAn);
} else {
gewaehlt.clear();
gitter.querySelectorAll('.wahlmarke').forEach(m => m.remove());
gitter.querySelectorAll('.kachel.gewaehlt').forEach(k => {
k.classList.remove('gewaehlt');
k.removeAttribute('aria-pressed');
});
}
leiste.auffrischen(gewaehlt.size, an);
}
// **Im Auswahlmodus fängt das Raster den Klick ab**, bevor er die Kachel
// erreicht. Sonst öffnete jeder Haken zugleich die Detailseite — und der
// Griff mit den drei Punkten ein Kontextmenü obendrein.
gitter.addEventListener('click', e => {
if (!auswahlmodus) return;
const k = (e.target as HTMLElement).closest<HTMLElement>('.kachel');
if (!k || !gitter.contains(k)) return;
e.preventDefault();
e.stopPropagation();
const id = k.dataset.id;
if (!id) return;
const jetztGewaehlt = !gewaehlt.has(id);
if (jetztGewaehlt) gewaehlt.add(id); else gewaehlt.delete(id);
k.classList.toggle('gewaehlt', jetztGewaehlt);
k.setAttribute('aria-pressed', String(jetztGewaehlt));
leiste.auffrischen(gewaehlt.size, true);
}, true);
/** Die Leiste am unteren Rand — nur sichtbar, solange ausgewählt wird. */
function auswahlleiste(): { element: HTMLElement; auffrischen: (zahl: number, an: boolean) => void } {
const b = document.createElement('div');
b.className = 'auswahlleiste hidden';
const zahl = document.createElement('span');
zahl.className = 'auswahlzahl';
const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe';
/** Baut einen Knopf, der die Auswahl bearbeitet und dabei alle sperrt.
*
* **Gesperrt, solange es läuft.** Zweimal „Als gesehen" hintereinander
* wären zwei Sammelanfragen auf denselben Titeln; die zweite käme
* zurück, bevor die erste durch ist, und die Meldung wäre geraten. */
const aktion = (text: string, arbeit: (ids: string[]) => Promise<string>): HTMLButtonElement => {
const b = knopf(text, 'btn');
b.dataset.braucht = 'auswahl';
b.onclick = async () => {
const ids = [...gewaehlt];
if (ids.length === 0) return;
const alle = [...knoepfe.querySelectorAll('button')];
alle.forEach(x => { x.disabled = true; });
try { hinweisZeigen(await arbeit(ids)); }
catch (fehler) { hinweisZeigen(meldung(fehler), 'fehler'); }
finally { alle.forEach(x => { x.disabled = false; }); }
};
return b;
};
knoepfe.append(
aktion('Als gesehen', async ids => {
const ergebnis = await gesehenSammel(ids, true);
nachMarkierung(ids, true);
return satzZurMarkierung(ergebnis, true);
}),
aktion('Als ungesehen', async ids => {
const ergebnis = await gesehenSammel(ids, false);
nachMarkierung(ids, false);
return satzZurMarkierung(ergebnis, false);
}));
// Metadaten holen darf nur, wer die Mediathek verwaltet — der Server
// lehnt es sonst ab, und ein Knopf, der nur ablehnt, ist keiner.
if (istAdmin()) {
knoepfe.appendChild(aktion('Metadaten holen', async ids => {
await metadatenAuffrischen(bibliothek.id, ids);
return `Metadaten werden für ${anzahlwort(ids.length)} geholt. `
+ 'Der Fortschritt steht in der Verwaltung.';
}));
}
// **Abwählen bleibt immer anklickbar** — es ist der Weg heraus.
const wegKnopf = knopf('Abwählen', 'btn');
wegKnopf.onclick = () => auswahlmodusSetzen(false);
knoepfe.appendChild(wegKnopf);
b.append(zahl, knoepfe);
return {
element: b,
auffrischen: (anzahl, an) => {
b.classList.toggle('hidden', !an);
zahl.textContent = anzahl === 0
? 'Nichts ausgewählt'
: `${anzahlwort(anzahl)} ausgewählt`;
knoepfe.querySelectorAll<HTMLButtonElement>('[data-braucht="auswahl"]')
.forEach(x => { x.disabled = anzahl === 0; });
},
};
}
/** Schreibt die Kacheln fort, ohne die Liste neu zu holen.
*
* **Haken oder Zahl, nie beides.** Wird eine Serie abgehakt, verschwindet
* „noch 6 offen"; wird das Häkchen genommen, kommt die Zahl zurück — und
* zwar die volle Folgenzahl, denn genau das ist jetzt offen.
*/
function nachMarkierung(ids: string[], wert: boolean): void {
ids.forEach(id => {
const k = gitter.querySelector<HTMLElement>(`.kachel[data-id="${CSS.escape(id)}"]`);
const plakat = k?.querySelector('.plakat');
if (!k || !plakat) return;
const beschreibung = bekannt.get(id);
if (beschreibung) {
if (beschreibung.stand) beschreibung.stand.watched = wert;
beschreibung.offen = wert ? 0 : (beschreibung.gesamt ?? beschreibung.offen);
}
plakat.querySelector('.ecke')?.remove();
k.querySelector('.fortschritt')?.remove();
if (wert) {
const ecke = document.createElement('span');
ecke.className = 'ecke haken';
ecke.title = 'Gesehen';
ecke.innerHTML = icons.haken;
plakat.appendChild(ecke);
return;
}
const offen = beschreibung?.offen ?? 0;
if (offen <= 0) return;
const plakette = document.createElement('span');
plakette.className = 'ecke zahl';
plakette.textContent = String(offen);
plakette.title = `${offen} Folgen offen`;
plakat.appendChild(plakette);
});
}
// Die Genres der Mediathek nachladen — die Liste steht erst, wenn der
// Server sie genannt hat, und solange bleibt „Alle Genres" allein.
void genresVon(bibliothek.id).then(namen => {
@@ -230,7 +411,7 @@ export function mediathek(
skip: vonVorn ? 0 : geladen, take: SEITE,
});
if (vonVorn) { gitter.innerHTML = ''; geladen = 0; amEnde = false; }
if (vonVorn) { gitter.innerHTML = ''; geladen = 0; amEnde = false; bekannt.clear(); }
gesamt = seite.total;
geladen += seite.items.length;
@@ -242,12 +423,27 @@ export function mediathek(
// was übrig bleibt, nicht was der Server gefunden hat.
const uebrig = gesehen === '' ? seite.items
: seite.items.filter(e => {
const ist = 'state' in e ? e.state?.watched === true
: 'unwatchedCount' in e ? e.unwatchedCount === 0 : false;
// **Eine Serie gilt als gesehen, wenn keine Folge mehr offen
// ist** — oder wenn der Benutzer sie von Hand abgehakt hat und
// die Liste seitdem nicht neu geladen wurde. Eine Serie ganz
// ohne Folgen ist nicht gesehen, sondern leer.
const ist = e.state?.watched === true
|| ('unwatchedCount' in e && e.episodeCount > 0 && e.unwatchedCount === 0);
return gesehen === 'gesehen' ? ist : !ist;
});
uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
uebrig.forEach(e => {
const beschreibung = alsKachel(e);
const k = kachel(beschreibung, oeffnen);
// **Die Beschreibung bleibt greifbar.** Nach einer Sammelmarkierung
// muss die Kachel wissen, wie viele Folgen die Serie hat — sonst
// liesse sich die Plakette „noch 6 offen" nicht wiederherstellen.
bekannt.set(beschreibung.id, beschreibung);
// Nachgeladene Kacheln brauchen die Marke auch — sonst sind die
// ersten zweihundert auswählbar und der Rest nicht.
if (auswahlmodus) markeAn(k);
gitter.appendChild(k);
});
// **Die Zahl sagt, wie viel schon da ist.** „455" über einer Liste mit
// 200 Kacheln wäre eine Behauptung, die man beim Scrollen widerlegt.
@@ -378,3 +574,24 @@ function farbeAus(name: string): string {
for (const z of name) summe = (summe * 31 + z.charCodeAt(0)) % 360;
return `linear-gradient(135deg, hsl(${summe} 42% 28%), hsl(${(summe + 40) % 360} 38% 20%))`;
}
/** „1 Titel" / „14 Titel" — die Eins ohne Ziffer liest sich wie ein Satz. */
function anzahlwort(n: number): string {
return n === 1 ? 'ein Titel' : `${n} Titel`;
}
/** Was nach einer Sammelmarkierung dasteht.
*
* **Auch das, was nicht ging.** Der Server nennt die Kennungen, zu denen er
* nichts fand; sie zu verschweigen hiesse, einen Teilerfolg als vollen
* auszugeben.
*/
function satzZurMarkierung(ergebnis: Sammelergebnis, wert: boolean): string {
const was = wert ? 'als gesehen' : 'als ungesehen';
const kern = ergebnis.changed === 0
? `Nichts geändert — alles war schon ${was} markiert.`
: `${anzahlwort(ergebnis.changed)} ${was} markiert.`;
return ergebnis.unknown.length === 0
? kern
: `${kern} ${anzahlwort(ergebnis.unknown.length)} gibt es nicht mehr.`;
}