Neun gemeldete Fehler, und die Frage, welcher Build gerade läuft

Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare
Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne
Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand
läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im
Quelltext stehen.

Version und Build. Die Version steht fest; daneben eine Kennung aus
sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der
Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist
"reden wir vom selben Build?" in zwei Sekunden beantwortet statt in
einem halben Tag.

Serien. Lag eine Datei direkt im Serienordner und trug vor der
Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95
Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief
der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab
zwei Serien. Und weder neue noch umgezogene Dateien standen in der
Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite
Zeile mit gleichem (LibraryId, Path) anlegte und der
Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede
Serienanlage wird jetzt im Tagesprotokoll begründet.

Fehlermeldungen. "An error occurred while saving the entity changes"
stand englisch in einer deutschen Oberfläche und nannte weder Ursache
noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll
und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für
"Failed to fetch", das aus dem Browser kam und nie übersetzt wurde.

Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen
davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt
serverseitig rückwärts, mit einem Knopf für mehr.

Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein
helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein
eigenes Listenfeld mit Tastaturbedienung.

Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess
zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp.

Sprache. Die Serversprache stand in der Serverliste zwischen Name,
Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen
eigenen Abschnitt ganz oben.

Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne
Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden,
ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer
Zuordnung frischt nur noch die Kachel auf statt die ganze Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 15:55:56 +02:00
co-authored by Claude Opus 5
parent ab8a66d898
commit ac04901ccc
34 changed files with 1583 additions and 255 deletions
+55 -4
View File
@@ -113,7 +113,10 @@ export type Vorschlag = {
};
export type Hinweis = { text: string; logoUrl: string; url: string };
export type Systemauskunft = {
version: string; serverName: string | null;
version: string;
/** Die Kennung dieses Abbilds — bei jedem Bau neu, „DEV" ausserhalb eines Containers. */
build: string;
serverName: string | null;
needsSetup: boolean; attributions: Hinweis[];
};
@@ -169,6 +172,12 @@ export const meineSprache = (): string | null => sitzung.wer?.language ?? null;
/* ---------- Anfragen ---------- */
export class ApiFehler extends Error {
// **Der Name wird gesetzt, nicht geerbt.** Nach dem Bündeln heisst die
// Klasse anders, und `instanceof` über Modulgrenzen ist nicht verlässlich;
// `name` überlebt beides. Daran erkennt `meldung()`, dass der Text vom
// Server stammt und bereits deutsch ist.
override readonly name = 'ApiFehler';
constructor(public status: number, nachricht: string) { super(nachricht); }
}
@@ -182,7 +191,24 @@ async function anfrage<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
kopf.set('Content-Type', 'application/json');
}
const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
// **Einmal nachfassen, bevor gemeldet wird.**
//
// Während eines Scans über tausende Dateien kam es vor, dass eine Anfrage
// ins Leere lief und der Bilddialog mit „Failed to fetch" stehenblieb —
// beim zweiten Versuch von Hand ging es sofort. Ein einzelner Aussetzer ist
// kein Fehler, den der Benutzer sehen muss.
//
// Nur bei einem Netzfehler und nur bei lesenden Anfragen: ein zweites POST
// könnte dasselbe zweimal anlegen, und das wäre schlimmer als die Meldung.
const lesend = !opts.method || opts.method.toUpperCase() === 'GET';
let antwort: Response;
try {
antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
} catch (aussetzer) {
if (!lesend || !(aussetzer instanceof TypeError)) throw aussetzer;
await new Promise(r => setTimeout(r, 400));
antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
}
// **Eine 401 ist noch kein Grund, die Anmeldung wegzuwerfen.**
//
@@ -580,17 +606,42 @@ export const zurueckziehen = (ids: string[]) =>
export const reihenfolgeSetzen = (ids: string[]) =>
anfrage<void>('/libraries/order', { method: 'PUT', body: JSON.stringify({ ids }) });
/** Wirft den erfassten Inhalt einer Mediathek weg — die Dateien bleiben liegen.
*
* **Wofür.** Steht nach einer falschen Zuordnungsregel ein Bestand da, den kein
* Scan mehr geradezieht, ist das der kurze Weg: die Einträge weg, einmal neu
* scannen. Mediathek, Pfad und Einstellungen bleiben.
*/
export const mediathekZuruecksetzen = (bibliothek: string) =>
anfrage<void>(`/libraries/${bibliothek}/reset`, { method: 'POST' });
export const pruefungSetzen = (bibliothek: string, an: boolean) =>
anfrage<void>(`/libraries/${bibliothek}/review`,
{ method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) });
export type Speicherort = {
name: string; variable: string; pfad: string;
belegt: number; frei: number | null; bilder: number | null;
};
/** Wo der Server seine Dateien ablegt und wie viel davon belegt ist. */
export const speicherorte = () => anfrage<Speicherort[]>('/system/storage');
/** Leert den Bildspeicher. Ungefährlich — Bilder werden bei Bedarf neu geholt. */
export const bildspeicherLeeren = () =>
anfrage<{ freed: number }>('/system/storage/images', { method: 'DELETE' });
export type Protokolltag = { date: string; bytes: number };
/** Welche Tage protokolliert sind, neueste zuerst. */
export const protokolltage = () => anfrage<Protokolltag[]>('/system/logs');
/** Die Adresse, unter der ein Protokolltag liegt — zum Lesen und Herunterladen. */
export const protokollAdresse = (tag: string) => `/api/v1/system/logs/${tag}`;
/** Die Adresse eines Protokolltags.
*
* Mit `zeilen` kommt nur das Ende — das ist, was die Ansicht braucht. Ohne
* kommt alles, und das ist der Weg zum Herunterladen. */
export const protokollAdresse = (tag: string, zeilen?: number) =>
`/api/v1/system/logs/${tag}` + (zeilen ? `?tail=${zeilen}` : '');
export type VermissteDatei = {
fileId: string; path: string; versionLabel: string | null;
+186
View File
@@ -0,0 +1,186 @@
import { icons } from './icons';
export type Option = { wert: string; text: string };
/** Ein Auswahlfeld, das aussieht wie der Rest der Oberfläche.
*
* **Warum nicht `<select>`.** Dessen Liste zeichnet das Betriebssystem, und
* zwar in seinem eigenen Stil: auf einem Mac erscheint mitten in einer
* dunklen Oberfläche ein helles Systemmenü mit abgerundeten Ecken und
* blauer Markierung. Es lässt sich nicht gestalten — kein Rahmen, keine
* Farbe, keine Radien. In einer Oberfläche, die sonst durchgehend eine
* Formensprache spricht, ist es der eine Fleck, der verrät, dass hier nichts
* aufeinander abgestimmt wurde.
*
* **Was dafür selbst gebaut werden muss.** Ein `<select>` bringt viel mit,
* das man leicht vergisst: Tastaturbedienung, Rollen für Hilfsmittel, das
* Schliessen beim Klick daneben. Steht alles unten — ohne das wäre der
* Tausch eine Verschlechterung, nur eine hübschere.
*/
export type Auswahlfeld = {
/** Was in die Seite gehängt wird. */
element: HTMLButtonElement;
/** Der gewählte Wert — wie bei `select.value`. */
wert: string;
/** Wird bei jeder Änderung durch den Benutzer gerufen, nicht beim Setzen. */
beiWechsel: (fn: () => void) => void;
/** Tauscht die Liste aus — für Werte, die erst nachgeladen werden. */
setzen: (optionen: Option[]) => void;
};
export function auswahl(optionen: Option[], vorgabe = ''): Auswahlfeld {
let liste = [...optionen];
let wert = vorgabe;
let melden: (() => void) | null = null;
const knopf = document.createElement('button');
knopf.type = 'button';
knopf.className = 'auswahl';
knopf.setAttribute('aria-haspopup', 'listbox');
knopf.setAttribute('aria-expanded', 'false');
const beschriftung = document.createElement('span');
const pfeil = document.createElement('span');
pfeil.className = 'auswahl-pfeil icon';
pfeil.innerHTML = icons.runter;
knopf.append(beschriftung, pfeil);
const beschriften = () => {
beschriftung.textContent = liste.find(o => o.wert === wert)?.text ?? liste[0]?.text ?? '';
};
beschriften();
let offen: HTMLElement | null = null;
function schliessen(): void {
offen?.remove();
offen = null;
knopf.setAttribute('aria-expanded', 'false');
}
function waehlen(neu: string): void {
const geaendert = neu !== wert;
wert = neu;
beschriften();
schliessen();
knopf.focus();
if (geaendert) melden?.();
}
function oeffnen(): void {
if (offen) { schliessen(); return; }
const box = document.createElement('div');
box.className = 'auswahl-liste';
box.setAttribute('role', 'listbox');
liste.forEach(o => {
const e = document.createElement('button');
e.type = 'button';
e.className = 'auswahl-punkt' + (o.wert === wert ? ' gewaehlt' : '');
e.setAttribute('role', 'option');
e.setAttribute('aria-selected', String(o.wert === wert));
e.textContent = o.text;
e.onclick = () => waehlen(o.wert);
box.appendChild(e);
});
// **Die Tasten müssen auch in der Liste wirken.** Nach dem Öffnen sitzt
// der Fokus auf einem Eintrag, nicht mehr auf dem Knopf — Escape lief
// dort ins Leere, und die Liste blieb offen stehen.
box.onkeydown = e => {
if (e.key === 'Escape') { e.preventDefault(); schliessen(); knopf.focus(); return; }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
bewegen(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
const punkte = [...box.querySelectorAll<HTMLElement>('.auswahl-punkt')];
(e.key === 'Home' ? punkte[0] : punkte[punkte.length - 1])?.focus();
return;
}
// Tab verlässt die Liste — dann hat sie dort nichts mehr zu suchen.
if (e.key === 'Tab') schliessen();
};
document.body.appendChild(box);
offen = box;
knopf.setAttribute('aria-expanded', 'true');
// **Erst anhängen, dann messen.** Vorher hat die Liste keine Grösse, und
// die Prüfung auf den Bildschirmrand liefe gegen null. Dasselbe Vorgehen
// wie im Kontextmenü, und aus demselben Grund.
const ab = knopf.getBoundingClientRect();
const eigen = box.getBoundingClientRect();
box.style.minWidth = `${ab.width}px`;
box.style.left = `${Math.max(8, Math.min(ab.left, window.innerWidth - eigen.width - 8))}px`;
box.style.top = ab.bottom + eigen.height + 8 > window.innerHeight
? `${Math.max(8, ab.top - eigen.height - 4)}px`
: `${ab.bottom + 4}px`;
box.querySelector<HTMLElement>('.gewaehlt, .auswahl-punkt')?.focus();
}
knopf.onclick = e => { e.stopPropagation(); oeffnen(); };
// **Die Tastatur, die ein `select` mitbrachte.** Hoch und runter wechseln
// auch ohne geöffnete Liste — genau so verhält sich das Original, und wer
// es gewohnt ist, merkt den Tausch sonst sofort.
knopf.onkeydown = e => {
const stelle = liste.findIndex(o => o.wert === wert);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (offen) { bewegen(e.key === 'ArrowDown' ? 1 : -1); return; }
const ziel = liste[Math.min(liste.length - 1, Math.max(0, stelle + (e.key === 'ArrowDown' ? 1 : -1)))];
if (ziel) waehlen(ziel.wert);
return;
}
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
const ziel = e.key === 'Home' ? liste[0] : liste[liste.length - 1];
if (ziel) waehlen(ziel.wert);
return;
}
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oeffnen(); return; }
if (e.key === 'Escape') { schliessen(); return; }
// Anfangsbuchstabe — springt zum nächsten passenden Eintrag.
if (e.key.length === 1 && /\S/.test(e.key)) {
const zeichen = e.key.toLowerCase();
const ab = stelle + 1;
const treffer = [...liste.slice(ab), ...liste.slice(0, ab)]
.find(o => o.text.toLowerCase().startsWith(zeichen));
if (treffer) waehlen(treffer.wert);
}
};
/** Pfeiltasten in der offenen Liste. */
function bewegen(richtung: 1 | -1): void {
if (!offen) return;
const punkte = [...offen.querySelectorAll<HTMLElement>('.auswahl-punkt')];
const jetzt = punkte.indexOf(document.activeElement as HTMLElement);
const ziel = punkte[Math.min(punkte.length - 1, Math.max(0, jetzt + richtung))];
ziel?.focus();
}
// Ein Klick daneben, Escape oder ein Bildlauf schliessen — wie beim
// Kontextmenü. Je Feld registriert und nicht global: es gibt wenige davon,
// und beim Entfernen des Feldes gehen sie mit.
document.addEventListener('click', e => {
if (offen && !offen.contains(e.target as Node) && e.target !== knopf) schliessen();
});
window.addEventListener('scroll', () => schliessen(), { passive: true, capture: true });
return {
element: knopf,
get wert() { return wert; },
set wert(neu: string) { wert = neu; beschriften(); },
beiWechsel: fn => { melden = fn; },
setzen: neue => { liste = [...neue]; beschriften(); },
};
}
+53 -16
View File
@@ -1,5 +1,5 @@
import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api';
import { knopf, fehlerzeile } from './util';
import { knopf, fehlerzeile, meldung } from './util';
import { icons } from './icons';
type Bildart = 'poster' | 'backdrop' | 'logo';
@@ -43,6 +43,9 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
document.body.appendChild(d);
d.showModal();
/** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */
let offen: Bildart = 'poster';
void laden();
async function laden(): Promise<void> {
@@ -51,7 +54,7 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
auswahl = await bildauswahl(art, id);
} catch (fehler) {
koerper.innerHTML = '';
koerper.appendChild(fehlerzeile((fehler as Error).message));
koerper.appendChild(fehlerzeile(meldung(fehler)));
return;
}
@@ -66,14 +69,51 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
koerper.appendChild(hinweis);
}
// **Ein Reiter je Bildtyp statt drei Blöcke untereinander.**
//
// Vorher scrollte der Dialogkörper, und jedes der drei Raster darin
// scrollte noch einmal. Zwei Ebenen übereinander heisst: das Rad trifft
// mal die eine, mal die andere, je nachdem wo der Zeiger steht — und man
// landet nie dort, wo man hinwollte. Jetzt ist genau ein Typ sichtbar und
// genau eine Fläche scrollt.
const reiter = document.createElement('div');
reiter.className = 'bildreiter';
reiter.setAttribute('role', 'tablist');
const buehne = document.createElement('div');
buehne.className = 'bildbuehne';
koerper.append(reiter, buehne);
const bloecke = new Map<Bildart, HTMLElement>();
ARTEN.forEach(({ art: bildart, titel: name, verhaeltnis }) => {
const aktuell = bildart === 'poster' ? auswahl.current.poster
: bildart === 'backdrop' ? auswahl.current.backdrop
: auswahl.current.logo;
const passende = auswahl.candidates.filter(k => k.kind === bildart);
const knopfReiter = document.createElement('button');
knopfReiter.type = 'button';
knopfReiter.className = 'bildreiter-knopf' + (bildart === offen ? ' an' : '');
knopfReiter.setAttribute('role', 'tab');
knopfReiter.setAttribute('aria-selected', String(bildart === offen));
// Die Zahl dazu: so sieht man vor dem Klicken, ob es dort etwas gibt.
knopfReiter.innerHTML = `<span>${name}</span>`
+ `<span class="winzig">${passende.length}</span>`;
knopfReiter.onclick = () => { offen = bildart; void laden(); };
reiter.appendChild(knopfReiter);
if (bildart !== offen) return;
const abschnitt = document.createElement('section');
abschnitt.className = 'bildblock';
const kopfzeile = document.createElement('div');
kopfzeile.className = 'knopfreihe';
const u = document.createElement('h3');
u.textContent = name;
u.textContent = aktuell ? `${name} — eines ist gesetzt` : name;
kopfzeile.appendChild(u);
// Eigenes Bild — ein verstecktes Dateifeld hinter einem Knopf, weil
@@ -91,37 +131,34 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
eigenes.disabled = true;
try { await bildHochladen(art, id, bildart, datei); await laden(); }
catch (fehler) {
abschnitt.appendChild(fehlerzeile((fehler as Error).message));
abschnitt.appendChild(fehlerzeile(meldung(fehler)));
eigenes.disabled = false;
}
};
kopfzeile.append(eigenes, feld);
abschnitt.appendChild(kopfzeile);
const gitter = document.createElement('div');
gitter.className = 'bildgitter';
gitter.style.setProperty('--verhaeltnis', verhaeltnis);
const aktuell = bildart === 'poster' ? auswahl.current.poster
: bildart === 'backdrop' ? auswahl.current.backdrop
: auswahl.current.logo;
const passende = auswahl.candidates.filter(k => k.kind === bildart);
if (passende.length === 0 && !aktuell) {
const leer = document.createElement('p');
leer.className = 'winzig';
leer.textContent = 'Nichts zur Auswahl.';
abschnitt.append(leer);
koerper.appendChild(abschnitt);
buehne.appendChild(abschnitt);
bloecke.set(bildart, abschnitt);
return;
}
const gitter = document.createElement('div');
gitter.className = 'bildgitter';
gitter.style.setProperty('--verhaeltnis', verhaeltnis);
// Das gesetzte Bild zuerst, damit sichtbar ist, wovon man weggeht.
if (aktuell) gitter.appendChild(kachelFuer(null, bildAdresse(aktuell)!, true, bildart));
passende.forEach(k => gitter.appendChild(kachelFuer(k, k.thumbUrl, false, bildart)));
abschnitt.appendChild(gitter);
koerper.appendChild(abschnitt);
buehne.appendChild(abschnitt);
bloecke.set(bildart, abschnitt);
});
}
@@ -154,7 +191,7 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
b.disabled = true;
try { await bildSetzen(art, id, bildart, kandidat!.url); await laden(); }
catch (fehler) {
b.after(fehlerzeile((fehler as Error).message));
b.after(fehlerzeile(meldung(fehler)));
b.disabled = false;
}
};
+40 -2
View File
@@ -1,5 +1,5 @@
import {
bildAdresse, gesehen, favorit, weglegen, istAdmin,
bildAdresse, gesehen, favorit, weglegen, istAdmin, film, serie,
type Film, type Serie, type Angefangen, type Stand,
} from './api';
import { esc, anteil } from './util';
@@ -274,13 +274,51 @@ function kachelpunkte(
// erkannte Titel vor sich hat, soll sie hier geradeziehen können.
tun: () => zuordnungsdialog(art, k.id, k.titel,
k.zweite && /^\d{4}$/.test(k.zweite) ? Number(k.zweite) : null,
() => location.reload()),
// **Nur die Kachel, nicht die Seite.** `location.reload()` warf
// Bildlauf, Filter und offene Reiter weg — für einen Titel, der sich
// geändert hat. Bei zwanzig Korrekturen hintereinander ist das
// zwanzigmal von vorn anfangen.
() => void kachelAuffrischen(k, knopf)),
});
}
return punkte;
}
/** Holt einen Titel neu und schreibt Beschriftung und Plakat fort.
*
* **Warum nicht die ganze Liste.** Nach einer Korrektur ändert sich genau ein
* Eintrag. Alles neu zu laden kostet einen Netzabruf über mehrere hundert
* Titel und setzt die Ansicht an den Anfang zurück.
*/
async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise<void> {
let frisch: Film | Serie;
try { frisch = k.art === 'serien' ? await serie(k.id) : await film(k.id); }
catch { return; } // Still bleiben: die alte Kachel ist besser als eine Meldung.
k.titel = frisch.title;
k.zweite = frisch.year ? String(frisch.year) : null;
k.bild = frisch.posterImage;
const text = knopf.querySelector('.kacheltext');
if (text) {
text.innerHTML = `<strong>${esc(k.titel)}</strong>`
+ (k.zweite ? `<span>${esc(k.zweite)}</span>` : '');
}
const plakat = knopf.querySelector('.plakat');
const adresse = bildAdresse(k.bild);
plakat?.querySelector('img')?.remove();
plakat?.classList.remove('geladen');
if (plakat && adresse) {
const bild = document.createElement('img');
bild.src = adresse;
bild.alt = '';
bild.onload = () => plakat.classList.add('geladen');
plakat.prepend(bild);
}
}
/** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen. */
function hakenZeigen(knopf: HTMLElement, an: boolean): void {
const plakat = knopf.querySelector('.plakat');
+14 -17
View File
@@ -1,6 +1,7 @@
import { icons } from './icons';
import { knopf } from './util';
import { knopf, meldung } from './util';
import { ordner, type Ordnerstand } from './api';
import { auswahl, type Auswahlfeld } from './auswahl';
/* Dialoge über dem Inhalt statt Blöcken darin.
*
@@ -71,7 +72,7 @@ export function dialog(optionen: {
koerper.appendChild(p);
}
const eingaben = new Map<string, HTMLInputElement | HTMLSelectElement>();
const eingaben = new Map<string, HTMLInputElement | Auswahlfeld>();
(optionen.felder ?? []).forEach(f => {
// **Ein `<label>` nur dort, wo genau ein Bedienelement darin sitzt.**
@@ -86,16 +87,13 @@ export function dialog(optionen: {
block.appendChild(titel);
if (f.auswahl) {
const wahl = document.createElement('select');
wahl.className = 'feld';
f.auswahl.forEach(o => {
const e = document.createElement('option');
e.value = o.wert; e.textContent = o.text;
wahl.appendChild(e);
});
if (f.wert) wahl.value = f.wert;
// **Eigenes Listenfeld statt `select`.** Dessen Liste zeichnet das
// Betriebssystem; mitten im dunklen Dialog erschien ein helles
// Systemmenü, das sich nicht gestalten lässt.
const wahl = auswahl(f.auswahl, f.wert ?? f.auswahl[0]?.wert ?? '');
wahl.element.classList.add('feld');
eingaben.set(f.name, wahl);
block.appendChild(wahl);
block.appendChild(wahl.element);
} else if (f.schalter) {
const e = document.createElement('input');
e.type = 'checkbox';
@@ -163,9 +161,8 @@ export function dialog(optionen: {
los.disabled = true;
const gesammelt = new Map<string, string>();
eingaben.forEach((e, name) => {
gesammelt.set(name, e instanceof HTMLInputElement && e.type === 'checkbox'
? String(e.checked)
: e.value.trim());
if (!(e instanceof HTMLInputElement)) { gesammelt.set(name, e.wert); return; }
gesammelt.set(name, e.type === 'checkbox' ? String(e.checked) : e.value.trim());
});
try {
@@ -177,7 +174,7 @@ export function dialog(optionen: {
// schreiben, wo gerade niemand hinsieht.
const m = document.createElement('div');
m.className = 'fehler';
m.textContent = (fehler as Error).message;
m.textContent = meldung(fehler);
fehlerplatz.appendChild(m);
los.disabled = false;
}
@@ -279,7 +276,7 @@ function ordnerfeld(eingabe: HTMLInputElement): HTMLElement {
} catch (fehler) {
if (dieser !== lauf) return;
liste.innerHTML = '';
liste.appendChild(hinweiszeile(`Konnte nicht gelesen werden: ${(fehler as Error).message}`));
liste.appendChild(hinweiszeile(`Konnte nicht gelesen werden: ${meldung(fehler)}`));
return;
} finally {
window.clearTimeout(langsam);
@@ -294,7 +291,7 @@ function ordnerfeld(eingabe: HTMLInputElement): HTMLElement {
// **Auch ein Fehler beim Zeichnen muss sichtbar werden.** Vorher blieb
// dann der Spinner stehen, und es sah aus, als käme keine Antwort.
liste.innerHTML = '';
liste.appendChild(hinweiszeile(`Anzeige gescheitert: ${(fehler as Error).message}`));
liste.appendChild(hinweiszeile(`Anzeige gescheitert: ${meldung(fehler)}`));
}
}
+1
View File
@@ -22,6 +22,7 @@ export const icons = {
+ '<circle cx="12" cy="19" r="1.4"/>', true),
/** Winkel nach oben — eine Ebene zurück im Baum. */
hoch: rahmen('<path d="m6 14 6-6 6 6"/>'),
runter: rahmen('<path d="m6 10 6 6 6-6"/>'),
/** Filmstreifen — für eine Filmmediathek. */
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
+2 -2
View File
@@ -12,7 +12,7 @@ import { mediathek } from './views/library';
import { detailseite } from './views/detail';
import { verwaltung } from './views/admin';
import { suchseite } from './views/search';
import { esc } from './util';
import { esc, meldung } from './util';
const anmeldeflaeche = document.getElementById('login')!;
const kopfleiste = document.getElementById('topbar')!;
@@ -55,7 +55,7 @@ async function hochfahren(): Promise<void> {
// 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="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');
+74 -15
View File
@@ -261,23 +261,53 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
.pille {
position: relative; display: inline-flex; align-items: center;
}
.pille select {
appearance: none; -webkit-appearance: none;
background: none; border: 0; outline: none; cursor: pointer;
color: var(--text-dim); font-weight: 500;
padding: 6px 26px 6px 11px; border-radius: var(--r-pill);
.pille .auswahl {
background: none; border: 0; color: var(--text-dim); font-weight: 500;
padding: 6px 11px; border-radius: var(--r-pill);
}
.pille select:hover { background: var(--elevated); color: var(--text); }
.pille select:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
.pille .auswahl:hover { background: var(--elevated); color: var(--text); }
/* Gesetzt heisst farbig — so sieht man ohne Lesen, dass gefiltert wird. */
.pille.gesetzt select { color: var(--accent); }
.pille::after {
content: ''; position: absolute; right: 11px; top: 50%;
width: 6px; height: 6px; margin-top: -4px;
border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor;
transform: rotate(45deg); color: var(--text-faint); pointer-events: none;
.pille.gesetzt .auswahl { color: var(--accent); }
/* ---------- Auswahlfeld ----------
*
* Ersetzt <select>. Dessen Liste zeichnet das Betriebssystem: mitten in der
* dunklen Oberfläche erschien ein helles Systemmenü, das sich weder färben
* noch runden lässt. Die Liste hier ist dieselbe Fläche wie das Kontextmenü
* — gleicher Rahmen, gleiche Radien, gleicher Schatten.
*/
.auswahl {
display: inline-flex; align-items: center; gap: 6px;
background: var(--elevated); border: 1px solid var(--line);
border-radius: var(--r-knopf); cursor: pointer;
color: var(--text-dim); font-size: var(--t-normal); font-weight: 500;
padding: 7px 10px; text-align: left;
}
.pille.gesetzt::after { color: var(--accent); }
.auswahl:hover { color: var(--text); }
.auswahl > span:first-child {
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.auswahl-pfeil { display: inline-flex; color: var(--text-faint); }
.auswahl-pfeil svg { width: 15px; height: 15px; }
.auswahl[aria-expanded='true'] .auswahl-pfeil { transform: rotate(180deg); }
.auswahl-liste {
position: fixed; z-index: 120;
max-height: min(50vh, 360px); overflow-y: auto;
padding: var(--s-tight);
background: var(--elevated);
border: 1px solid var(--line); border-radius: var(--r-flaeche);
box-shadow: var(--schatten-dialog);
display: flex; flex-direction: column;
}
.auswahl-punkt {
background: none; border: 0; border-radius: var(--r-knopf);
padding: 8px var(--s-regular); text-align: left; white-space: nowrap;
color: var(--text-dim); font-size: var(--t-normal);
}
.auswahl-punkt:hover { background: var(--raised); color: var(--text); }
/* Das Gewählte trägt die Akzentfarbe — ohne Haken, der die Breite verschiebt. */
.auswahl-punkt.gewaehlt { color: var(--accent); font-weight: 600; }
.filterzeile .zuruecksetzen {
background: none; border: 0; padding: 6px 4px;
@@ -612,6 +642,9 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
display: flex; flex-direction: column; gap: var(--s-loose);
max-height: 60vh; overflow-y: auto;
}
/* Im Bilddialog ist die Höhe fest: so springt die Fläche beim Reiterwechsel
* nicht, und der Reiter bleibt an derselben Stelle. */
.dlg-breit .dlg-koerper { height: 62vh; max-height: none; }
.dlg-feld { display: flex; flex-direction: column; gap: var(--s-snug); }
.dlg-feld.waagerecht {
flex-direction: row; align-items: center; gap: var(--s-snug); cursor: pointer;
@@ -903,10 +936,36 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
.bildblock { display: flex; flex-direction: column; gap: var(--s-snug); }
.bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; }
/* ---------- Bildwahl: ein Reiter je Typ ----------
*
* Vorher standen Plakat, Hintergrund und Schriftzug untereinander: der
* Dialogkörper scrollte, und jedes Raster darin scrollte noch einmal. Wo das
* Rad landet, hing davon ab, über welcher Fläche der Zeiger gerade stand.
* Jetzt ist ein Typ sichtbar, und genau eine Fläche scrollt.
*/
.bildreiter {
display: flex; gap: var(--s-tight);
border-bottom: 1px solid var(--line);
margin: calc(var(--s-loose) * -1) 0 0;
position: sticky; top: 0; z-index: 2; background: var(--surface);
}
.bildreiter-knopf {
display: inline-flex; align-items: baseline; gap: 6px;
background: none; border: 0; border-bottom: 2px solid transparent;
padding: 10px var(--s-regular); margin-bottom: -1px;
color: var(--text-muted); font-size: var(--t-normal); font-weight: 600;
}
.bildreiter-knopf:hover { color: var(--text); }
.bildreiter-knopf.an { color: var(--text); border-bottom-color: var(--accent); }
.bildreiter-knopf .winzig { color: var(--text-faint); }
/* **Die eine scrollende Fläche.** Der Dialogkörper selbst wächst nicht mehr
* mit, sonst wären es wieder zwei. */
.bildbuehne { display: flex; flex-direction: column; min-height: 220px; }
.bildgitter {
display: grid; gap: var(--s-snug);
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
max-height: 260px; overflow-y: auto;
padding: 2px;
}
+47
View File
@@ -63,3 +63,50 @@ export function leer(text: string): HTMLElement {
d.textContent = text;
return d;
}
/** Was bei einem Fehler in der Oberfläche steht — immer deutsch.
*
* **Warum nicht einfach `fehler.message`.** Das stand überall, und überall
* kam dabei Englisch heraus: „Failed to fetch" vom Browser, „An error
* occurred while saving the entity changes" aus der Datenbankschicht. Ein
* Server, der auf Deutsch antwortet, darf das nicht durchreichen.
*
* **Der Server schreibt seine Sätze selbst.** Kommt die Meldung von dort,
* ist sie bereits deutsch und sagt, was zu tun ist — die wird übernommen.
* Alles andere entsteht hier, weil es im Browser passiert ist und der
* Server davon nichts weiss.
*/
export function meldung(fehler: unknown): string {
// Eine Antwort des Servers: sein Text gilt.
if (fehler instanceof Error && fehler.name === 'ApiFehler' && fehler.message) {
return fehler.message;
}
// **Die Verbindung kam nicht zustande.** `fetch` wirft dafür einen
// `TypeError` — und zwar in jedem Browser mit anderem Wortlaut: „Failed to
// fetch", „Load failed", „NetworkError when attempting to fetch resource".
if (fehler instanceof TypeError) {
return 'Der Server war nicht erreichbar. Läuft er noch, und steht die Verbindung?';
}
if (fehler instanceof DOMException && fehler.name === 'AbortError') {
return 'Der Vorgang wurde abgebrochen.';
}
const text = fehler instanceof Error ? fehler.message : String(fehler ?? '');
// Eine reine Statuszahl ist keine Meldung.
if (/^\d{3}$/.test(text.trim())) {
return `Der Server hat die Anfrage abgelehnt (${text.trim()}). `
+ 'Die Einzelheiten stehen im Protokoll von heute.';
}
// **Englisch bleibt draussen.** Was hier ankommt und keine deutschen
// Umlaute oder typischen Wörter trägt, stammt fast sicher aus einer
// Laufzeitbibliothek — das hilft niemandem weiter.
if (text.length === 0 || !/[äöüßÄÖÜ]|\b(der|die|das|ist|wurde|nicht|kein|keine)\b/i.test(text)) {
return 'Etwas ist schiefgegangen. Die Einzelheiten stehen im Protokoll von heute.';
}
return text;
}
+304 -102
View File
@@ -4,14 +4,15 @@ import {
vermisste, vermisstesEntfernen, type VermissteDatei,
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
mediathekFreigeben, reihenfolgeSetzen,
mediathekFreigeben, reihenfolgeSetzen, mediathekZuruecksetzen,
type Pruefeintrag,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
servernameSetzen, systemauskunft,
laufendeWiedergaben, serverzustand, bildAdresse,
type Bibliothek, type Wiedergabe,
speicherorte, bildspeicherLeeren,
type Bibliothek, type Wiedergabe, ApiFehler,
} from '../api';
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../util';
import { dialog, nachfragen } from '../dialog';
import { icon, icons } from '../icons';
@@ -29,9 +30,9 @@ export function verwaltung(
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const wurzel = document.createElement('div');
wurzel.append(dashboard(signal), pruefbereich(geaendert), vermisstenbereich(geaendert),
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert),
protokollbereich());
wurzel.append(dashboard(signal), sprachbereich(), pruefbereich(geaendert),
vermisstenbereich(geaendert), bibliotheksbereich(geaendert, signal), benutzerbereich(),
serverbereich(geaendert), speicherbereich(), protokollbereich());
return wurzel;
}
@@ -99,7 +100,7 @@ function dashboard(signal: AbortSignal): HTMLElement {
zustand.textContent = teile.join(' · ');
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
liste.appendChild(fehlerzeile(meldung(fehler)));
return; // Nicht weiter nachfragen, wenn es scheitert.
}
// **Nur weitermachen, solange die Ansicht hängt.** Ohne diese Zeile
@@ -238,7 +239,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
await laden();
geaendert();
} catch (fehler) {
liste.prepend(fehlerzeile((fehler as Error).message));
liste.prepend(fehlerzeile(meldung(fehler)));
} finally {
freigeben.disabled = false;
planen.disabled = false;
@@ -316,7 +317,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
nur.onclick = async () => {
nur.disabled = true;
try { await veroeffentlichen(e.ids); await laden(); geaendert(); }
catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); nur.disabled = false; }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
};
const oben = document.createElement('div');
@@ -328,7 +329,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
zurueck.onclick = async () => {
zurueck.disabled = true;
try { await zurueckziehen(e.ids); await laden(); }
catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); zurueck.disabled = false; }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); zurueck.disabled = false; }
};
oben.appendChild(zurueck);
}
@@ -375,6 +376,11 @@ function protokollbereich(): HTMLElement {
anzeige.className = 'protokoll hidden';
bereich.appendChild(anzeige);
// **Blättern statt alles auf einmal.** Die ersten 800 Zeilen beantworten
// fast jede Frage; wer weiter zurück muss, verdoppelt mit diesem Knopf.
const mehr = knopf('Mehr laden', 'btn hidden');
bereich.appendChild(mehr);
void (async () => {
let tage;
try { tage = await protokolltage(); } catch { bereich.classList.add('hidden'); return; }
@@ -416,25 +422,44 @@ function protokollbereich(): HTMLElement {
});
})();
async function zeigen(tag: string, nurFehler: boolean): Promise<void> {
/** Wie viele Zeilen zuletzt geholt wurden — der Knopf „Mehr" verdoppelt. */
let gezeigt = 0;
async function zeigen(tag: string, nurFehler: boolean, zeilen = 0): Promise<void> {
// **Nur das Ende holen, und davon anfangs wenig.** Vorher kam die ganze
// Datei, und davon wurden 95 % weggeworfen — bei zwei Megabyte über eine
// wackelige Verbindung endete das in „Failed to fetch". Bei der
// Fehlersuche mehr, weil davon die meisten weggefiltert werden.
gezeigt = zeilen > 0 ? zeilen : (nurFehler ? 5000 : 800);
anzeige.classList.remove('hidden');
anzeige.textContent = 'Wird geladen…';
mehr.classList.add('hidden');
try {
const antwort = await fetch(protokollAdresse(tag));
if (!antwort.ok) throw new Error(`${antwort.status}`);
const text = await antwort.text();
const zeilen = text.split('\n');
// **Das Ende ist interessanter als der Anfang.** Ein Protokolltag kann
// zehntausend Zeilen haben; wer etwas sucht, sucht meist das Letzte.
const antwort = await fetch(protokollAdresse(tag, gezeigt));
if (!antwort.ok) throw new ApiFehler(antwort.status, `${antwort.status}`);
const roh = (await antwort.text()).split('\n');
const gefiltert = nurFehler
? zeilen.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
: zeilen;
anzeige.textContent = gefiltert.length === 0
? (nurFehler ? 'Keine Fehler an diesem Tag.' : 'Leer.')
: gefiltert.slice(-800).join('\n');
? roh.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
: roh;
anzeige.textContent = gefiltert.join('\n').trim().length === 0
? (nurFehler ? 'Keine Fehler in den letzten Zeilen.' : 'Leer.')
: gefiltert.join('\n');
anzeige.scrollTop = anzeige.scrollHeight;
// **„Mehr" nur, solange es mehr geben kann.** Kamen weniger Zeilen
// zurück als verlangt, ist die Datei zu Ende — ein Knopf, der dann
// nichts tut, ist schlechter als keiner.
if (roh.length >= gezeigt && gezeigt < 20000) {
mehr.classList.remove('hidden');
mehr.textContent = `Weitere ${Math.min(gezeigt, 20000 - gezeigt)} Zeilen laden`;
mehr.onclick = () => void zeigen(tag, nurFehler, Math.min(gezeigt * 2, 20000));
}
} catch (fehler) {
anzeige.textContent = `Konnte nicht gelesen werden: ${(fehler as Error).message}`;
anzeige.textContent = `Konnte nicht gelesen werden: ${meldung(fehler)}`;
}
}
@@ -500,7 +525,7 @@ function vermisstenbereich(geaendert: () => void): HTMLElement {
weg.onclick = async () => {
weg.disabled = true;
try { await vermisstesEntfernen(v.fileId); await laden(); geaendert(); }
catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); weg.disabled = false; }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); weg.disabled = false; }
};
oben.appendChild(weg);
d.appendChild(oben);
@@ -592,7 +617,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
alle.forEach((b, i) => liste.appendChild(block(b, i)));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
liste.appendChild(fehlerzeile(meldung(fehler)));
}
}
@@ -626,7 +651,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
los.onclick = async () => {
los.disabled = true;
try { await scanStarten(b.id); await verfolgen(true); }
catch (fehler) { stand.textContent = (fehler as Error).message; }
catch (fehler) { stand.textContent = meldung(fehler); }
los.disabled = false;
};
@@ -654,6 +679,25 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
},
});
// **Zurücksetzen steht neben Entfernen, weil es fast dasselbe tut** — nur
// ohne Namen, Pfad und Einstellungen mitzunehmen. Wer eine Mediathek
// löscht und neu anlegt, um sie sauber zu bekommen, tippt alles noch
// einmal; genau das war bisher der einzige Weg.
const zuruecksetzen = knopf('Zurücksetzen', 'btn');
zuruecksetzen.onclick = () => nachfragen({
titel: `„${b.name}" zurücksetzen?`,
text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser '
+ 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. '
+ 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal '
+ 'scannen, und der Bestand entsteht neu.',
bestaetigen: 'Zurücksetzen',
tun: async () => {
await mediathekZuruecksetzen(b.id);
await laden();
geaendert();
},
});
const entfernen = knopf('Entfernen', 'btn');
entfernen.onclick = () => nachfragen({
titel: `„${b.name}" entfernen?`,
@@ -687,7 +731,9 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
if (melden && i > 0 && i % 5 === 0) await laden();
else if (s.state === 'finished') {
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
if (s.unrecognised) teile.push(`${s.unrecognised} ohne Folgennummer`);
// „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
// was eine Nummer trägt, aber zu keiner Serie gehört.
if (s.unrecognised) teile.push(`${s.unrecognised} nicht zugeordnet`);
if (s.metadata?.skipped) teile.push('Metadaten übersprungen');
else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`);
if (s.metadata?.error) teile.push(s.metadata.error);
@@ -709,7 +755,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
await pruefungSetzen(b.id, !b.reviewNewItems);
await laden();
} catch (fehler) {
d.appendChild(fehlerzeile((fehler as Error).message));
d.appendChild(fehlerzeile(meldung(fehler)));
pruefung.disabled = false;
}
};
@@ -736,7 +782,8 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe';
knoepfe.append(hoch, runter, los, auffrischen, pruefung, umbenennen, entfernen);
knoepfe.append(
hoch, runter, los, auffrischen, pruefung, umbenennen, zuruecksetzen, entfernen);
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es
@@ -753,7 +800,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
await laden();
geaendert();
} catch (fehler) {
d.appendChild(fehlerzeile((fehler as Error).message));
d.appendChild(fehlerzeile(meldung(fehler)));
freigeben.disabled = false;
}
};
@@ -814,7 +861,7 @@ function benutzerbereich(): HTMLElement {
});
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
liste.appendChild(fehlerzeile(meldung(fehler)));
}
}
@@ -823,9 +870,21 @@ function benutzerbereich(): HTMLElement {
/* ---------- Server ---------- */
function serverbereich(geaendert: () => void): HTMLElement {
/** Sprache — eigener Abschnitt, ganz oben.
*
* **Warum nicht in der Serverliste.** Dort stand sie, zwischen „Name",
* „Metadaten", „Doppelte Einträge" und „Fassung". Gesucht wurde sie
* trotzdem, gefunden nicht — und das ist die Antwort: eine Einstellung, die
* man sucht und nicht findet, steht am falschen Platz, egal wie gut sie
* beschriftet ist.
*
* **Oben, weil sie alles andere färbt.** In dieser Sprache werden Metadaten
* geholt und gespeichert; wer sie spät umstellt, frischt den ganzen Bestand
* noch einmal auf.
*/
function sprachbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Server'));
bereich.appendChild(abschnitt('Sprache'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
@@ -833,74 +892,9 @@ function serverbereich(geaendert: () => void): HTMLElement {
void (async () => {
try {
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
const stand = await metadatenEinstellung();
const eigeneSprache = meineSprache();
liste.innerHTML = '';
liste.appendChild(zeile(
'Name', auskunft.serverName ?? '—',
'So nennen ihn die Apps und die Kopfzeile.',
() => dialog({
titel: 'Server umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }],
bestaetigen: 'Speichern',
tun: async wert => { await servernameSetzen(wert('name')); location.reload(); },
})));
// **Wartung, nicht Alltag.** Nach jedem Scan passiert das von selbst;
// dieser Knopf ist für den Fall, dass doch wieder Dubletten auftauchen —
// etwa nachdem eine Zuordnung von Hand geändert wurde.
const wartung = zeile(
'Doppelte Einträge', 'Zusammenlegen, was denselben Film meint',
'Zwei Dateien mit leicht verschiedenen Namen konnten früher zwei '
+ 'Einträge ergeben. Der Sehfortschritt bleibt dabei erhalten — es '
+ 'gewinnt die weitere Stelle.');
const legen = knopf('Zusammenlegen', 'btn');
legen.onclick = async () => {
legen.disabled = true;
const vorher = legen.textContent;
legen.textContent = 'Läuft…';
try {
const bericht = await doppelteZusammenlegen();
legen.textContent = bericht.gesamt === 0
? 'Nichts zu tun'
: `${bericht.gesamt} zusammengelegt`;
if (bericht.gesamt > 0) geaendert();
} catch (fehler) {
wartung.appendChild(fehlerzeile((fehler as Error).message));
legen.textContent = vorher;
legen.disabled = false;
}
};
wartung.querySelector('.knopfreihe')?.appendChild(legen);
liste.appendChild(wartung);
liste.appendChild(zeile(
'Metadaten',
// Ohne die Sprache: die steht eine Zeile tiefer und gehört dort hin.
stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang'
: stand.hasApiKey ? 'Eigener TMDB-Zugang'
: 'Kein Zugang hinterlegt',
stand.usingProjectKey
? 'Ein eigener Zugang löst den mitgelieferten ab.'
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
+ 'vorhandene Titel ändern sich sonst nicht.',
() => dialog({
titel: 'Metadaten',
felder: [
{
name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password',
platzhalter: 'Read Access Token oder API Key',
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
},
],
bestaetigen: 'Speichern',
tun: async wert => {
await metadatenSetzen({ apiKey: wert('schluessel') || undefined });
location.reload();
},
})));
// **Die Sprache des Servers als eigene Zeile.**
//
// Sie steckte im Dialog „Metadaten" — zusammen mit dem TMDB-Schlüssel,
@@ -973,11 +967,219 @@ function serverbereich(geaendert: () => void): HTMLElement {
alert(`${bericht.gesamt} Übersetzungen geholt, ${bericht.fehlend} ohne Treffer.`);
},
})));
liste.appendChild(zeile('Fassung', auskunft.version, null));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
liste.appendChild(fehlerzeile(meldung(fehler)));
}
})();
return bereich;
function zeile(
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
text.innerHTML = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
oben.appendChild(text);
if (bearbeiten) {
const b = knopf('Ändern', 'btn');
b.prepend(icon('stift'));
b.onclick = bearbeiten;
oben.appendChild(b);
}
d.appendChild(oben);
if (hinweis) {
const h = document.createElement('div');
h.className = 'hinweis';
h.textContent = hinweis;
d.appendChild(h);
}
return d;
}
}
/** Speicher — wo die Dateien des Servers liegen.
*
* **Warum das in die Oberfläche gehört.** Die drei Pfade standen nur in der
* Compose-Datei. Wer wissen wollte, wohin die Bilder wandern oder warum das
* `cache`-Volume vollläuft, musste sich auf das NAS verbinden — für eine
* Auskunft, die der Server längst hat.
*
* **Die Pfade bleiben Umgebungsvariablen.** Sie im Betrieb umzubiegen, während
* Dateien darin liegen, hiesse: die Datenbank zeigt auf den alten Ort, der
* Server schreibt in den neuen, und niemand merkt es, bis ein Bild fehlt.
* Angezeigt wird deshalb der Variablenname — damit klar ist, wo man schraubt.
*/
function speicherbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Speicher'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
liste.innerHTML = '<div class="spinner"></div>';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
let orte;
try { orte = await speicherorte(); }
catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
return;
}
liste.innerHTML = '';
orte.forEach(o => {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const frei = o.frei === null ? '' : ` · ${groesse(o.frei)} frei`;
text.innerHTML = `<strong>${esc(o.name)}</strong>`
+ `<span class="winzig">${esc(o.pfad)} — ${groesse(o.belegt)} belegt${esc(frei)}</span>`
+ `<span class="winzig">Wird gesetzt über ${esc(o.variable)}.</span>`;
oben.appendChild(text);
// Leeren gibt es nur dort, wo es gefahrlos ist.
if (o.bilder !== null) {
const leeren = knopf('Bildspeicher leeren', 'btn');
leeren.onclick = async () => {
leeren.disabled = true;
try {
const { freed } = await bildspeicherLeeren();
await laden();
// Erst nach dem Neuladen melden, sonst steht die alte Zahl daneben.
const gemeldet = document.createElement('p');
gemeldet.className = 'hinweis';
gemeldet.textContent = `${groesse(freed)} freigegeben. `
+ 'Die Bilder werden beim nächsten Aufruf neu geholt.';
liste.prepend(gemeldet);
} catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler)));
leeren.disabled = false;
}
};
oben.appendChild(leeren);
}
d.appendChild(oben);
liste.appendChild(d);
});
}
return bereich;
}
function serverbereich(geaendert: () => void): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Server'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void (async () => {
try {
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
liste.innerHTML = '';
liste.appendChild(zeile(
'Name', auskunft.serverName ?? '—',
'So nennen ihn die Apps und die Kopfzeile.',
() => dialog({
titel: 'Server umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }],
bestaetigen: 'Speichern',
tun: async wert => { await servernameSetzen(wert('name')); location.reload(); },
})));
// **Wartung, nicht Alltag.** Nach jedem Scan passiert das von selbst;
// dieser Knopf ist für den Fall, dass doch wieder Dubletten auftauchen —
// etwa nachdem eine Zuordnung von Hand geändert wurde.
const wartung = zeile(
'Doppelte Einträge', 'Zusammenlegen, was denselben Film meint',
'Zwei Dateien mit leicht verschiedenen Namen konnten früher zwei '
+ 'Einträge ergeben. Der Sehfortschritt bleibt dabei erhalten — es '
+ 'gewinnt die weitere Stelle.');
const legen = knopf('Zusammenlegen', 'btn');
legen.onclick = async () => {
legen.disabled = true;
const vorher = legen.textContent;
legen.textContent = 'Läuft…';
try {
const bericht = await doppelteZusammenlegen();
legen.textContent = bericht.gesamt === 0
? 'Nichts zu tun'
: `${bericht.gesamt} zusammengelegt`;
if (bericht.gesamt > 0) geaendert();
} catch (fehler) {
wartung.appendChild(fehlerzeile(meldung(fehler)));
legen.textContent = vorher;
legen.disabled = false;
}
};
wartung.querySelector('.knopfreihe')?.appendChild(legen);
liste.appendChild(wartung);
liste.appendChild(zeile(
'Metadaten',
// Ohne die Sprache: die steht eine Zeile tiefer und gehört dort hin.
stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang'
: stand.hasApiKey ? 'Eigener TMDB-Zugang'
: 'Kein Zugang hinterlegt',
stand.usingProjectKey
? 'Ein eigener Zugang löst den mitgelieferten ab.'
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
+ 'vorhandene Titel ändern sich sonst nicht.',
() => dialog({
titel: 'Metadaten',
felder: [
{
name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password',
platzhalter: 'Read Access Token oder API Key',
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
},
],
bestaetigen: 'Speichern',
tun: async wert => {
await metadatenSetzen({ apiKey: wert('schluessel') || undefined });
location.reload();
},
})));
// **Fassung und Build nebeneinander.**
//
// Die Fassung sagt, welcher Stand gemeint ist; der Build sagt, welches
// Abbild wirklich läuft. Ohne den zweiten Wert war nicht feststellbar,
// ob das NAS den Bau von eben zieht — und eine Fehlersuche, die diese
// Frage nicht beantworten kann, dreht sich im Kreis.
liste.appendChild(zeile(
'Fassung',
`${auskunft.version} · Build ${auskunft.build}`,
auskunft.build === 'DEV'
? 'Läuft aus dem Quellbaum, nicht aus einem Abbild.'
: 'Diese Kennung druckt das Veröffentlichungsskript am Ende. '
+ 'Stimmt sie nicht überein, läuft hier ein älteres Abbild.'));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
}
})();
+25 -24
View File
@@ -1,10 +1,10 @@
import {
import { ApiFehler,
film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse,
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen,
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 } from '../util';
import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung } from '../util';
import { icons, icon } from '../icons';
import { kachel, alsKachel, reihe } from '../card';
@@ -48,9 +48,10 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
}
} catch (fehler) {
wurzel.innerHTML = '';
wurzel.appendChild(fehler instanceof Error && /404/.test(fehler.message)
// 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((fehler as Error).message));
: fehlerzeile(meldung(fehler)));
}
}
@@ -191,25 +192,25 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'aktionen';
const meldung = document.createElement('div');
meldung.className = 'hinweis';
const hinweisfeld = document.createElement('div');
hinweisfeld.className = 'hinweis';
if (datei) {
const stelle = stand?.positionSeconds ?? 0;
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
los.prepend(icon('abspielen'));
los.onclick = () => void starten(datei, eintrag.id, stelle, los, meldung);
los.onclick = () => void starten(datei, eintrag.id, stelle, los, hinweisfeld);
reihe.appendChild(los);
if (stelle > 0) {
const vonVorn = knopf('Von vorn', 'btn');
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, meldung);
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, hinweisfeld);
reihe.appendChild(vonVorn);
}
const laden = knopf('Herunterladen', 'btn');
laden.prepend(icon('download'));
laden.onclick = () => void herunterladen(datei, laden, meldung);
laden.onclick = () => void herunterladen(datei, laden, hinweisfeld);
reihe.appendChild(laden);
}
@@ -231,7 +232,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
}
const huelle = document.createElement('div');
huelle.append(reihe, meldung);
huelle.append(reihe, hinweisfeld);
return huelle;
}
@@ -459,10 +460,10 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
/* ---------- Abspielen und Herunterladen ---------- */
/** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */
async function bereitstellen(datei: Datei, meldung: HTMLElement): Promise<boolean> {
async function bereitstellen(datei: Datei, hinweisfeld: HTMLElement): Promise<boolean> {
let auskunft: Lieferung;
try { auskunft = await lieferung(datei.id); }
catch (fehler) { meldung.textContent = (fehler as Error).message; return false; }
catch (fehler) { hinweisfeld.textContent = meldung(fehler); return false; }
if (auskunft.method === 'directFile') return true;
if (auskunft.packageState === 'ready') return true;
@@ -471,35 +472,35 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const gruende: string[] = [];
if (auskunft.audioToEncode.length) gruende.push(`${auskunft.audioToEncode.length} Tonspur(en) werden neu gerechnet`);
if (auskunft.subtitlesDropped.length) gruende.push(`${auskunft.subtitlesDropped.length} Bilduntertitel können nicht mit`);
meldung.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`;
hinweisfeld.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`;
for (let i = 0; i < 600; i++) {
const antwort = await fetch(downloadAdresse(datei.id), { method: 'HEAD' });
if (antwort.ok) { meldung.textContent = ''; return true; }
if (antwort.status !== 202) { meldung.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; }
if (antwort.ok) { hinweisfeld.textContent = ''; return true; }
if (antwort.status !== 202) { hinweisfeld.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; }
const wie = await lieferung(datei.id);
if (wie.error) { meldung.textContent = wie.error; return false; }
meldung.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`;
if (wie.error) { hinweisfeld.textContent = wie.error; return false; }
hinweisfeld.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`;
await new Promise(r => setTimeout(r, 1000));
}
meldung.textContent = 'Das Umpacken dauert ungewöhnlich lange.';
hinweisfeld.textContent = 'Das Umpacken dauert ungewöhnlich lange.';
return false;
}
async function starten(
datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, hinweisfeld: HTMLElement,
): Promise<void> {
knopfDavon.disabled = true;
if (await bereitstellen(datei, meldung)) spieler(downloadAdresse(datei.id), titel, ab, laden);
if (await bereitstellen(datei, hinweisfeld)) spieler(downloadAdresse(datei.id), titel, ab, laden);
knopfDavon.disabled = false;
}
async function herunterladen(
datei: Datei, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
datei: Datei, knopfDavon: HTMLButtonElement, hinweisfeld: HTMLElement,
): Promise<void> {
knopfDavon.disabled = true;
if (await bereitstellen(datei, meldung)) {
if (await bereitstellen(datei, hinweisfeld)) {
// Ein echter Link, kein Blob: der Browser lädt im Hintergrund und kann
// fortsetzen, statt den ganzen Film in den Speicher zu nehmen.
const a = document.createElement('a');
@@ -565,13 +566,13 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
try {
await zuordnen(art === 'filme' ? 'movies' : 'series', id, v.tmdbId);
await laden();
} catch (fehler) { treffer.appendChild(fehlerzeile((fehler as Error).message)); }
} catch (fehler) { treffer.appendChild(fehlerzeile(meldung(fehler))); }
};
treffer.appendChild(zeile);
});
} catch (fehler) {
treffer.innerHTML = '';
treffer.appendChild(fehlerzeile((fehler as Error).message));
treffer.appendChild(fehlerzeile(meldung(fehler)));
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import {
inhalt, weiterschauen, naechsteFolgen, empfehlungen,
type Bibliothek,
} from '../api';
import { leer, fehlerzeile } from '../util';
import { leer, fehlerzeile, meldung } from '../util';
import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card';
import { hero, alsHero, type Heroeintrag } from '../hero';
@@ -104,7 +104,7 @@ export function startseite(
}
} catch (fehler) {
wurzel.innerHTML = '';
wurzel.appendChild(fehlerzeile((fehler as Error).message));
wurzel.appendChild(fehlerzeile(meldung(fehler)));
}
})();
+26 -34
View File
@@ -1,8 +1,9 @@
import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api';
import { leer, fehlerzeile } from '../util';
import { leer, fehlerzeile, meldung } from '../util';
import { icons } from '../icons';
import { kachel, alsKachel, reihe } from '../card';
import { beiRueckkehr } from '../ansichten';
import { auswahl, type Auswahlfeld } from '../auswahl';
type Art = 'filme' | 'serien';
type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien';
@@ -88,23 +89,20 @@ export function mediathek(
werte: [string, string][],
gewaehlt: () => string,
setzen: (wert: string) => void,
): { huelle: HTMLElement; wahl: HTMLSelectElement } {
): { huelle: HTMLElement; wahl: Auswahlfeld } {
const huelle = document.createElement('div');
huelle.className = 'pille';
const wahl = document.createElement('select');
[[ '', vorgabe ] as [string, string], ...werte].forEach(([w, t]) => {
const o = document.createElement('option');
o.value = w; o.textContent = t;
wahl.appendChild(o);
});
wahl.onchange = () => {
setzen(wahl.value);
huelle.classList.toggle('gesetzt', wahl.value !== '');
const wahl = auswahl(
[{ wert: '', text: vorgabe }, ...werte.map(([w, t]) => ({ wert: w, text: t }))],
gewaehlt());
wahl.beiWechsel(() => {
setzen(wahl.wert);
huelle.classList.toggle('gesetzt', wahl.wert !== '');
aufraeumen();
void laden();
};
});
huelle.classList.toggle('gesetzt', gewaehlt() !== '');
huelle.appendChild(wahl);
huelle.appendChild(wahl.element);
return { huelle, wahl };
}
@@ -118,18 +116,13 @@ export function mediathek(
// Die Sortierung ist kein Filter: sie ist immer gesetzt und bleibt grau.
const sortierHuelle = document.createElement('div');
sortierHuelle.className = 'pille';
const sortierung = document.createElement('select');
([
['title', 'Nach Titel'],
['added', 'Zuletzt hinzugefügt'],
['year', 'Nach Jahr'],
] as [Ordnung, string][]).forEach(([wert, text]) => {
const o = document.createElement('option');
o.value = wert; o.textContent = text;
sortierung.appendChild(o);
});
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
sortierHuelle.appendChild(sortierung);
const sortierung = auswahl([
{ wert: 'title', text: 'Nach Titel' },
{ wert: 'added', text: 'Zuletzt hinzugefügt' },
{ wert: 'year', text: 'Nach Jahr' },
], ordnung);
sortierung.beiWechsel(() => { ordnung = sortierung.wert as Ordnung; void laden(); });
sortierHuelle.appendChild(sortierung.element);
// Erscheint nur, wenn es etwas zurückzusetzen gibt.
const zuruecksetzen = document.createElement('button');
@@ -138,7 +131,7 @@ export function mediathek(
zuruecksetzen.hidden = true;
zuruecksetzen.onclick = () => {
genre = ''; gesehen = '';
genrePille.wahl.value = ''; standPille.wahl.value = '';
genrePille.wahl.wert = ''; standPille.wahl.wert = '';
genrePille.huelle.classList.remove('gesetzt');
standPille.huelle.classList.remove('gesetzt');
aufraeumen();
@@ -173,11 +166,10 @@ export function mediathek(
// 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 => {
namen.forEach(n => {
const o = document.createElement('option');
o.value = n; o.textContent = n;
genrePille.wahl.appendChild(o);
});
genrePille.wahl.setzen([
{ wert: '', text: 'Alle Genres' },
...namen.map(n => ({ wert: n, text: n })),
]);
}).catch(() => {});
void laden();
@@ -218,7 +210,7 @@ export function mediathek(
uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
} catch (fehler) {
gitter.innerHTML = '';
gitter.appendChild(fehlerzeile((fehler as Error).message));
gitter.appendChild(fehlerzeile(meldung(fehler)));
}
}
}
@@ -257,7 +249,7 @@ export function mediathek(
beliebt.items.map(e => kachel(alsKachel(e), oeffnen))));
} catch (fehler) {
ziel.innerHTML = '';
ziel.appendChild(fehlerzeile((fehler as Error).message));
ziel.appendChild(fehlerzeile(meldung(fehler)));
}
})();
}
@@ -301,7 +293,7 @@ export function mediathek(
ziel.appendChild(gitter);
} catch (fehler) {
ziel.innerHTML = '';
ziel.appendChild(fehlerzeile((fehler as Error).message));
ziel.appendChild(fehlerzeile(meldung(fehler)));
}
})();
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { anmelden } from '../api';
import { knopf, feld, fehlerzeile } from '../util';
import { knopf, feld, fehlerzeile, meldung } from '../util';
export function anmeldung(fertig: () => void): HTMLElement {
const karte = document.createElement('div');
@@ -34,7 +34,7 @@ export function anmeldung(fertig: () => void): HTMLElement {
await anmelden(name.value.trim(), passwort.value, haken.checked);
fertig();
} catch (fehler) {
karte.appendChild(fehlerzeile((fehler as Error).message));
karte.appendChild(fehlerzeile(meldung(fehler)));
los.disabled = false;
passwort.select();
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { filme, serien } from '../api';
import { esc, leer, fehlerzeile } from '../util';
import { esc, leer, fehlerzeile, meldung } from '../util';
import { kachel, alsKachel } from '../card';
/** Suchergebnisse quer durch alle Mediatheken.
@@ -40,7 +40,7 @@ export function suchseite(
if (s.total > 0) buehne.appendChild(abschnitt(`Serien · ${s.total}`, s.items));
} catch (fehler) {
buehne.innerHTML = '';
buehne.appendChild(fehlerzeile((fehler as Error).message));
buehne.appendChild(fehlerzeile(meldung(fehler)));
}
})();
+2 -2
View File
@@ -1,5 +1,5 @@
import { einrichten, anmelden } from '../api';
import { knopf, feld, fehlerzeile } from '../util';
import { knopf, feld, fehlerzeile, meldung } from '../util';
/** Der erste Start: ein Administrator muss angelegt werden.
*
@@ -32,7 +32,7 @@ export function einrichtung(fertig: () => void): HTMLElement {
await anmelden(name.value.trim(), passwort.value);
fertig();
} catch (fehler) {
karte.appendChild(fehlerzeile((fehler as Error).message));
karte.appendChild(fehlerzeile(meldung(fehler)));
los.disabled = false;
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { vorschlaege, zuordnen } from './api';
import { esc, knopf, feld, fehlerzeile, leer } from './util';
import { esc, knopf, feld, fehlerzeile, leer, meldung } from './util';
import { icons } from './icons';
/** Einen Titel einem anderen Eintrag der Metadatenquelle zuordnen.
@@ -90,7 +90,7 @@ export function zuordnungsdialog(
d.close();
fertig?.();
} catch (fehler) {
treffer.appendChild(fehlerzeile((fehler as Error).message));
treffer.appendChild(fehlerzeile(meldung(fehler)));
zeile.disabled = false;
}
};
@@ -98,7 +98,7 @@ export function zuordnungsdialog(
});
} catch (fehler) {
treffer.innerHTML = '';
treffer.appendChild(fehlerzeile((fehler as Error).message));
treffer.appendChild(fehlerzeile(meldung(fehler)));
}
}
}