From ac04901ccc32cbf9a7dd5d0e378853de9e20aae5 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 4 Oct 2026 15:55:22 +0200 Subject: [PATCH] =?UTF-8?q?Neun=20gemeldete=20Fehler,=20und=20die=20Frage,?= =?UTF-8?q?=20welcher=20Build=20gerade=20l=C3=A4uft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `.** 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 `. 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; } diff --git a/web/app/src/util.ts b/web/app/src/util.ts index 680f3ea..cdcc238 100644 --- a/web/app/src/util.ts +++ b/web/app/src/util.ts @@ -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; +} diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 7fb8199..5634032 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -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 { + /** Wie viele Zeilen zuletzt geholt wurden — der Knopf „Mehr" verdoppelt. */ + let gezeigt = 0; + + async function zeigen(tag: string, nurFehler: boolean, zeilen = 0): Promise { + // **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 = `${esc(name)}${esc(wert)}`; + 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 = '
'; + bereich.appendChild(liste); + + void laden(); + + async function laden(): Promise { + 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 = `${esc(o.name)}` + + `${esc(o.pfad)} — ${groesse(o.belegt)} belegt${esc(frei)}` + + `Wird gesetzt über ${esc(o.variable)}.`; + 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))); } })(); diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 2042960..8156c76 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -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 { + async function bereitstellen(datei: Datei, hinweisfeld: HTMLElement): Promise { 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 { 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 { 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))); } } diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts index bd413e2..f363c7d 100644 --- a/web/app/src/views/home.ts +++ b/web/app/src/views/home.ts @@ -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))); } })(); diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index 3860a0e..8751f30 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -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))); } })(); } diff --git a/web/app/src/views/login.ts b/web/app/src/views/login.ts index 6af49ec..6bc5ba5 100644 --- a/web/app/src/views/login.ts +++ b/web/app/src/views/login.ts @@ -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(); } diff --git a/web/app/src/views/search.ts b/web/app/src/views/search.ts index f5126bd..36139dd 100644 --- a/web/app/src/views/search.ts +++ b/web/app/src/views/search.ts @@ -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))); } })(); diff --git a/web/app/src/views/setup.ts b/web/app/src/views/setup.ts index 0be2111..6be99b7 100644 --- a/web/app/src/views/setup.ts +++ b/web/app/src/views/setup.ts @@ -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; } } diff --git a/web/app/src/zuordnung.ts b/web/app/src/zuordnung.ts index 8342508..bf0fa10 100644 --- a/web/app/src/zuordnung.ts +++ b/web/app/src/zuordnung.ts @@ -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))); } } }