Seit verschwundene Dateien markiert statt gelöscht werden, blieb nach jeder Umbenennung die alte Zeile als "vermisst" stehen. Damit galt die alte Folge nicht als verwaist, die alte Serie nicht als leer — und nach dem Umbenennen eines Serienordners stand alles doppelt da. Jetzt erkennt der Scan eine Verschiebung an Grösse und Änderungszeit und hängt den vorhandenen Eintrag um, statt einen zweiten anzulegen. Der Zeitstempel musste dafür auf Sekunden gerundet werden: er geht durch die Datenbank und kommt nicht auf den Tick genau zurück. Mit Vergleich auf Gleichheit schlug die Erkennung ausnahmslos fehl — der Test zeigte es, nachdem er zweimal dasselbe falsche Ergebnis lieferte. Grösse und Zeit müssen beide stimmen, und das Merkmal muss eindeutig sein. Grösse allein reichte nicht: zwei gleich grosse Dateien gibt es, und die zu vertauschen wäre schlimmer als eine Leiche. Dazu: die Zahl der Titel läuft während eines Scans mit, statt erst am Ende zu springen. Alle fünf Runden, nicht jede — ein Abruf je Sekunde für eine Zahl wäre Verschwendung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1018 lines
38 KiB
TypeScript
1018 lines
38 KiB
TypeScript
import {
|
|
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
|
|
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
|
|
vermisste, vermisstesEntfernen, type VermissteDatei,
|
|
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
|
|
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
|
|
mediathekFreigeben, reihenfolgeSetzen,
|
|
type Pruefeintrag,
|
|
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
|
servernameSetzen, systemauskunft,
|
|
laufendeWiedergaben, serverzustand, bildAdresse,
|
|
type Bibliothek, type Wiedergabe,
|
|
} from '../api';
|
|
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
|
|
import { dialog, nachfragen } from '../dialog';
|
|
import { icon, icons } from '../icons';
|
|
|
|
/** Die Verwaltung.
|
|
*
|
|
* **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block
|
|
* in der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg,
|
|
* das man einmal im Monat braucht — und sieht aus wie eine Eingabe, die
|
|
* noch aussteht.
|
|
*
|
|
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
|
|
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
|
|
export function verwaltung(
|
|
geaendert: () => void = () => {},
|
|
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());
|
|
return wurzel;
|
|
}
|
|
|
|
/** Überschrift mit einem Knopf rechts daneben. */
|
|
function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLElement {
|
|
const kopf = document.createElement('div');
|
|
kopf.className = 'reihe-kopf';
|
|
const h = document.createElement('h2');
|
|
h.textContent = titel;
|
|
kopf.appendChild(h);
|
|
if (knoepfe.length) {
|
|
const rechts = document.createElement('div');
|
|
rechts.className = 'knopfreihe';
|
|
rechts.style.marginLeft = 'auto';
|
|
knoepfe.forEach(k => rechts.appendChild(k));
|
|
kopf.appendChild(rechts);
|
|
}
|
|
return kopf;
|
|
}
|
|
|
|
/* ---------- Dashboard ---------- */
|
|
|
|
/** Was gerade läuft.
|
|
*
|
|
* **Fragt alle fünf Sekunden nach.** Ein dauerhaft offener Kanal wäre
|
|
* sparsamer, aber für eine Handvoll Zeilen auf einer Seite, die man selten
|
|
* offen hat, ist er der grössere Aufwand — auf beiden Seiten. */
|
|
function dashboard(signal: AbortSignal): HTMLElement {
|
|
const bereich = document.createElement('section');
|
|
bereich.appendChild(abschnitt('Wird abgespielt'));
|
|
|
|
const liste = document.createElement('div');
|
|
liste.className = 'einstellungen';
|
|
liste.innerHTML = '<div class="spinner"></div>';
|
|
|
|
const zustand = document.createElement('div');
|
|
zustand.className = 'hinweis';
|
|
|
|
bereich.append(liste, zustand);
|
|
|
|
let timer: number | undefined;
|
|
void laden();
|
|
|
|
// Aufhören, sobald die Seite weg ist — sonst fragt eine unsichtbare
|
|
// Verwaltung bis zum Neuladen weiter.
|
|
const waechter = new MutationObserver(() => {
|
|
if (!bereich.isConnected) { window.clearTimeout(timer); waechter.disconnect(); }
|
|
});
|
|
waechter.observe(document.body, { childList: true, subtree: true });
|
|
|
|
async function laden(): Promise<void> {
|
|
try {
|
|
const [laufend, stand] = await Promise.all([laufendeWiedergaben(), serverzustand()]);
|
|
|
|
liste.innerHTML = '';
|
|
if (laufend.length === 0) {
|
|
liste.appendChild(leer('Derzeit wird nichts gespielt.'));
|
|
} else {
|
|
laufend.forEach(w => liste.appendChild(wiedergabe(w)));
|
|
}
|
|
|
|
const teile = [`Läuft seit ${laufzeit(stand.uptime)}`];
|
|
if (stand.packaging > 0) teile.push(`${stand.packaging} Umwandlung(en) laufen`);
|
|
if (stand.scanning > 0) teile.push(`${stand.scanning} Scan(s) laufen`);
|
|
zustand.textContent = teile.join(' · ');
|
|
} catch (fehler) {
|
|
liste.innerHTML = '';
|
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
|
return; // Nicht weiter nachfragen, wenn es scheitert.
|
|
}
|
|
// **Nur weitermachen, solange die Ansicht hängt.** Ohne diese Zeile
|
|
// legte jeder Besuch der Verwaltung einen weiteren Fünf-Sekunden-Takt
|
|
// an, und nach zehn Wechseln liefen zehn davon nebeneinander.
|
|
if (signal.aborted) return;
|
|
timer = window.setTimeout(() => void laden(), 5000);
|
|
}
|
|
|
|
function wiedergabe(w: Wiedergabe): HTMLElement {
|
|
const d = document.createElement('div');
|
|
d.className = 'einstellung laeuft';
|
|
|
|
const bild = document.createElement('div');
|
|
bild.className = 'laeuft-bild';
|
|
const adresse = bildAdresse(w.posterImage);
|
|
if (adresse) {
|
|
const img = document.createElement('img');
|
|
img.src = adresse;
|
|
img.alt = '';
|
|
bild.appendChild(img);
|
|
}
|
|
|
|
const text = document.createElement('div');
|
|
text.className = 'zeile';
|
|
text.style.flex = '1';
|
|
const wie = w.runtimeSeconds
|
|
? `${dauer(w.positionSeconds)} von ${dauer(w.runtimeSeconds)}`
|
|
: dauer(w.positionSeconds);
|
|
text.innerHTML = `<strong>${esc(w.title)}</strong>`
|
|
+ (w.subTitle ? `<span class="winzig">${esc(w.subTitle)}</span>` : '')
|
|
+ `<span class="winzig">${esc(w.userName)} · ${esc(w.deviceName)} · ${esc(wie)}</span>`;
|
|
|
|
const innen = document.createElement('div');
|
|
innen.className = 'knopfreihe';
|
|
innen.append(bild, text);
|
|
d.appendChild(innen);
|
|
|
|
if (w.runtimeSeconds) {
|
|
const balken = document.createElement('div');
|
|
balken.className = 'fortschritt breit';
|
|
const gefuellt = document.createElement('div');
|
|
gefuellt.style.width = `${Math.min(100, (w.positionSeconds / w.runtimeSeconds) * 100)}%`;
|
|
balken.appendChild(gefuellt);
|
|
d.appendChild(balken);
|
|
}
|
|
|
|
return d;
|
|
}
|
|
|
|
return bereich;
|
|
}
|
|
|
|
/** „3 Std. 12 Min." — aus Sekunden. */
|
|
function laufzeit(sekunden: number): string {
|
|
const tage = Math.floor(sekunden / 86400);
|
|
const stunden = Math.floor((sekunden % 86400) / 3600);
|
|
const minuten = Math.floor((sekunden % 3600) / 60);
|
|
if (tage > 0) return `${tage} Tg. ${stunden} Std.`;
|
|
if (stunden > 0) return `${stunden} Std. ${minuten} Min.`;
|
|
return `${minuten} Min.`;
|
|
}
|
|
|
|
/* ---------- Zu prüfen ---------- */
|
|
|
|
/** Neue Funde, die noch nicht erscheinen — und geplante mit ihrem Termin.
|
|
*
|
|
* **Erscheint nur, wenn etwas wartet.** Ein leerer Abschnitt wäre Rauschen.
|
|
*
|
|
* Geplante bleiben in der Liste. Ein Titel mit Termin ist nicht erledigt, er
|
|
* hat nur schon einen — ihn verschwinden zu lassen hiesse, dass man ihn nur
|
|
* wiederfindet, wenn man sich den Termin gemerkt hat. */
|
|
function pruefbereich(geaendert: () => void): HTMLElement {
|
|
const bereich = document.createElement('section');
|
|
bereich.className = 'hidden';
|
|
|
|
const liste = document.createElement('div');
|
|
liste.className = 'einstellungen';
|
|
|
|
/** Was gerade angehakt ist. Als Menge und nicht am DOM abgelesen: beim
|
|
* Neuladen bleibt die Auswahl damit erhalten. */
|
|
const gewaehlt = new Set<string>();
|
|
let stand: Pruefeintrag[] = [];
|
|
|
|
const alleHaken = document.createElement('label');
|
|
alleHaken.className = 'bleiben';
|
|
const alleKasten = document.createElement('input');
|
|
alleKasten.type = 'checkbox';
|
|
alleHaken.append(alleKasten, document.createTextNode(' Alle'));
|
|
alleKasten.onchange = () => {
|
|
gewaehlt.clear();
|
|
if (alleKasten.checked) stand.forEach(e => gewaehlt.add(e.id));
|
|
zeichnen();
|
|
};
|
|
|
|
const freigeben = knopf('Veröffentlichen', 'btn btn-primary');
|
|
freigeben.onclick = () => void handeln(undefined);
|
|
|
|
const planen = knopf('Planen…', 'btn');
|
|
planen.onclick = () => dialog({
|
|
titel: `${betroffene().length} Titel planen`,
|
|
text: 'Ab diesem Zeitpunkt erscheinen sie für alle. Es läuft dafür nichts '
|
|
+ 'im Hintergrund — die Sichtbarkeit wird bei jeder Abfrage gerechnet und '
|
|
+ 'stimmt deshalb auch, wenn der Server gerade nicht lief.',
|
|
felder: [{
|
|
name: 'wann', beschriftung: 'Zeitpunkt', art: 'zeitpunkt',
|
|
wert: inEingabeform(new Date(Date.now() + 86400000)),
|
|
min: inEingabeform(new Date()),
|
|
}],
|
|
bestaetigen: 'Planen',
|
|
tun: async wert => {
|
|
const wann = new Date(wert('wann'));
|
|
if (Number.isNaN(wann.getTime())) throw new Error('Das ist kein Zeitpunkt.');
|
|
await handeln(wann.toISOString());
|
|
},
|
|
});
|
|
|
|
bereich.append(abschnitt('Zu prüfen', [alleHaken, freigeben, planen]), liste);
|
|
void laden();
|
|
|
|
/** Die Auswahl — oder alles, wenn nichts angehakt ist. */
|
|
function betroffene(): Pruefeintrag[] {
|
|
const angehakt = stand.filter(e => gewaehlt.has(e.id));
|
|
return angehakt.length > 0 ? angehakt : stand;
|
|
}
|
|
|
|
async function handeln(wann: string | undefined): Promise<void> {
|
|
const ids = betroffene().flatMap(e => e.ids);
|
|
if (ids.length === 0) return;
|
|
|
|
freigeben.disabled = true;
|
|
planen.disabled = true;
|
|
try {
|
|
await veroeffentlichen(ids, wann);
|
|
gewaehlt.clear();
|
|
await laden();
|
|
geaendert();
|
|
} catch (fehler) {
|
|
liste.prepend(fehlerzeile((fehler as Error).message));
|
|
} finally {
|
|
freigeben.disabled = false;
|
|
planen.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function laden(): Promise<void> {
|
|
try { stand = await zuPruefen(); } catch { bereich.classList.add('hidden'); return; }
|
|
|
|
if (stand.length === 0) { bereich.classList.add('hidden'); return; }
|
|
bereich.classList.remove('hidden');
|
|
|
|
// Was es nicht mehr gibt, bleibt nicht angehakt.
|
|
const da = new Set(stand.map(e => e.id));
|
|
[...gewaehlt].forEach(id => { if (!da.has(id)) gewaehlt.delete(id); });
|
|
|
|
zeichnen();
|
|
}
|
|
|
|
function zeichnen(): void {
|
|
alleKasten.checked = gewaehlt.size > 0 && gewaehlt.size === stand.length;
|
|
alleKasten.indeterminate = gewaehlt.size > 0 && gewaehlt.size < stand.length;
|
|
|
|
const anzahl = betroffene().reduce((n, e) => n + e.count, 0);
|
|
const wieViele = gewaehlt.size > 0 ? `${anzahl} gewählt` : `alle ${anzahl}`;
|
|
freigeben.textContent = `Veröffentlichen (${wieViele})`;
|
|
|
|
liste.innerHTML = '';
|
|
stand.forEach(e => liste.appendChild(zeileFuer(e)));
|
|
}
|
|
|
|
function zeileFuer(e: Pruefeintrag): HTMLElement {
|
|
const d = document.createElement('div');
|
|
d.className = 'einstellung pruefzeile';
|
|
|
|
const haken = document.createElement('input');
|
|
haken.type = 'checkbox';
|
|
haken.checked = gewaehlt.has(e.id);
|
|
haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
|
|
haken.onchange = () => {
|
|
if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id);
|
|
zeichnen();
|
|
};
|
|
|
|
const bild = document.createElement('div');
|
|
bild.className = 'pruef-bild';
|
|
const adresse = bildAdresse(e.posterImage);
|
|
if (adresse) {
|
|
const img = document.createElement('img');
|
|
img.src = adresse; img.alt = ''; img.loading = 'lazy';
|
|
bild.appendChild(img);
|
|
}
|
|
|
|
const text = document.createElement('div');
|
|
text.className = 'zeile';
|
|
text.style.flex = '1';
|
|
|
|
const geplant = e.publishAt ? new Date(e.publishAt) : null;
|
|
const was = e.kind === 'series'
|
|
? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}`
|
|
: null;
|
|
const lage = geplant
|
|
? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
|
|
: e.kind === 'series' && e.count > 1 && !e.publishAt
|
|
? 'Wartet'
|
|
: 'Wartet auf Freigabe';
|
|
// Ohne Zuordnung fehlen Titel und Bilder — dann ist nicht die Freigabe
|
|
// das Nächste, sondern die Zuordnung.
|
|
const hinweis = e.matched ? null : 'noch keinem Titel zugeordnet';
|
|
|
|
text.innerHTML = `<strong>${esc(e.title)}${e.year ? ` (${e.year})` : ''}</strong>`
|
|
+ `<span class="winzig">${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}</span>`;
|
|
|
|
const nur = knopf(geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen', 'btn');
|
|
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; }
|
|
};
|
|
|
|
const oben = document.createElement('div');
|
|
oben.className = 'knopfreihe';
|
|
oben.append(haken, bild, text, nur);
|
|
|
|
if (geplant) {
|
|
const zurueck = knopf('Zurückziehen', 'btn');
|
|
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; }
|
|
};
|
|
oben.appendChild(zurueck);
|
|
}
|
|
|
|
d.appendChild(oben);
|
|
|
|
if (e.path) {
|
|
const pfad = document.createElement('div');
|
|
pfad.className = 'winzig';
|
|
pfad.textContent = e.path;
|
|
d.appendChild(pfad);
|
|
}
|
|
|
|
return d;
|
|
}
|
|
|
|
/** Die Form, die `datetime-local` erwartet — Ortszeit ohne Zone. */
|
|
function inEingabeform(d: Date): string {
|
|
const z = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`
|
|
+ `T${z(d.getHours())}:${z(d.getMinutes())}`;
|
|
}
|
|
|
|
return bereich;
|
|
}
|
|
|
|
/* ---------- Protokolle ---------- */
|
|
|
|
/** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS.
|
|
*
|
|
* Das Containerprotokoll ist nach einem Neustart weg und nur über SSH
|
|
* erreichbar — die Frage „warum wurde der Film gestern Nacht nicht erkannt?"
|
|
* lässt sich damit nicht beantworten. */
|
|
function protokollbereich(): HTMLElement {
|
|
const bereich = document.createElement('section');
|
|
bereich.appendChild(abschnitt('Protokolle'));
|
|
|
|
const liste = document.createElement('div');
|
|
liste.className = 'einstellungen';
|
|
liste.innerHTML = '<div class="spinner"></div>';
|
|
bereich.appendChild(liste);
|
|
|
|
const anzeige = document.createElement('pre');
|
|
anzeige.className = 'protokoll hidden';
|
|
bereich.appendChild(anzeige);
|
|
|
|
void (async () => {
|
|
let tage;
|
|
try { tage = await protokolltage(); } catch { bereich.classList.add('hidden'); return; }
|
|
|
|
liste.innerHTML = '';
|
|
if (tage.length === 0) {
|
|
liste.appendChild(leer('Noch nichts protokolliert.'));
|
|
return;
|
|
}
|
|
|
|
tage.forEach(t => {
|
|
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 datum = new Date(t.date + 'T12:00:00').toLocaleDateString('de-CH',
|
|
{ weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
|
|
text.innerHTML = `<strong>${esc(datum)}</strong>`
|
|
+ `<span class="winzig">${Math.max(1, Math.round(t.bytes / 1024))} KB</span>`;
|
|
oben.appendChild(text);
|
|
|
|
const lesen = knopf('Ansehen', 'btn');
|
|
lesen.onclick = () => void zeigen(t.date, false);
|
|
const nurFehler = knopf('Nur Fehler', 'btn');
|
|
nurFehler.onclick = () => void zeigen(t.date, true);
|
|
const holen = document.createElement('a');
|
|
holen.className = 'btn';
|
|
holen.href = protokollAdresse(t.date);
|
|
holen.download = `flaro-${t.date}.log`;
|
|
holen.textContent = 'Herunterladen';
|
|
oben.append(lesen, nurFehler, holen);
|
|
|
|
d.appendChild(oben);
|
|
liste.appendChild(d);
|
|
});
|
|
})();
|
|
|
|
async function zeigen(tag: string, nurFehler: boolean): Promise<void> {
|
|
anzeige.classList.remove('hidden');
|
|
anzeige.textContent = 'Wird geladen…';
|
|
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 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');
|
|
anzeige.scrollTop = anzeige.scrollHeight;
|
|
} catch (fehler) {
|
|
anzeige.textContent = `Konnte nicht gelesen werden: ${(fehler as Error).message}`;
|
|
}
|
|
}
|
|
|
|
return bereich;
|
|
}
|
|
|
|
/* ---------- Nicht mehr gefunden ---------- */
|
|
|
|
/** Was in der Mediathek steht, aber nicht mehr auf der Platte liegt.
|
|
*
|
|
* **Erscheint nur, wenn es etwas zu melden gibt.** Ein dauerhaft leerer
|
|
* Abschnitt wäre Rauschen; ein gefüllter ist eine Nachricht.
|
|
*
|
|
* Der Scan löscht nichts mehr von selbst. Eine kurz nicht eingehängte Platte
|
|
* sah vorher aus wie „die Filme sind weg" und kostete Titel samt
|
|
* Sehfortschritt aller Benutzer. Beides ist für den Scan nicht zu
|
|
* unterscheiden — also entscheidet hier ein Mensch. */
|
|
function vermisstenbereich(geaendert: () => void): HTMLElement {
|
|
const bereich = document.createElement('section');
|
|
bereich.className = 'hidden';
|
|
|
|
const liste = document.createElement('div');
|
|
liste.className = 'einstellungen';
|
|
|
|
const alle = knopf('Alle entfernen', 'btn btn-gefahr');
|
|
alle.onclick = () => nachfragen({
|
|
titel: 'Alle vermissten Einträge entfernen',
|
|
text: 'Entfernt alle Dateien, die nicht mehr gefunden werden, aus der '
|
|
+ 'Mediathek — mitsamt den Titeln, von denen keine Datei übrig bleibt. '
|
|
+ 'Auf der Platte wird nichts gelöscht.',
|
|
bestaetigen: 'Entfernen',
|
|
tun: async () => { await vermisstesEntfernen(); await laden(); geaendert(); },
|
|
});
|
|
|
|
bereich.append(abschnitt('Nicht mehr gefunden', [alle]), liste);
|
|
void laden();
|
|
|
|
async function laden(): Promise<void> {
|
|
let fehlend: VermissteDatei[];
|
|
try { fehlend = await vermisste(); } catch { bereich.classList.add('hidden'); return; }
|
|
|
|
if (fehlend.length === 0) { bereich.classList.add('hidden'); return; }
|
|
bereich.classList.remove('hidden');
|
|
|
|
liste.innerHTML = '';
|
|
fehlend.forEach(v => {
|
|
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 seit = new Date(v.missingSince).toLocaleDateString('de-CH',
|
|
{ day: '2-digit', month: 'long', year: 'numeric' });
|
|
text.innerHTML = `<strong>${esc(v.title ?? '(ohne Titel)')}`
|
|
+ `${v.versionLabel ? ` · ${esc(v.versionLabel)}` : ''}</strong>`
|
|
+ `<span class="winzig">${esc(v.libraryName)} · vermisst seit ${esc(seit)}</span>`;
|
|
oben.appendChild(text);
|
|
|
|
const weg = knopf('Entfernen', 'btn');
|
|
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; }
|
|
};
|
|
oben.appendChild(weg);
|
|
d.appendChild(oben);
|
|
|
|
const pfad = document.createElement('div');
|
|
pfad.className = 'winzig';
|
|
pfad.textContent = v.path;
|
|
d.appendChild(pfad);
|
|
|
|
liste.appendChild(d);
|
|
});
|
|
}
|
|
|
|
return bereich;
|
|
}
|
|
|
|
/* ---------- Bibliotheken ---------- */
|
|
|
|
function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement {
|
|
/** Die Liste, wie sie gerade steht — für das Umsortieren. */
|
|
let alleBibliotheken: Bibliothek[] = [];
|
|
|
|
/** Schiebt einen Eintrag und schickt die ganze neue Reihenfolge. */
|
|
async function schieben(von: number, nach: number): Promise<void> {
|
|
if (nach < 0 || nach >= alleBibliotheken.length) return;
|
|
|
|
const neu = [...alleBibliotheken];
|
|
const [eintrag] = neu.splice(von, 1);
|
|
neu.splice(nach, 0, eintrag!);
|
|
|
|
await reihenfolgeSetzen(neu.map(b => b.id));
|
|
await laden();
|
|
geaendert();
|
|
}
|
|
|
|
const bereich = document.createElement('section');
|
|
|
|
const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary');
|
|
anlegen.onclick = () => dialog({
|
|
titel: 'Mediathek hinzufügen',
|
|
felder: [
|
|
{ name: 'name', beschriftung: 'Name', platzhalter: 'etwa Filme' },
|
|
{
|
|
name: 'art', beschriftung: 'Art',
|
|
auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }],
|
|
},
|
|
{
|
|
name: 'pfad', beschriftung: 'Ordner', art: 'ordner',
|
|
platzhalter: '/media/Filme',
|
|
hinweis: 'Der Pfad im Container, nicht der auf dem NAS — '
|
|
+ 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.',
|
|
},
|
|
{
|
|
name: 'pruefen', beschriftung: 'Neue Funde erst prüfen', schalter: true,
|
|
hinweis: 'Aus heisst: gefundene Titel erscheinen sofort. An heisst: sie '
|
|
+ 'warten unter „Zu prüfen", bis du sie freigibst — Zeit, die Zuordnung '
|
|
+ 'und die Bilder zu kontrollieren oder einen Termin zu setzen.',
|
|
},
|
|
],
|
|
bestaetigen: 'Anlegen',
|
|
tun: async wert => {
|
|
await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'),
|
|
wert('pruefen') === 'true');
|
|
await laden();
|
|
geaendert();
|
|
},
|
|
});
|
|
|
|
bereich.appendChild(abschnitt('Mediatheken', [anlegen]));
|
|
|
|
const liste = document.createElement('div');
|
|
liste.className = 'einstellungen';
|
|
bereich.appendChild(liste);
|
|
void laden();
|
|
|
|
async function laden(): Promise<void> {
|
|
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
|
|
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
|
|
// und bei einem Takt im Hintergrund blinkt sie dauernd.
|
|
if (liste.childElementCount === 0) liste.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const alle = await bibliotheken();
|
|
liste.innerHTML = '';
|
|
if (alle.length === 0) {
|
|
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
|
return;
|
|
}
|
|
alleBibliotheken = alle;
|
|
alle.forEach((b, i) => liste.appendChild(block(b, i)));
|
|
} catch (fehler) {
|
|
liste.innerHTML = '';
|
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
|
}
|
|
}
|
|
|
|
function block(b: Bibliothek, platz: number): HTMLElement {
|
|
const d = document.createElement('div');
|
|
d.className = 'einstellung';
|
|
|
|
const serie = b.kind === 'Shows';
|
|
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
|
|
const anzahl = b.itemCount == null ? ''
|
|
: ` · ${b.itemCount} ` + (b.itemCount === 1
|
|
? (serie ? 'Serie' : 'Film')
|
|
: (serie ? 'Serien' : 'Filme'));
|
|
|
|
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
|
|
// man bis zur Prüfliste weiterscrollt.
|
|
const wartet = b.pendingCount > 0
|
|
? ` · ${b.pendingCount} wartet${b.pendingCount === 1 ? '' : 'en'} auf Freigabe`
|
|
: '';
|
|
|
|
const kopf = document.createElement('div');
|
|
kopf.className = 'zeile';
|
|
kopf.innerHTML = `<strong>${esc(b.name)}</strong>`
|
|
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}${esc(wartet)}</span>`;
|
|
d.appendChild(kopf);
|
|
|
|
const stand = document.createElement('div');
|
|
stand.className = 'hinweis';
|
|
|
|
const los = knopf('Scannen', 'btn');
|
|
los.onclick = async () => {
|
|
los.disabled = true;
|
|
try { await scanStarten(b.id); await verfolgen(true); }
|
|
catch (fehler) { stand.textContent = (fehler as Error).message; }
|
|
los.disabled = false;
|
|
};
|
|
|
|
const auffrischen = knopf('Metadaten auffrischen', 'btn');
|
|
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
|
|
+ 'nötig nach einer Sprachänderung.';
|
|
auffrischen.onclick = () => nachfragen({
|
|
titel: `Metadaten von „${b.name}" auffrischen?`,
|
|
text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, '
|
|
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
|
|
+ 'Bei vielen Titeln dauert es.',
|
|
bestaetigen: 'Auffrischen',
|
|
tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(true); },
|
|
});
|
|
|
|
const umbenennen = knopf('Umbenennen', 'btn');
|
|
umbenennen.onclick = () => dialog({
|
|
titel: 'Mediathek umbenennen',
|
|
felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }],
|
|
bestaetigen: 'Speichern',
|
|
tun: async wert => {
|
|
await bibliothekUmbenennen(b.id, wert('name'));
|
|
await laden();
|
|
geaendert();
|
|
},
|
|
});
|
|
|
|
const entfernen = knopf('Entfernen', 'btn');
|
|
entfernen.onclick = () => nachfragen({
|
|
titel: `„${b.name}" entfernen?`,
|
|
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
|
|
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
|
|
bestaetigen: 'Entfernen',
|
|
tun: async () => { await bibliothekLoeschen(b.id); await laden(); geaendert(); },
|
|
});
|
|
|
|
/** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */
|
|
/// Verfolgt einen Lauf bis zum Ende.
|
|
///
|
|
/// **`melden` entscheidet, ob am Schluss `geaendert()` gerufen wird.**
|
|
/// Beim Aufbau der Seite ist es `false`: ein längst beendeter Lauf meldete
|
|
/// sonst „fertig", liess neu zeichnen, und der Neuaufbau verfolgte wieder
|
|
/// denselben beendeten Lauf — eine Endlosschleife, die sich als vier
|
|
/// dauernd drehende Spinner zeigte.
|
|
async function verfolgen(melden: boolean): Promise<void> {
|
|
for (let i = 0; i < 900; i++) {
|
|
if (signal.aborted) return;
|
|
const s = await scanstand(b.id);
|
|
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier
|
|
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
|
|
if (s.state === 'running') stand.textContent = s.step ?? 'Wird durchsucht…';
|
|
else if (s.state === 'enriching') stand.textContent = s.step ?? 'Metadaten werden geholt…';
|
|
else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; }
|
|
|
|
// **Die Zahl mitlaufen lassen, nicht erst am Ende.** Der Scan schreibt
|
|
// laufend; wer dabei zusieht, soll das auch sehen. Alle fünf Runden,
|
|
// nicht jede: ein Abruf je Sekunde für eine Zahl wäre Verschwendung.
|
|
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`);
|
|
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);
|
|
if (s.missing) teile.push(`${s.missing} nicht mehr gefunden`);
|
|
stand.textContent = teile.join(' · ');
|
|
if (melden) { await laden(); geaendert(); }
|
|
return;
|
|
}
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
}
|
|
}
|
|
|
|
// Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
|
|
// entschieden wurde, soll kein Urteil fürs Leben sein.
|
|
const pruefung = knopf(b.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
|
|
pruefung.onclick = async () => {
|
|
pruefung.disabled = true;
|
|
try {
|
|
await pruefungSetzen(b.id, !b.reviewNewItems);
|
|
await laden();
|
|
} catch (fehler) {
|
|
d.appendChild(fehlerzeile((fehler as Error).message));
|
|
pruefung.disabled = false;
|
|
}
|
|
};
|
|
|
|
// **Umsortieren über zwei Pfeile, nicht über Ziehen.**
|
|
//
|
|
// Ziehen und Ablegen ist schöner, aber mit der Tastatur unbedienbar und
|
|
// auf dem Telefon ein Glücksspiel. Zwei Knöpfe kann jeder bedienen, und
|
|
// der Server bekommt die ganze Liste statt eines „eins hoch" — zwei
|
|
// Benutzer, die gleichzeitig schieben, kämen sonst in einen Zustand, den
|
|
// keiner von beiden wollte.
|
|
const hoch = knopf('', 'btn rund');
|
|
hoch.innerHTML = icons.hoch;
|
|
hoch.setAttribute('aria-label', `„${b.name}“ nach oben`);
|
|
hoch.disabled = platz === 0;
|
|
hoch.onclick = () => void schieben(platz, platz - 1);
|
|
|
|
const runter = knopf('', 'btn rund');
|
|
runter.innerHTML = icons.hoch;
|
|
runter.style.transform = 'rotate(180deg)';
|
|
runter.setAttribute('aria-label', `„${b.name}“ nach unten`);
|
|
runter.disabled = platz === alleBibliotheken.length - 1;
|
|
runter.onclick = () => void schieben(platz, platz + 1);
|
|
|
|
const knoepfe = document.createElement('div');
|
|
knoepfe.className = 'knopfreihe';
|
|
knoepfe.append(hoch, runter, los, auffrischen, pruefung, umbenennen, 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
|
|
// geht, ohne dass man erst weiterscrollen muss.
|
|
if (b.pendingCount > 0) {
|
|
const freigeben = knopf(
|
|
`${b.pendingCount} ${b.pendingCount === 1 ? 'Titel' : 'Titel'} freigeben`,
|
|
'btn btn-primary');
|
|
freigeben.onclick = async () => {
|
|
freigeben.disabled = true;
|
|
try {
|
|
const bericht = await mediathekFreigeben(b.id);
|
|
stand.textContent = `${bericht.published} freigegeben.`;
|
|
await laden();
|
|
geaendert();
|
|
} catch (fehler) {
|
|
d.appendChild(fehlerzeile((fehler as Error).message));
|
|
freigeben.disabled = false;
|
|
}
|
|
};
|
|
knoepfe.appendChild(freigeben);
|
|
}
|
|
d.append(knoepfe, stand);
|
|
void verfolgen(false);
|
|
return d;
|
|
}
|
|
|
|
return bereich;
|
|
}
|
|
|
|
/* ---------- Benutzer ---------- */
|
|
|
|
function benutzerbereich(): HTMLElement {
|
|
const bereich = document.createElement('section');
|
|
|
|
const anlegen = knopf('Benutzer hinzufügen', 'btn');
|
|
anlegen.onclick = () => dialog({
|
|
titel: 'Benutzer hinzufügen',
|
|
felder: [
|
|
{ name: 'name', beschriftung: 'Benutzername' },
|
|
{
|
|
name: 'passwort', beschriftung: 'Passwort', art: 'password',
|
|
hinweis: 'Mindestens acht Zeichen.',
|
|
},
|
|
{ name: 'admin', beschriftung: 'Administrator', schalter: true },
|
|
],
|
|
bestaetigen: 'Anlegen',
|
|
tun: async wert => {
|
|
await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true');
|
|
await laden();
|
|
},
|
|
});
|
|
|
|
bereich.appendChild(abschnitt('Benutzer', [anlegen]));
|
|
|
|
const liste = document.createElement('div');
|
|
liste.className = 'einstellungen';
|
|
bereich.appendChild(liste);
|
|
void laden();
|
|
|
|
async function laden(): Promise<void> {
|
|
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
|
|
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
|
|
// und bei einem Takt im Hintergrund blinkt sie dauernd.
|
|
if (liste.childElementCount === 0) liste.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const alle = await benutzer();
|
|
liste.innerHTML = '';
|
|
alle.forEach(b => {
|
|
const d = document.createElement('div');
|
|
d.className = 'einstellung';
|
|
d.innerHTML = `<div class="zeile"><strong>${esc(b.userName)}</strong>`
|
|
+ `<span class="winzig">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
|
|
liste.appendChild(d);
|
|
});
|
|
} catch (fehler) {
|
|
liste.innerHTML = '';
|
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
|
}
|
|
}
|
|
|
|
return bereich;
|
|
}
|
|
|
|
/* ---------- Server ---------- */
|
|
|
|
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()]);
|
|
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,
|
|
// also dort, wo niemand nach einer Sprache sucht. Wer die Mediathek auf
|
|
// Englisch vorfand, sah nur „Meine Sprache" und hielt das für die
|
|
// einzige Möglichkeit.
|
|
liste.appendChild(zeile(
|
|
'Sprache des Servers',
|
|
sprachen.find(x => x.wert === stand.language)?.text ?? stand.language,
|
|
'In dieser Sprache werden Titel und Beschreibungen geholt, und sie '
|
|
+ 'bestimmt die Altersfreigabe. Gilt für alle. Vorhandene Titel behalten '
|
|
+ 'ihre alte Sprache — dafür gibt es bei jeder Mediathek „Metadaten '
|
|
+ 'auffrischen".',
|
|
() => dialog({
|
|
titel: 'Sprache des Servers',
|
|
text: 'Die Umstellung wirkt auf neue Funde sofort. Für den vorhandenen '
|
|
+ 'Bestand drückst du danach bei jeder Mediathek „Metadaten auffrischen" '
|
|
+ '— sonst behalten die Titel ihre alte Sprache.',
|
|
felder: [{
|
|
name: 'sprache', beschriftung: 'Sprache', auswahl: sprachen, wert: stand.language,
|
|
}],
|
|
bestaetigen: 'Übernehmen',
|
|
tun: async wert => {
|
|
await metadatenSetzen({ language: wert('sprache') });
|
|
location.reload();
|
|
},
|
|
})));
|
|
|
|
// **Die eigene Sprache, getrennt von der des Servers.** Die obere
|
|
// bestimmt, was geholt wird; diese nur, was man selbst zu sehen bekommt.
|
|
liste.appendChild(zeile(
|
|
'Meine Sprache',
|
|
eigeneSprache ? (sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache)
|
|
: 'Wie der Server',
|
|
'Gilt nur für dich. Was nicht übersetzt vorliegt, erscheint weiterhin in '
|
|
+ 'der Sprache des Servers.',
|
|
() => dialog({
|
|
titel: 'Meine Sprache',
|
|
text: 'Die Metadaten liegen in der Sprache des Servers vor. Eine andere '
|
|
+ 'zu wählen heisst: du siehst, was übersetzt vorliegt — und für den '
|
|
+ 'Rest die Serversprache. Ein Administrator kann die Übersetzungen '
|
|
+ 'unten für alle Titel auf einmal holen.',
|
|
felder: [{
|
|
name: 'sprache', beschriftung: 'Sprache',
|
|
auswahl: [{ wert: '', text: 'Wie der Server' }, ...sprachen],
|
|
wert: eigeneSprache ?? '',
|
|
}],
|
|
bestaetigen: 'Übernehmen',
|
|
tun: async wert => {
|
|
const neu = wert('sprache');
|
|
await spracheSetzen(neu || null);
|
|
location.reload();
|
|
},
|
|
})));
|
|
|
|
liste.appendChild(zeile(
|
|
'Übersetzungen holen',
|
|
'Füllt eine zweite Sprache für alle Titel auf',
|
|
'Kostet je Titel eine Anfrage an TMDB und dauert entsprechend. '
|
|
+ 'Danach sehen Benutzer mit dieser Sprache die übersetzten Titel.',
|
|
() => dialog({
|
|
titel: 'Übersetzungen holen',
|
|
felder: [{
|
|
name: 'sprache', beschriftung: 'Für welche Sprache',
|
|
auswahl: sprachen, wert: eigeneSprache ?? 'en-US',
|
|
}],
|
|
bestaetigen: 'Holen',
|
|
tun: async wert => {
|
|
const bericht = await uebersetzungenHolen(wert('sprache'));
|
|
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));
|
|
}
|
|
})();
|
|
|
|
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;
|
|
}
|
|
|
|
liste.innerHTML = '<div class="spinner"></div>';
|
|
return bereich;
|
|
}
|