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>
150 lines
5.6 KiB
TypeScript
150 lines
5.6 KiB
TypeScript
|
|
|
|
export function esc(wert: unknown): string {
|
|
return String(wert ?? '').replace(/[&<>"']/g, z =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[z]!));
|
|
}
|
|
|
|
/** Sekunden als 1:02:03 oder 2:03. */
|
|
export function dauer(sekunden: number | null): string {
|
|
if (!sekunden || sekunden <= 0) return '';
|
|
const s = Math.round(sekunden);
|
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
|
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
|
: `${m}:${String(s % 60).padStart(2, '0')}`;
|
|
}
|
|
|
|
export function groesse(bytes: number): string {
|
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MB`;
|
|
return `${Math.round(bytes / 1024)} KB`;
|
|
}
|
|
|
|
/** Wie weit ein Stand gediehen ist, als Anteil — oder nichts. */
|
|
export function anteil(stand: { positionSeconds: number; runtimeSeconds: number | null } | null | undefined)
|
|
: number | null {
|
|
if (!stand?.runtimeSeconds || stand.runtimeSeconds <= 0) return null;
|
|
return stand.positionSeconds / stand.runtimeSeconds;
|
|
}
|
|
|
|
/** „noch 42 Min." — was auf einer Kachel wirklich interessiert. */
|
|
export function verbleibend(stand: { positionSeconds: number; runtimeSeconds: number | null }): string {
|
|
if (!stand.runtimeSeconds) return '';
|
|
const rest = Math.max(0, stand.runtimeSeconds - stand.positionSeconds);
|
|
return rest < 60 ? 'fast durch' : `noch ${Math.round(rest / 60)} Min.`;
|
|
}
|
|
|
|
export function knopf(text: string, klasse = 'btn'): HTMLButtonElement {
|
|
const b = document.createElement('button');
|
|
b.className = klasse;
|
|
b.textContent = text;
|
|
return b;
|
|
}
|
|
|
|
export function feld(platzhalter: string, art = 'text'): HTMLInputElement {
|
|
const i = document.createElement('input');
|
|
i.className = 'feld';
|
|
i.type = art;
|
|
i.placeholder = platzhalter;
|
|
return i;
|
|
}
|
|
|
|
/** Zeigt einen Fehler, ohne die Seite zu verlassen. */
|
|
export function fehlerzeile(text: string): HTMLElement {
|
|
const d = document.createElement('div');
|
|
d.className = 'fehler';
|
|
d.textContent = text;
|
|
return d;
|
|
}
|
|
|
|
export function leer(text: string): HTMLElement {
|
|
const d = document.createElement('div');
|
|
d.className = 'leer';
|
|
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;
|
|
}
|
|
|
|
/** 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);
|
|
}
|