Einstellungen hat jeder, Verwaltung nicht
Die Seite hiess „Verwaltung" und war für alle anderen gar nicht erreichbar — „Meine Sprache" lag dadurch hinter einer Tür, die den meisten verschlossen ist. Jetzt heisst sie **Einstellungen**, steht jedem offen und hat zwei beschriftete Gruppen: oben, was einem selbst gehört (Mein Konto, Geschaut), darunter — nur für Administratoren — **Verwaltung**. Die Überschrift ist hier die halbe Aussage: sie sagt ohne weiteren Satz, wem der untere Teil gehört. In der Hauptleiste steht der Eintrag jetzt unten, ohne Gruppenüberschrift. Eine Überschrift über einem einzigen Eintrag ist eine Überschrift über nichts, und am Fuss sucht man Einstellungen ohnehin. `#/profil` und `#/verwaltung/…` leiten weiter; ein Lesezeichen merkt davon nichts. **Ein kurzes Menü am Bild.** Erst meldete der Klick ab — ohne Rückfrage. Dann führte er auf die eigene Seite, und zum Abmelden musste man erst dorthin. Beides sind zwei Punkte; ein Menü mit zwei Punkten ist ehrlicher als eine Tür, hinter der eine zweite liegt. **Und das Profilbild lässt sich zuschneiden.** Ein Profilbild ist rund, was hochgeladen wird selten quadratisch — den Ausschnitt dem Browser zu überlassen hiesse: mittig, und das trifft bei einem Porträt den Hals. Der Dialog zeigt den runden Ausschnitt, man zieht und vergrössert, und hochgeladen wird genau das: ein Quadrat von 512 Pixeln. Gemessen, es kommt auch so an. **Dazu die Spielerleiste.** Beide Knopfgruppen durften schrumpfen, ihre Knöpfe aber nicht — wird es eng, steht der äusserste halb hinter dem Fensterrand. Rechts sind es nur Knöpfe, da gibt es nichts nachzugeben; links schon. Dazu seitlich `env(safe-area-inset-*)`, damit auf einem Gerät mit runden Ecken nichts darunter liegt. Gemessen: vorher riss es ab 420 Pixeln, jetzt bis 320 nicht mehr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9c2e2b5f47
commit
327e2e9195
@@ -0,0 +1,62 @@
|
||||
import { benutzer, benutzerAnlegen } from '../../api';
|
||||
import { esc, knopf, fehlerzeile, meldung } from '../../util';
|
||||
import { dialog } from '../../dialog';
|
||||
|
||||
import { abschnitt } from './teile';
|
||||
|
||||
/** Konten auf diesem Server. */
|
||||
|
||||
/* ---------- Benutzer ---------- */
|
||||
|
||||
export 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(meldung(fehler)));
|
||||
}
|
||||
}
|
||||
|
||||
return bereich;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { statistik, bildAdresse, type Statistik } from '../../api';
|
||||
import { esc, fehlerzeile, meldung } from '../../util';
|
||||
|
||||
/** Was man geschaut hat — die eigenen Zahlen, und nur die eigenen.
|
||||
*
|
||||
* **Jeder sieht sich selbst, auch ein Administrator.** Das ist die Linie, die
|
||||
* im Server schon steht: wer wann welchen Film anschaut, ist eine Auskunft
|
||||
* über Menschen und nicht über den Server.
|
||||
*/
|
||||
export function geschautseite(): HTMLElement {
|
||||
const wurzel = document.createElement('div');
|
||||
wurzel.className = 'profil-zahlen';
|
||||
void zahlenLaden(wurzel);
|
||||
return wurzel;
|
||||
}
|
||||
|
||||
async function zahlenLaden(ziel: HTMLElement): Promise<void> {
|
||||
ziel.innerHTML = '<div class="spinner"></div>';
|
||||
let stand: Statistik;
|
||||
try {
|
||||
stand = await statistik();
|
||||
} catch (fehler) {
|
||||
ziel.innerHTML = '';
|
||||
ziel.appendChild(fehlerzeile(meldung(fehler)));
|
||||
return;
|
||||
}
|
||||
|
||||
ziel.innerHTML = '';
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Geschaut';
|
||||
ziel.appendChild(h);
|
||||
|
||||
// **Drei Zahlen, und jede sagt, woher sie kommt.**
|
||||
//
|
||||
// Die Gesamtzeit stammt aus dem Protokoll und fängt an dem Tag an, an dem
|
||||
// es eingebaut wurde. Die Zahl gesehener Titel steht seit jeher fest. Das
|
||||
// zu einer Zahl zu verrühren hiesse, die eine zu behaupten.
|
||||
const kacheln = document.createElement('div');
|
||||
kacheln.className = 'zahlenreihe';
|
||||
kacheln.append(
|
||||
zahl(stunden(stand.loggedSeconds), 'aufgezeichnet',
|
||||
stand.since
|
||||
? `seit ${new Date(stand.since).toLocaleDateString('de-CH', { dateStyle: 'medium' })}`
|
||||
: 'noch nichts aufgezeichnet'),
|
||||
zahl(String(stand.watchedTitles), stand.watchedTitles === 1 ? 'Titel gesehen' : 'Titel gesehen',
|
||||
'mit Häkchen, auch von früher'),
|
||||
zahl(stunden(stand.watchedRuntimeSeconds), 'Laufzeit davon',
|
||||
'die Länge aller abgehakten Titel'),
|
||||
);
|
||||
ziel.appendChild(kacheln);
|
||||
|
||||
ziel.appendChild(wochenkurve(stand));
|
||||
|
||||
if (stand.genres.length > 0) ziel.appendChild(genreliste(stand));
|
||||
if (stand.recent.length > 0) ziel.appendChild(zuletztliste(stand));
|
||||
}
|
||||
|
||||
/** Die Wochenkurve als Balken — zwölf Werte brauchen keine Bibliothek. */
|
||||
|
||||
function wochenkurve(stand: Statistik): HTMLElement {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'wochen';
|
||||
|
||||
const hoechstwert = Math.max(...stand.weeks.map(w => w.seconds), 1);
|
||||
stand.weeks.forEach(w => {
|
||||
const saeule = document.createElement('div');
|
||||
saeule.className = 'woche';
|
||||
const balken = document.createElement('div');
|
||||
balken.className = 'woche-balken';
|
||||
// **In Pixeln, nicht in Prozent.** Eine Prozenthöhe in einer Flexspalte
|
||||
// löst sich nicht gegen das auf, was man erwartet — gemessen: aus „2 %"
|
||||
// wurden 48 Pixel. Hundert Pixel sind der Platz, der da ist.
|
||||
// Mindestens zwei, damit eine leere Woche als leer zu sehen ist und
|
||||
// nicht als fehlend.
|
||||
balken.style.height = `${Math.max(2, Math.round((w.seconds / hoechstwert) * 100))}px`;
|
||||
// **`woche-leer`, nicht `leer`.** Den kurzen Namen gibt es schon: er
|
||||
// gehört der Fläche „hier ist nichts" und bringt 24 Pixel Polster mit.
|
||||
// Der Balken wurde dadurch 48 Pixel hoch, egal welche Höhe dastand.
|
||||
if (w.seconds === 0) balken.classList.add('woche-leer');
|
||||
const datum = new Date(w.start);
|
||||
saeule.title = `Woche ab ${datum.toLocaleDateString('de-CH', { dateStyle: 'medium' })}: `
|
||||
+ (w.seconds > 0 ? stunden(w.seconds) : 'nichts');
|
||||
const marke = document.createElement('span');
|
||||
marke.className = 'woche-marke';
|
||||
marke.textContent = datum.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' });
|
||||
saeule.append(balken, marke);
|
||||
block.appendChild(saeule);
|
||||
});
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function genreliste(stand: Statistik): HTMLElement {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'genres';
|
||||
const groesste = Math.max(...stand.genres.map(g => g.seconds), 1);
|
||||
stand.genres.forEach(g => {
|
||||
const zeileG = document.createElement('div');
|
||||
zeileG.className = 'genre';
|
||||
zeileG.innerHTML = `<span class="genre-name">${esc(g.name)}</span>`
|
||||
+ `<span class="genre-balken"><i style="width:${Math.round((g.seconds / groesste) * 100)}%"></i></span>`
|
||||
+ `<span class="genre-wert">${esc(stunden(g.seconds))}</span>`;
|
||||
block.appendChild(zeileG);
|
||||
});
|
||||
return block;
|
||||
}
|
||||
|
||||
function zuletztliste(stand: Statistik): HTMLElement {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'zuletzt';
|
||||
|
||||
const h = document.createElement('h3');
|
||||
h.textContent = 'Zuletzt gesehen';
|
||||
block.appendChild(h);
|
||||
|
||||
stand.recent.slice(0, 8).forEach(e => {
|
||||
const k = document.createElement('button');
|
||||
k.type = 'button';
|
||||
k.className = 'zuletzt-zeile';
|
||||
const adresse = bildAdresse(e.posterImage);
|
||||
k.innerHTML = (adresse ? `<img src="${adresse}" alt="">` : '<span class="platzhalter"></span>')
|
||||
+ `<span class="zuletzt-text"><strong>${esc(e.title)}</strong>`
|
||||
+ (e.subTitle ? `<span class="winzig">${esc(e.subTitle)}</span>` : '')
|
||||
+ `</span>`
|
||||
+ `<span class="winzig">${esc(new Date(e.at).toLocaleDateString('de-CH', { dateStyle: 'medium' }))}</span>`;
|
||||
k.onclick = () => { location.hash = `#/${e.kind === 'episode' ? 'serien' : 'filme'}/${e.id}`; };
|
||||
block.appendChild(k);
|
||||
});
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function zahl(wert: string, was: string, hinweis: string): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'zahlkachel';
|
||||
d.innerHTML = `<strong>${esc(wert)}</strong><span>${esc(was)}</span>`
|
||||
+ `<span class="winzig">${esc(hinweis)}</span>`;
|
||||
return d;
|
||||
}
|
||||
|
||||
/* ---------- Bild und Name ---------- */
|
||||
|
||||
/** „12 Std. 30 Min." — oder „0 Min.", solange nichts dasteht. */
|
||||
function stunden(sekunden: number): string {
|
||||
if (sekunden < 60) return '0 Min.';
|
||||
const h = Math.floor(sekunden / 3600);
|
||||
const m = Math.round((sekunden % 3600) / 60);
|
||||
return h > 0 ? `${h} Std. ${m} Min.` : `${m} Min.`;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { istAdmin, type Bibliothek } from '../../api';
|
||||
import { icon, type IconName } from '../../icons';
|
||||
import { kontoseite } from './konto';
|
||||
import { geschautseite } from './geschaut';
|
||||
import { dashboard, serverbereich, speicherbereich } from './uebersicht';
|
||||
import { bibliotheksbereich, vermisstenbereich } from './mediatheken';
|
||||
import { pruefbereich } from './pruefung';
|
||||
import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung';
|
||||
import { wiedergabebereich } from './wiedergabe';
|
||||
import { sprachbereich } from './metadaten';
|
||||
import { benutzerbereich } from './benutzer';
|
||||
import { protokollbereich } from './protokolle';
|
||||
|
||||
/** Welcher Teil der Einstellungen offen ist. */
|
||||
export type Bereich =
|
||||
| 'konto' | 'geschaut'
|
||||
| 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
|
||||
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'protokolle';
|
||||
|
||||
type Eintrag = { wert: Bereich; text: string; bild: IconName };
|
||||
|
||||
/** Was jedem gehört — das eigene Konto und die eigenen Zahlen. */
|
||||
const MEINS: Eintrag[] = [
|
||||
{ wert: 'konto', text: 'Mein Konto', bild: 'person' },
|
||||
{ wert: 'geschaut', text: 'Geschaut', bild: 'hakenKreis' },
|
||||
];
|
||||
|
||||
/** Was dem Server gehört. Nur Administratoren sehen diese Gruppe. */
|
||||
const SERVER: Eintrag[] = [
|
||||
{ wert: 'uebersicht', text: 'Übersicht', bild: 'verwaltung' },
|
||||
{ wert: 'mediatheken', text: 'Mediatheken', bild: 'filme' },
|
||||
{ wert: 'pruefung', text: 'Zu prüfen', bild: 'haken' },
|
||||
{ wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' },
|
||||
{ wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' },
|
||||
{ wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
|
||||
{ wert: 'benutzer', text: 'Benutzer', bild: 'serien' },
|
||||
{ wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
|
||||
];
|
||||
|
||||
/** Macht aus einem Adressteil einen Bereich.
|
||||
*
|
||||
* Unbekanntes und Verbotenes werden zum eigenen Konto: das hat jeder, und es
|
||||
* ist die Seite, auf der man landen will, wenn man hier nichts zu suchen hat.
|
||||
*/
|
||||
export function alsBereich(wert: string | undefined, admin: boolean): Bereich {
|
||||
if (MEINS.some(b => b.wert === wert)) return wert as Bereich;
|
||||
if (admin && SERVER.some(b => b.wert === wert)) return wert as Bereich;
|
||||
return 'konto';
|
||||
}
|
||||
|
||||
/** Die Einstellungen.
|
||||
*
|
||||
* **Ein Ort, zwei Gruppen.** Oben steht, was einem selbst gehört — Bild,
|
||||
* Sprache, die eigenen Zahlen. Darunter, nur für Administratoren, was dem
|
||||
* Server gehört. Vorher hiess die ganze Seite „Verwaltung" und war für alle
|
||||
* anderen gar nicht erreichbar; „Meine Sprache" lag dadurch hinter einer Tür,
|
||||
* die den meisten verschlossen ist.
|
||||
*
|
||||
* **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.
|
||||
*
|
||||
* **Nur der offene Bereich wird gebaut.** Das ist nicht nur sparsamer — das
|
||||
* Abspielfenster der Übersicht fragt im Takt von fünf Sekunden nach, und die
|
||||
* Mediathekenliste beobachtet laufende Scans. Was nicht dasteht, fragt auch
|
||||
* nicht.
|
||||
*
|
||||
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
|
||||
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
|
||||
export function einstellungenseite(
|
||||
welcher: Bereich = 'konto',
|
||||
geaendert: (bekannt?: Bibliothek[]) => void = () => {},
|
||||
signal: AbortSignal = new AbortController().signal,
|
||||
): HTMLElement {
|
||||
const admin = istAdmin();
|
||||
const wurzel = document.createElement('div');
|
||||
wurzel.className = 'verwaltung';
|
||||
|
||||
const leiste = document.createElement('nav');
|
||||
leiste.className = 'verw-leiste';
|
||||
leiste.setAttribute('aria-label', 'Bereiche der Einstellungen');
|
||||
|
||||
gruppe('Einstellungen', MEINS);
|
||||
if (admin) gruppe('Verwaltung', SERVER);
|
||||
|
||||
const buehne = document.createElement('div');
|
||||
buehne.className = 'verw-buehne';
|
||||
inhalt().forEach(a => buehne.appendChild(a));
|
||||
|
||||
wurzel.append(leiste, buehne);
|
||||
return wurzel;
|
||||
|
||||
/** Eine beschriftete Gruppe — die Beschriftung ist hier die halbe Aussage.
|
||||
*
|
||||
* „Verwaltung" über den unteren Einträgen sagt ohne weiteren Satz, dass
|
||||
* diese dem Server gehören und nicht einem selbst. */
|
||||
function gruppe(name: string, eintraege: Eintrag[]): void {
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'verw-gruppe';
|
||||
kopf.textContent = name;
|
||||
leiste.appendChild(kopf);
|
||||
|
||||
eintraege.forEach(b => {
|
||||
const k = document.createElement('button');
|
||||
k.type = 'button';
|
||||
k.className = 'verw-eintrag' + (b.wert === welcher ? ' check' : '');
|
||||
k.appendChild(icon(b.bild));
|
||||
k.append(document.createTextNode(b.text));
|
||||
if (b.wert === welcher) k.setAttribute('aria-current', 'page');
|
||||
k.onclick = () => { location.hash = `#/einstellungen/${b.wert}`; };
|
||||
leiste.appendChild(k);
|
||||
});
|
||||
}
|
||||
|
||||
function inhalt(): HTMLElement[] {
|
||||
switch (welcher) {
|
||||
case 'geschaut':
|
||||
return [geschautseite()];
|
||||
case 'mediatheken':
|
||||
return [bibliotheksbereich(geaendert, signal), vermisstenbereich(geaendert)];
|
||||
case 'pruefung': {
|
||||
// **Die Prüfliste hört auf sich selbst.** Wird etwas freigegeben,
|
||||
// ändert sich der Bestand — und die Liste darüber muss nachziehen.
|
||||
const p = pruefbereich(() => { geaendert(); p.auffrischen(); });
|
||||
return [p.wurzel];
|
||||
}
|
||||
case 'zuordnung':
|
||||
return [ohneZuordnungBereich(), unzugeordnetbereich()];
|
||||
case 'wiedergabe':
|
||||
return [wiedergabebereich()];
|
||||
case 'metadaten':
|
||||
return [sprachbereich()];
|
||||
case 'benutzer':
|
||||
return [benutzerbereich()];
|
||||
case 'protokolle':
|
||||
return [protokollbereich()];
|
||||
case 'uebersicht':
|
||||
return [dashboard(signal), serverbereich(geaendert), speicherbereich()];
|
||||
default:
|
||||
return [kontoseite()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
sitzung, meinKonto, profilbildSetzen, profilbildEntfernen, bildAdresse,
|
||||
spracheSetzen, sprachen, abmelden, type Rolle,
|
||||
} from '../../api';
|
||||
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util';
|
||||
import { dialog, nachfragen } from '../../dialog';
|
||||
import { icon } from '../../icons';
|
||||
import { zuschneiden } from '../../bildzuschnitt';
|
||||
|
||||
/** Das eigene Konto: Bild, Name, Sprache — und Abmelden.
|
||||
*
|
||||
* **Warum es diese Seite gibt.** Ein Klick auf das eigene Bild meldete ab —
|
||||
* ohne Rückfrage, ohne Menü, ohne Weg zurück. Das ist die unfreundlichste
|
||||
* Deutung, die ein Bild mit dem eigenen Namen haben kann.
|
||||
*
|
||||
* **Und eine Einstellung wird dadurch überhaupt erst erreichbar.** „Meine
|
||||
* Sprache" stand mitten in der Verwaltung; die zeigt der Client nur
|
||||
* Administratoren, der Endpunkt erlaubte sie längst jedem. Wer kein Admin war,
|
||||
* konnte seine Sprache nirgends ändern.
|
||||
*/
|
||||
export function kontoseite(): HTMLElement {
|
||||
const wurzel = document.createElement('div');
|
||||
wurzel.className = 'profil';
|
||||
wurzel.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
void laden();
|
||||
return wurzel;
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
let konto: Rolle;
|
||||
try {
|
||||
konto = await meinKonto();
|
||||
} catch (fehler) {
|
||||
wurzel.innerHTML = '';
|
||||
wurzel.appendChild(fehlerzeile(meldung(fehler)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Die Sitzung mitziehen: die Kopfleiste liest aus ihr.
|
||||
if (sitzung.wer) Object.assign(sitzung.wer, konto);
|
||||
|
||||
wurzel.innerHTML = '';
|
||||
wurzel.append(kopf(konto), einstellungen(konto), abmeldeteil());
|
||||
}
|
||||
|
||||
function kopf(konto: Rolle): HTMLElement {
|
||||
const reihe = document.createElement('section');
|
||||
reihe.className = 'profil-kopf';
|
||||
|
||||
const bild = document.createElement('div');
|
||||
bild.className = 'profil-bild';
|
||||
const adresse = bildAdresse(konto.avatarImage ?? null);
|
||||
if (adresse) {
|
||||
const img = document.createElement('img');
|
||||
img.src = adresse;
|
||||
img.alt = '';
|
||||
// Kommt es nicht an, tritt der Buchstabe an seine Stelle — dieselbe
|
||||
// Vorsorge wie im Aufmacher.
|
||||
img.onerror = () => { img.remove(); bild.textContent = anfangsbuchstabe(konto.userName); };
|
||||
bild.appendChild(img);
|
||||
} else {
|
||||
bild.textContent = anfangsbuchstabe(konto.userName);
|
||||
}
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'profil-text';
|
||||
text.innerHTML = `<h1>${esc(konto.userName)}</h1>`
|
||||
+ `<span class="hinweis">${konto.isAdministrator ? 'Administrator' : 'Benutzer'}</span>`;
|
||||
|
||||
const waehlen = document.createElement('input');
|
||||
waehlen.type = 'file';
|
||||
waehlen.accept = 'image/*';
|
||||
waehlen.className = 'hidden';
|
||||
waehlen.onchange = () => void hochladen(waehlen.files?.[0] ?? null);
|
||||
|
||||
const reihen = document.createElement('div');
|
||||
reihen.className = 'knopfreihe';
|
||||
|
||||
const neu = knopf(konto.avatarImage ? 'Bild tauschen' : 'Bild wählen', 'btn');
|
||||
neu.prepend(icon('stift'));
|
||||
neu.onclick = () => waehlen.click();
|
||||
reihen.appendChild(neu);
|
||||
|
||||
if (konto.avatarImage) {
|
||||
const weg = knopf('Entfernen', 'btn');
|
||||
weg.onclick = () => void entfernen();
|
||||
reihen.appendChild(weg);
|
||||
}
|
||||
|
||||
text.appendChild(reihen);
|
||||
reihe.append(bild, text, waehlen);
|
||||
return reihe;
|
||||
}
|
||||
|
||||
async function hochladen(datei: File | null): Promise<void> {
|
||||
if (!datei) return;
|
||||
// **Vorher prüfen, nicht hinterher.** Fünf Megabyte sind die Grenze des
|
||||
// Servers; sie hier zu kennen spart einen Upload, der mit 400 endet.
|
||||
if (datei.size > 5 * 1024 * 1024) {
|
||||
hinweisZeigen('Das Bild ist grösser als 5 MB. Nimm ein kleineres.');
|
||||
return;
|
||||
}
|
||||
|
||||
// **Erst zuschneiden, dann hochladen.** Ein Profilbild ist rund; was
|
||||
// hochgeladen wird, ist selten quadratisch. Den Ausschnitt dem Browser zu
|
||||
// überlassen hiesse: mittig, und das trifft bei einem Porträt den Hals.
|
||||
const ausschnitt = await zuschneiden(datei);
|
||||
if (!ausschnitt) return; // Abgebrochen — das ist keine Meldung wert.
|
||||
|
||||
try {
|
||||
await profilbildSetzen(new File([ausschnitt], 'profil.jpg', { type: 'image/jpeg' }));
|
||||
hinweisZeigen('Bild gesetzt.');
|
||||
await laden();
|
||||
kopfleisteAuffrischen();
|
||||
} catch (fehler) {
|
||||
hinweisZeigen(meldung(fehler));
|
||||
}
|
||||
}
|
||||
|
||||
async function entfernen(): Promise<void> {
|
||||
try {
|
||||
await profilbildEntfernen();
|
||||
hinweisZeigen('Bild entfernt.');
|
||||
await laden();
|
||||
kopfleisteAuffrischen();
|
||||
} catch (fehler) {
|
||||
hinweisZeigen(meldung(fehler));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Einstellungen, die jedem gehören ---------- */
|
||||
|
||||
function einstellungen(konto: Rolle): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Einstellungen';
|
||||
bereich.appendChild(h);
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
|
||||
const gewaehlt = sprachen.find(s => s.wert === konto.language);
|
||||
liste.appendChild(zeile(
|
||||
'Meine Sprache',
|
||||
gewaehlt?.text ?? 'Wie der Server',
|
||||
'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.',
|
||||
() => dialog({
|
||||
titel: 'Meine Sprache',
|
||||
text: 'Gilt nur für dich. Was nicht übersetzt vorliegt, steht weiter in '
|
||||
+ 'der Sprache des Servers.',
|
||||
felder: [{
|
||||
name: 'sprache', beschriftung: 'Sprache',
|
||||
auswahl: [{ wert: '', text: 'Wie der Server' }, ...sprachen],
|
||||
wert: konto.language ?? '',
|
||||
}],
|
||||
bestaetigen: 'Übernehmen',
|
||||
tun: async wert => {
|
||||
await spracheSetzen(wert('sprache') || null);
|
||||
location.reload();
|
||||
},
|
||||
})));
|
||||
|
||||
|
||||
bereich.appendChild(liste);
|
||||
return bereich;
|
||||
}
|
||||
|
||||
/* ---------- Abmelden ---------- */
|
||||
|
||||
function abmeldeteil(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.className = 'profil-abmelden';
|
||||
|
||||
const k = knopf('Abmelden', 'btn btn-gefahr');
|
||||
k.prepend(icon('abmelden'));
|
||||
// **Mit Rückfrage.** Vorher war der Klick aufs Bild selbst das Abmelden —
|
||||
// eine Aktion ohne Weg zurück hinter einer Geste, die wie „mein Konto"
|
||||
// aussieht.
|
||||
k.onclick = () => nachfragen({
|
||||
titel: 'Abmelden',
|
||||
text: `Du meldest dich als „${sitzung.wer?.userName ?? ''}“ von diesem Gerät ab. `
|
||||
+ 'Sehstände und Favoriten bleiben auf dem Server.',
|
||||
bestaetigen: 'Abmelden',
|
||||
tun: async () => { abmelden(); location.reload(); },
|
||||
});
|
||||
|
||||
bereich.appendChild(k);
|
||||
return bereich;
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Kopfleiste zeigt das Bild — nach einem Wechsel muss sie es neu lesen. */
|
||||
|
||||
function kopfleisteAuffrischen(): void {
|
||||
window.dispatchEvent(new CustomEvent('flaro:konto'));
|
||||
}
|
||||
|
||||
/** „12 Std. 30 Min." — oder „noch nichts", solange nichts dasteht. */
|
||||
|
||||
export function anfangsbuchstabe(name: string): string {
|
||||
return (name.slice(0, 1) || '?').toUpperCase();
|
||||
}
|
||||
|
||||
/** Dieselbe Zeile wie in der Verwaltung — hier mit frei wählbarer Beschriftung. */
|
||||
|
||||
function zeile(
|
||||
name: string, wert: string, hinweis: string, tun: () => void, text = 'Ändern',
|
||||
): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const oben = document.createElement('div');
|
||||
oben.className = 'knopfreihe';
|
||||
const links = document.createElement('div');
|
||||
links.className = 'zeile';
|
||||
links.style.flex = '1';
|
||||
links.innerHTML = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
|
||||
oben.appendChild(links);
|
||||
|
||||
const b = knopf(text, 'btn');
|
||||
b.prepend(icon('stift'));
|
||||
b.onclick = tun;
|
||||
oben.appendChild(b);
|
||||
d.appendChild(oben);
|
||||
|
||||
const h = document.createElement('div');
|
||||
h.className = 'hinweis';
|
||||
h.textContent = hinweis;
|
||||
d.appendChild(h);
|
||||
return d;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import {
|
||||
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten,
|
||||
scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei,
|
||||
pruefungSetzen, mediathekZuruecksetzen, type Bibliothek,
|
||||
} from '../../api';
|
||||
import { esc, knopf, fehlerzeile, leer, meldung } from '../../util';
|
||||
import { dialog, nachfragen } from '../../dialog';
|
||||
|
||||
import { abschnitt } from './teile';
|
||||
|
||||
/** Mediatheken anlegen, einlesen, aufräumen. */
|
||||
|
||||
export function bibliotheksbereich(
|
||||
geaendert: (bekannt?: Bibliothek[]) => void, signal: AbortSignal,
|
||||
): HTMLElement {
|
||||
|
||||
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);
|
||||
|
||||
/** Die gezeichneten Zeilen, je Mediathek.
|
||||
*
|
||||
* **Vor dem ersten `laden()`.** Stand die Map darunter, lief der erste
|
||||
* Aufruf in die temporale Totzone von `const` — „Cannot access 'zeilen'
|
||||
* before initialization", und der ganze Abschnitt blieb leer.
|
||||
*/
|
||||
const zeilen = new Map<string, Mediathekzeile>();
|
||||
|
||||
void laden();
|
||||
|
||||
/** **Auffrischen heisst fortschreiben, nicht neu zeichnen.**
|
||||
*
|
||||
* Hier stand `liste.innerHTML = ''` und danach ein Neuaufbau — alle drei
|
||||
* Sekunden, während ein Scan läuft. Der ganze Abschnitt verschwand dabei
|
||||
* sichtbar und kam wieder; wer gerade zielte, klickte ins Leere.
|
||||
*
|
||||
* Schlimmer noch: jeder Neuaufbau startete über `verfolgen(false)` einen
|
||||
* weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander und
|
||||
* fragten im Sekundentakt denselben Scanstand ab.
|
||||
*/
|
||||
async function laden(): Promise<Bibliothek[] | undefined> {
|
||||
if (zeilen.size === 0 && liste.childElementCount === 0) {
|
||||
liste.innerHTML = '<div class="spinner"></div>';
|
||||
}
|
||||
|
||||
let alle: Bibliothek[];
|
||||
try { alle = await bibliotheken(); }
|
||||
catch (fehler) {
|
||||
// Beim Auffrischen bleibt stehen, was da ist: ein Aussetzer darf keine
|
||||
// Liste wegräumen, die noch stimmt.
|
||||
if (zeilen.size === 0) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (alle.length === 0) {
|
||||
zeilen.clear();
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
||||
return alle;
|
||||
}
|
||||
|
||||
// Spinner oder Hinweis weg — aber nur beim allerersten Mal.
|
||||
if (zeilen.size === 0) liste.innerHTML = '';
|
||||
|
||||
// Was es nicht mehr gibt, verschwindet.
|
||||
[...zeilen.keys()]
|
||||
.filter(id => !alle.some(b => b.id === id))
|
||||
.forEach(id => { zeilen.get(id)!.wurzel.remove(); zeilen.delete(id); });
|
||||
|
||||
alle.forEach((b, i) => {
|
||||
let zeile = zeilen.get(b.id);
|
||||
if (zeile) zeile.aktualisieren(b);
|
||||
else { zeile = block(b); zeilen.set(b.id, zeile); }
|
||||
|
||||
// Reihenfolge angleichen, ohne alles anzufassen: `insertBefore` mit
|
||||
// einem Knoten, der schon dort steht, tut nichts.
|
||||
if (liste.children[i] !== zeile.wurzel) {
|
||||
liste.insertBefore(zeile.wurzel, liste.children[i] ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
return alle;
|
||||
}
|
||||
|
||||
function block(b: Bibliothek): Mediathekzeile {
|
||||
// Die Knöpfe lesen den Stand über diese Grösse, nicht über `b` — sonst
|
||||
// arbeiteten sie nach dem ersten Auffrischen mit veralteten Werten.
|
||||
let aktuell = b;
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'zeile';
|
||||
d.appendChild(kopf);
|
||||
|
||||
function kopfSchreiben(): void {
|
||||
const serie = aktuell.kind === 'Shows';
|
||||
|
||||
// **Alle Zahlen nebeneinander, weil sie Verschiedenes meinen.**
|
||||
//
|
||||
// Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 Dateien.
|
||||
// Keine davon war falsch — es sind Titel, Dateien und zweite Fassungen.
|
||||
// Einzeln gezeigt bleibt die Frage offen, nebeneinander beantwortet sie
|
||||
// sich.
|
||||
const teile: string[] = [aktuell.path];
|
||||
|
||||
if (aktuell.itemCount != null) {
|
||||
teile.push(`${aktuell.itemCount} ` + (aktuell.itemCount === 1
|
||||
? (serie ? 'Serie' : 'Film')
|
||||
: (serie ? 'Serien' : 'Filme')));
|
||||
}
|
||||
if (serie && aktuell.episodeCount != null) {
|
||||
teile.push(`${aktuell.episodeCount} ${aktuell.episodeCount === 1 ? 'Folge' : 'Folgen'}`);
|
||||
}
|
||||
teile.push(`${aktuell.fileCount} ${aktuell.fileCount === 1 ? 'Datei' : 'Dateien'}`);
|
||||
if (aktuell.extraVersionCount > 0) {
|
||||
teile.push(`${aktuell.extraVersionCount} zweite Fassung`
|
||||
+ (aktuell.extraVersionCount === 1 ? '' : 'en'));
|
||||
}
|
||||
if (aktuell.unassignedCount > 0) teile.push(`${aktuell.unassignedCount} nicht zugeordnet`);
|
||||
if (aktuell.pendingCount > 0) {
|
||||
teile.push(`${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`);
|
||||
}
|
||||
|
||||
kopf.innerHTML = `<strong>${esc(aktuell.name)}</strong>`
|
||||
+ `<span class="winzig">${esc(teile.join(' · '))}</span>`;
|
||||
}
|
||||
kopfSchreiben();
|
||||
|
||||
const stand = document.createElement('div');
|
||||
stand.className = 'hinweis';
|
||||
|
||||
const los = knopf('Scannen', 'btn');
|
||||
los.onclick = async () => {
|
||||
laufendSetzen(true);
|
||||
try { await scanStarten(aktuell.id); await verfolgen(true); }
|
||||
catch (fehler) { stand.textContent = meldung(fehler); laufendSetzen(false); }
|
||||
};
|
||||
|
||||
/** Sperrt alles, was einen laufenden Lauf stören würde.
|
||||
*
|
||||
* **Nicht nur „Scannen".** Ein zweiter Scan prallt am Server ab und ist
|
||||
* nur verwirrend — „Zurücksetzen" und „Entfernen" dagegen löschen Zeilen,
|
||||
* während der Lauf welche schreibt, und das endet in einem Bestand, den
|
||||
* niemand mehr erklären kann.
|
||||
*
|
||||
* **Umbenennen, Prüfung und die Pfeile bleiben bedienbar.** Sie rühren
|
||||
* den Inhalt nicht an, und ein Knopf, der ohne Not gesperrt ist, ist
|
||||
* genauso ärgerlich wie einer, der ohne Not etwas kaputtmacht.
|
||||
*/
|
||||
function laufendSetzen(an: boolean): void {
|
||||
const grund = 'Läuft gerade — bitte warten, bis der Lauf durch ist.';
|
||||
[los, auffrischen, zuruecksetzen, entfernen].forEach(k => {
|
||||
k.disabled = an;
|
||||
if (an) k.title = grund;
|
||||
else if (k.title === grund) k.title = '';
|
||||
});
|
||||
if (!an) {
|
||||
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
|
||||
+ 'nötig nach einer Sprachänderung.';
|
||||
}
|
||||
}
|
||||
|
||||
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 „${aktuell.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 () => {
|
||||
laufendSetzen(true);
|
||||
await metadatenAuffrischen(aktuell.id);
|
||||
void verfolgen(true);
|
||||
},
|
||||
});
|
||||
|
||||
const umbenennen = knopf('Umbenennen', 'btn');
|
||||
umbenennen.onclick = () => dialog({
|
||||
titel: 'Mediathek umbenennen',
|
||||
felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }],
|
||||
bestaetigen: 'Speichern',
|
||||
tun: async wert => {
|
||||
await bibliothekUmbenennen(aktuell.id, wert('name'));
|
||||
await laden();
|
||||
geaendert();
|
||||
},
|
||||
});
|
||||
|
||||
// **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: `„${aktuell.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(aktuell.id);
|
||||
await laden();
|
||||
geaendert();
|
||||
},
|
||||
});
|
||||
|
||||
const entfernen = knopf('Entfernen', 'btn');
|
||||
entfernen.onclick = () => nachfragen({
|
||||
titel: `„${aktuell.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(aktuell.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(aktuell.id);
|
||||
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier
|
||||
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
|
||||
// **Die Sperre richtet sich nach dem Server, nicht nach dem Klick.**
|
||||
// Beim Aufbau der Seite läuft vielleicht schon einer — dann müssen die
|
||||
// Knöpfe gesperrt sein, ohne dass hier jemand etwas gedrückt hat.
|
||||
if (s.state === 'running') {
|
||||
laufendSetzen(true);
|
||||
stand.textContent = s.step ?? 'Wird durchsucht…';
|
||||
} else if (s.state === 'enriching') {
|
||||
laufendSetzen(true);
|
||||
stand.textContent = s.step ?? 'Metadaten werden geholt…';
|
||||
} else if (s.state === 'failed') {
|
||||
laufendSetzen(false);
|
||||
stand.textContent = s.error ?? 'Gescheitert.';
|
||||
return;
|
||||
}
|
||||
|
||||
// **Alle Zähler auf demselben Takt.**
|
||||
//
|
||||
// Vorher frischte diese Schleife nur die Zeile in der Verwaltung auf,
|
||||
// und die Seitenleiste erst am Ende des Laufs. In der Mediathek stand
|
||||
// dann 20, in der Verwaltung 14 und in der Seitenleiste 0 — drei
|
||||
// Zahlen für dieselbe Sache, und keine davon offensichtlich falsch.
|
||||
// `geaendert()` baut nur die Seitenleiste neu, nicht die Ansicht;
|
||||
// es hier zu rufen ist deshalb gefahrlos.
|
||||
//
|
||||
// Alle drei Runden statt alle fünf: nah genug am Zähler in der
|
||||
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
|
||||
if (melden && i > 0 && i % 3 === 0) geaendert(await laden());
|
||||
|
||||
if (s.state === 'finished') {
|
||||
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
||||
// „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);
|
||||
if (s.missing) teile.push(`${s.missing} nicht mehr gefunden`);
|
||||
stand.textContent = teile.join(' · ');
|
||||
laufendSetzen(false);
|
||||
if (melden) { await laden(); geaendert(); }
|
||||
return;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// Nach fünfzehn Minuten ohne Ende: die Sperre fällt, sonst bliebe die
|
||||
// Mediathek bis zum Neuladen der Seite unbedienbar.
|
||||
laufendSetzen(false);
|
||||
}
|
||||
|
||||
// Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
|
||||
// entschieden wurde, soll kein Urteil fürs Leben sein.
|
||||
const pruefung = knopf(aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
|
||||
pruefung.onclick = async () => {
|
||||
pruefung.disabled = true;
|
||||
try {
|
||||
await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems);
|
||||
await laden();
|
||||
} catch (fehler) {
|
||||
d.appendChild(fehlerzeile(meldung(fehler)));
|
||||
pruefung.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// **Umsortiert wird in der Seitenleiste, nicht hier.**
|
||||
//
|
||||
// Die Reihenfolge wirkt dort, also gehört der Griff dorthin: man sieht
|
||||
// beim Schieben, was man verändert, statt in den Einstellungen zu raten
|
||||
// und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten".
|
||||
const knoepfe = document.createElement('div');
|
||||
knoepfe.className = 'knopfreihe';
|
||||
knoepfe.append(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
|
||||
// geht, ohne dass man erst weiterscrollen muss.
|
||||
//
|
||||
// Angelegt wird er trotzdem immer und nur versteckt: ihn beim Auffrischen
|
||||
// anzulegen und wieder wegzuwerfen wäre genau das Flackern, das hier
|
||||
// gerade abgestellt wird.
|
||||
const freigeben = knopf('', 'btn btn-primary');
|
||||
// **Zeigen statt blind freigeben.**
|
||||
//
|
||||
// Der Knopf veröffentlichte bisher auf einen Klick alles, was wartete —
|
||||
// ohne dass man gesehen hätte, was. Auf die Frage „wo sehe ich die vier
|
||||
// Filme?" gab es damit keine Antwort: die Liste stand weiter oben, aber
|
||||
// niemand wusste das, und sie war obendrein nicht nachgeladen worden.
|
||||
//
|
||||
// Jetzt führt er dorthin. Veröffentlicht wird dort, wo die Titel mit
|
||||
// Plakat und Dateiname stehen und sich einzeln anhaken lassen.
|
||||
//
|
||||
// **Ein Bereichswechsel, kein Sprung in der Seite.** Solange alles in
|
||||
// einer Spalte stand, genügte `scrollIntoView`; seit „Zu prüfen" ein
|
||||
// eigener Bereich ist, steht die Liste gar nicht mehr im selben Dokument.
|
||||
freigeben.onclick = () => { location.hash = '#/verwaltung/pruefung'; };
|
||||
knoepfe.appendChild(freigeben);
|
||||
|
||||
function freigabeSchreiben(): void {
|
||||
// **`hidden` reicht hier nicht.** Das Attribut wirkt über
|
||||
// `display: none` mit der Spezifität von Null — `.btn` setzt
|
||||
// `display: inline-flex` und gewinnt. Der Knopf stand deshalb als
|
||||
// „0 Titel freigeben" da, obwohl nichts wartet. `.hidden` trägt ein
|
||||
// `!important` und setzt sich durch.
|
||||
freigeben.classList.toggle('hidden', aktuell.pendingCount === 0);
|
||||
freigeben.textContent = aktuell.pendingCount === 1
|
||||
? 'Ein Titel wartet — ansehen'
|
||||
: `${aktuell.pendingCount} Titel warten — ansehen`;
|
||||
}
|
||||
freigabeSchreiben();
|
||||
|
||||
d.append(knoepfe, stand);
|
||||
|
||||
// **Genau ein Takt je Mediathek.** Er entsteht hier, beim Anlegen der
|
||||
// Zeile — und weil die Zeile nicht mehr neu gebaut wird, bleibt es dabei.
|
||||
void verfolgen(false);
|
||||
|
||||
return {
|
||||
wurzel: d,
|
||||
aktualisieren: (neu: Bibliothek) => {
|
||||
aktuell = neu;
|
||||
kopfSchreiben();
|
||||
pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus';
|
||||
freigabeSchreiben();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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. */
|
||||
export 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(meldung(fehler))); 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 ---------- */
|
||||
|
||||
/** Eine gezeichnete Mediathekszeile und der Weg, sie fortzuschreiben. */
|
||||
type Mediathekzeile = {
|
||||
wurzel: HTMLElement;
|
||||
aktualisieren: (b: Bibliothek) => void;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
sprachen, uebersetzungenHolen, meineSprache, metadatenEinstellung,
|
||||
metadatenSetzen, sprachverteilung, abweichendeAuffrischen,
|
||||
} from '../../api';
|
||||
import { knopf, fehlerzeile, meldung } from '../../util';
|
||||
import { dialog } from '../../dialog';
|
||||
|
||||
import { abschnitt, zeile } from './teile';
|
||||
|
||||
/** Sprache und Herkunft der Metadaten. */
|
||||
|
||||
/* ---------- Server ---------- */
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
export function sprachbereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.appendChild(abschnitt('Sprache'));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const stand = await metadatenEinstellung();
|
||||
const eigeneSprache = meineSprache();
|
||||
liste.innerHTML = '';
|
||||
// **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.
|
||||
// **Was tatsächlich vorliegt — nicht was eingestellt ist.**
|
||||
//
|
||||
// Der eigentliche Fehler war nicht, dass Titel englisch blieben, sondern
|
||||
// dass sich nicht feststellen liess, ob sie es sind: nirgends stand, in
|
||||
// welcher Sprache ein Titel geholt wurde. Hier steht es, als Zahl.
|
||||
void sprachverteilung().then(v => {
|
||||
const benennen = (k: string | null) => k === null
|
||||
? 'unbekannt (vor dieser Fassung geholt)'
|
||||
: sprachen.find(x => x.wert === k)?.text ?? k;
|
||||
|
||||
const zeileBestand = zeile(
|
||||
'Bestand',
|
||||
v.anteile.length === 0
|
||||
? 'Noch nichts zugeordnet'
|
||||
: v.anteile.map(a => `${a.anzahl} × ${benennen(a.sprache)}`).join(' · '),
|
||||
v.abweichend === 0
|
||||
? 'Alles liegt in der Serversprache vor.'
|
||||
: `${v.abweichend} Titel liegen in einer anderen Sprache vor. Das Auffrischen `
|
||||
+ 'holt genau diese neu — nicht den ganzen Bestand.');
|
||||
|
||||
if (v.abweichend > 0) {
|
||||
const los = knopf(`${v.abweichend} auffrischen`, 'btn btn-primary');
|
||||
los.onclick = async () => {
|
||||
los.disabled = true;
|
||||
try {
|
||||
await abweichendeAuffrischen();
|
||||
los.textContent = 'Läuft…';
|
||||
} catch (fehler) {
|
||||
zeileBestand.appendChild(fehlerzeile(meldung(fehler)));
|
||||
los.disabled = false;
|
||||
}
|
||||
};
|
||||
zeileBestand.querySelector('.knopfreihe')?.appendChild(los);
|
||||
}
|
||||
|
||||
// Direkt unter die Serversprache, denn dort wird die Frage gestellt.
|
||||
liste.insertBefore(zeileBestand, liste.children[1] ?? null);
|
||||
}).catch(() => { /* Ohne Verteilung fehlt nur die Zeile. */ });
|
||||
|
||||
// **Weicht sie ab, steht das deutlich dabei.**
|
||||
//
|
||||
// Eine eigene Sprache ändert nicht, was der Server holt — nur was man
|
||||
// selbst sieht. Steht sie auf Englisch, während der Server auf Deutsch
|
||||
// holt, sieht die ganze Mediathek englisch aus, und jedes „Metadaten
|
||||
// auffrischen" scheint wirkungslos. Genau in diese Falle läuft man,
|
||||
// ohne je zu erfahren, warum.
|
||||
const weichtAb = eigeneSprache !== null && eigeneSprache !== stand.language;
|
||||
const serversprache = sprachen.find(x => x.wert === stand.language)?.text ?? stand.language;
|
||||
|
||||
// **Geändert wird sie im Profil, nicht hier.**
|
||||
//
|
||||
// Sie gehört einem Menschen, nicht dem Server — und sie stand in einem
|
||||
// Bereich, den der Client nur Administratoren zeigt. Wer kein Admin war,
|
||||
// konnte seine eigene Sprache nirgends umstellen, obwohl der Endpunkt es
|
||||
// längst erlaubte.
|
||||
//
|
||||
// Der Hinweis bleibt trotzdem hier: Die Verwirrung entsteht an dieser
|
||||
// Stelle. Wer die Mediathek englisch vorfindet und hier die Serversprache
|
||||
// auf Deutsch stehen sieht, sucht den Fehler sonst beim Auffrischen.
|
||||
if (weichtAb) {
|
||||
liste.appendChild(zeile(
|
||||
'Deine Sprache weicht ab',
|
||||
sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache!,
|
||||
`Du siehst alles auf ${sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache}, `
|
||||
+ `obwohl der Server auf ${serversprache} holt. Titel ohne Übersetzung bleiben `
|
||||
+ `auf ${serversprache}. Umstellen kannst du das in deinem Profil.`,
|
||||
() => { location.hash = '#/profil'; }));
|
||||
}
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Übersetzungen holen',
|
||||
`Füllt eine zweite Sprache neben ${serversprache} auf`,
|
||||
`Der Bestand liegt auf ${serversprache} vor. Das hier legt zusätzlich `
|
||||
+ 'eine zweite Fassung an, für Benutzer, die eine andere Sprache gewählt '
|
||||
+ 'haben — am Bestand selbst ändert es nichts. Kostet je Titel eine '
|
||||
+ 'Anfrage an TMDB.',
|
||||
() => dialog({
|
||||
titel: 'Übersetzungen holen',
|
||||
text: `Die Titel des Servers bleiben auf ${serversprache}. Geholt wird `
|
||||
+ 'eine zusätzliche Fassung, die nur Benutzer mit genau dieser Sprache '
|
||||
+ 'zu sehen bekommen.',
|
||||
felder: [{
|
||||
name: 'sprache', beschriftung: 'Für welche Sprache',
|
||||
// **Nicht stur en-US.** Stand dort „Englisch", während der Server
|
||||
// auf Deutsch steht, las sich das wie eine Aussage über den
|
||||
// Server — und führte zu der Vermutung, die Spracheinstellung
|
||||
// wirke nicht. Vorgewählt wird die eigene Sprache, sonst die
|
||||
// erste, die nicht schon die des Servers ist.
|
||||
auswahl: sprachen,
|
||||
wert: eigeneSprache
|
||||
?? sprachen.find(x => x.wert !== stand.language)?.wert
|
||||
?? stand.language,
|
||||
}],
|
||||
bestaetigen: 'Holen',
|
||||
tun: async wert => {
|
||||
const bericht = await uebersetzungenHolen(wert('sprache'));
|
||||
alert(`${bericht.gesamt} Übersetzungen geholt, ${bericht.fehlend} ohne Treffer.`);
|
||||
},
|
||||
})));
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
})();
|
||||
|
||||
return bereich;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { protokolltage, protokollAdresse, ApiFehler } from '../../api';
|
||||
import { esc, knopf, leer, meldung } from '../../util';
|
||||
|
||||
import { abschnitt } from './teile';
|
||||
|
||||
/** Die Tagesprotokolle des Servers. */
|
||||
|
||||
export 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);
|
||||
|
||||
// **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; }
|
||||
|
||||
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);
|
||||
});
|
||||
})();
|
||||
|
||||
/** 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, gezeigt));
|
||||
if (!antwort.ok) throw new ApiFehler(antwort.status, `${antwort.status}`);
|
||||
|
||||
const roh = (await antwort.text()).split('\n');
|
||||
const gefiltert = nurFehler
|
||||
? 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: ${meldung(fehler)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return bereich;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
zuPruefen, veroeffentlichen, zurueckziehen, zurKenntnis, type Pruefeintrag, bildAdresse,
|
||||
} from '../../api';
|
||||
import { esc, knopf, fehlerzeile, meldung } from '../../util';
|
||||
import { dialog } from '../../dialog';
|
||||
import { icons } from '../../icons';
|
||||
import { kontextmenue, menueAnZiel, type Menuepunkt } from '../../menue';
|
||||
import { bilddialog } from '../../bilder';
|
||||
import { zuordnungsdialog } from '../../zuordnung';
|
||||
import { titeldialog } from '../../titel';
|
||||
import { bearbeiten } from '../../bearbeiten';
|
||||
|
||||
import { abschnitt } from './teile';
|
||||
|
||||
/** Zu prüfen: was der Scan gefunden hat und noch nicht freigegeben ist. */
|
||||
|
||||
/* ---------- 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. */
|
||||
export function pruefbereich(geaendert: () => void): {
|
||||
wurzel: HTMLElement; auffrischen: () => void;
|
||||
} {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.className = 'hidden';
|
||||
bereich.id = 'zu-pruefen';
|
||||
|
||||
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(meldung(fehler)));
|
||||
} 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 = '';
|
||||
|
||||
// **Zwei Gruppen, zwei Bedeutungen.** Ein neuer Fund wartet auf Freigabe
|
||||
// und ist bis dahin unsichtbar; ein geänderter Titel steht längst in der
|
||||
// Mediathek und soll nur nicht unbemerkt anders heissen. Beides in einer
|
||||
// Reihe hiesse, „Veröffentlichen" auf etwas anzubieten, das veröffentlicht
|
||||
// ist.
|
||||
const neue = stand.filter(e => e.grund !== 'geaendert');
|
||||
const geaenderte = stand.filter(e => e.grund === 'geaendert');
|
||||
|
||||
neue.forEach(e => liste.appendChild(zeileFuer(e)));
|
||||
|
||||
if (geaenderte.length > 0) {
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'pruef-gruppe';
|
||||
kopf.innerHTML = '<strong>Geändert</strong>'
|
||||
+ '<span class="winzig">Diese Titel sind schon sichtbar. Ein Auffrischen '
|
||||
+ 'hat Titel, Jahr oder Plakat geändert — hier nur zur Kenntnis.</span>';
|
||||
|
||||
const abhaken = knopf('Alle zur Kenntnis', 'btn');
|
||||
abhaken.onclick = async () => {
|
||||
abhaken.disabled = true;
|
||||
try { await zurKenntnis(); await laden(); }
|
||||
catch (fehler) { liste.prepend(fehlerzeile(meldung(fehler))); abhaken.disabled = false; }
|
||||
};
|
||||
kopf.appendChild(abhaken);
|
||||
|
||||
liste.append(kopf, ...geaenderte.map(zeileFuer));
|
||||
}
|
||||
}
|
||||
|
||||
function zeileFuer(e: Pruefeintrag): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung pruefzeile';
|
||||
|
||||
const geaendertes = e.grund === 'geaendert';
|
||||
|
||||
const haken = document.createElement('input');
|
||||
haken.type = 'checkbox';
|
||||
haken.checked = gewaehlt.has(e.id);
|
||||
haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
|
||||
// Die Sammelaktionen oben veröffentlichen — auf Geändertes passt das nicht.
|
||||
haken.classList.toggle('hidden', geaendertes);
|
||||
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 = geaendertes
|
||||
? 'Sichtbar — ein Auffrischen hat etwas geändert'
|
||||
: 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>`;
|
||||
|
||||
// **Geändertes wird abgehakt, nicht veröffentlicht** — es ist schon
|
||||
// sichtbar. „Veröffentlichen" wäre dort ein Knopf, der nichts tut.
|
||||
const nur = knopf(
|
||||
geaendertes ? 'Zur Kenntnis' : geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen',
|
||||
'btn');
|
||||
nur.onclick = async () => {
|
||||
nur.disabled = true;
|
||||
try {
|
||||
if (geaendertes) await zurKenntnis([e.id]);
|
||||
else await veroeffentlichen(e.ids);
|
||||
await laden();
|
||||
geaendert();
|
||||
} catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
|
||||
};
|
||||
|
||||
// **Dasselbe Dreipunktemenü wie auf einer Kachel.**
|
||||
//
|
||||
// Hier stand bisher nur „Veröffentlichen" — ausgerechnet bei dem Titel,
|
||||
// den man vor der Freigabe geradeziehen will. Bilder, Zuordnung und Titel
|
||||
// waren an einem wartenden Eintrag überhaupt nicht erreichbar.
|
||||
const art = e.kind === 'series' ? 'series' : 'movies';
|
||||
const mehr = document.createElement('button');
|
||||
mehr.className = 'btn rund';
|
||||
mehr.innerHTML = icons.punkte;
|
||||
mehr.setAttribute('aria-label', `Mehr zu „${e.title}“`);
|
||||
const punkte = (): Menuepunkt[] => [
|
||||
// Auch hier: ein Ort für alles, die Einzelnen bleiben als Abkürzung.
|
||||
{ text: 'Bearbeiten', icon: 'stift',
|
||||
tun: () => bearbeiten(art, e.id, () => void laden()) },
|
||||
{ text: 'Titel ändern', icon: 'stift',
|
||||
tun: () => titeldialog(art, e.id, e.title, () => void laden()) },
|
||||
{ text: 'Bilder bearbeiten', icon: 'stift',
|
||||
tun: () => bilddialog(art, e.id, e.title, () => void laden()) },
|
||||
{ text: 'Zuordnung korrigieren', icon: 'suche',
|
||||
// Ohne Jahr — siehe card.ts: das der falschen Zuordnung grenzt falsch ein.
|
||||
tun: () => zuordnungsdialog(art, e.id, e.title, null, () => void laden()) },
|
||||
];
|
||||
mehr.onclick = () => menueAnZiel(mehr, punkte());
|
||||
kontextmenue(mehr, punkte);
|
||||
|
||||
const oben = document.createElement('div');
|
||||
oben.className = 'knopfreihe';
|
||||
oben.append(haken, bild, text, mehr, 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(meldung(fehler))); 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())}`;
|
||||
}
|
||||
|
||||
// **Nachladbar, und das fehlte.** Die Liste wurde einmal beim Öffnen
|
||||
// geholt. Fand ein laufender Scan danach etwas Neues, zählte die Zeile der
|
||||
// Mediathek es mit — „4 warten auf Freigabe" —, die Liste darüber blieb
|
||||
// aber leer und sogar versteckt. Da stand eine Zahl ohne das, worauf sie
|
||||
// sich bezog.
|
||||
return { wurzel: bereich, auffrischen: () => void laden() };
|
||||
}
|
||||
|
||||
/* ---------- 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. */
|
||||
/** Dateien, die zu keinem Titel gehören.
|
||||
*
|
||||
* **Warum das eine eigene Ansicht verdient.** Der Scan erfindet keine Serie
|
||||
* mehr, wenn weder Ordner noch Dateiname eine nennen — richtig so, aber die
|
||||
* Dateien verschwanden damit lautlos. Bei 3559 von 5884 war das die halbe
|
||||
* Mediathek, und in der Übersicht stand eine Zahl, die niemand erklären
|
||||
* konnte.
|
||||
*
|
||||
* **Mit Grund je Datei**, weil die beiden Gründe Verschiedenes verlangen:
|
||||
* fehlt die Folgennummer, muss umbenannt werden; fehlt der Serienname, genügt
|
||||
* ein Ordner darum herum.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { esc, knopf } from '../../util';
|
||||
import { icon } from '../../icons';
|
||||
|
||||
|
||||
/** Gemeinsame Bausteine der Verwaltung. */
|
||||
|
||||
/** Überschrift mit einem Knopf rechts daneben. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
|
||||
/** Eine Zeile „Name — Wert" mit einem Ändern-Knopf.
|
||||
*
|
||||
* **Eine Stelle, nicht zwei.** Diesen Bauer gab es wortgleich zweimal — in
|
||||
* der Sprach- und in der Serverauskunft. Zwei Kopien derselben Zeile sind
|
||||
* genau dann ein Problem, wenn eine davon gepflegt wird.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import {
|
||||
doppelteZusammenlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen,
|
||||
systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte,
|
||||
bildspeicherLeeren, type Wiedergabe,
|
||||
} from '../../api';
|
||||
import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util';
|
||||
import { dialog } from '../../dialog';
|
||||
|
||||
import { abschnitt, zeile } from './teile';
|
||||
|
||||
/** Übersicht: was läuft, was der Server meldet, was er belegt. */
|
||||
|
||||
/* ---------- 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. */
|
||||
export 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(meldung(fehler)));
|
||||
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. */
|
||||
export 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.`;
|
||||
}
|
||||
|
||||
export 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)));
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
liste.innerHTML = '<div class="spinner"></div>';
|
||||
return bereich;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { wiedergabeEinstellung, wiedergabeSetzen } from '../../api';
|
||||
import { fehlerzeile, meldung, hinweisZeigen } from '../../util';
|
||||
import { dialog } from '../../dialog';
|
||||
|
||||
import { abschnitt, zeile } from './teile';
|
||||
|
||||
/** Wie der Server mit Fortschritt umgeht und wie er umkodiert. */
|
||||
|
||||
/** Die Stufen fürs Weiterschauen — lieber eine Auswahl als ein Zahlenfeld.
|
||||
*
|
||||
* **„87 Sekunden" will niemand eintippen.** Eine freie Zahl sieht nach
|
||||
* Genauigkeit aus, die es hier nicht gibt: ob 90 oder 100 Sekunden, merkt
|
||||
* kein Mensch. Fünf Stufen beantworten die Frage, um die es geht — zählt
|
||||
* Hineinschauen oder nicht.
|
||||
*/
|
||||
const SCHWELLEN: { wert: string; text: string }[] = [
|
||||
{ wert: '0', text: 'Sofort — jede angefangene Sekunde' },
|
||||
{ wert: '30', text: 'Nach 30 Sekunden' },
|
||||
{ wert: '90', text: 'Nach 1½ Minuten' },
|
||||
{ wert: '180', text: 'Nach 3 Minuten' },
|
||||
{ wert: '300', text: 'Nach 5 Minuten' },
|
||||
];
|
||||
|
||||
const ANTEILE: { wert: string; text: string }[] = [
|
||||
{ wert: '0.85', text: '85 % — früher abgehakt' },
|
||||
{ wert: '0.9', text: '90 %' },
|
||||
{ wert: '0.95', text: '95 % — erst im Abspann' },
|
||||
];
|
||||
|
||||
const STUFEN: { wert: string; text: string }[] = [
|
||||
{ wert: 'qualitaet', text: 'Qualität — schöneres Bild, rechnet länger' },
|
||||
{ wert: 'ausgewogen', text: 'Ausgewogen' },
|
||||
{ wert: 'tempo', text: 'Tempo — startet schneller, kostet Bild' },
|
||||
];
|
||||
|
||||
export function wiedergabebereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.appendChild(abschnitt('Wiedergabe'));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
liste.innerHTML = '<div class="spinner"></div>';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
void laden();
|
||||
return bereich;
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
let stand;
|
||||
try {
|
||||
stand = await wiedergabeEinstellung();
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
return;
|
||||
}
|
||||
|
||||
liste.innerHTML = '';
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Weiterschauen ab',
|
||||
textVon(SCHWELLEN, String(stand.resumeAfterSeconds)),
|
||||
'Wer kürzer hineinschaut, findet den Titel nicht in „Fortsetzen" wieder. '
|
||||
+ 'Die Stelle wird trotzdem gemerkt — wer ihn später öffnet, macht dort weiter.',
|
||||
() => fragen({
|
||||
titel: 'Weiterschauen ab',
|
||||
text: 'Ohne Untergrenze genügt eine Sekunde: ein Blick in den Aufmacher, '
|
||||
+ 'und der Titel steht in „Fortsetzen". Die Umstellung wirkt rückwirkend — '
|
||||
+ 'was schon in der Reihe steht, wird neu bewertet.',
|
||||
name: 'schwelle',
|
||||
beschriftung: 'Erst ab',
|
||||
auswahl: SCHWELLEN,
|
||||
wert: naechsteStufe(SCHWELLEN, stand.resumeAfterSeconds),
|
||||
setzen: v => wiedergabeSetzen({ resumeAfterSeconds: Number(v) }),
|
||||
})));
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Gesehen ab',
|
||||
`${Math.round(stand.watchedFraction * 100)} %`,
|
||||
'Niemand schaut bis zur letzten Sekunde des Abspanns. Ab diesem Anteil '
|
||||
+ 'bekommt ein Titel sein Häkchen und verlässt „Fortsetzen".',
|
||||
() => fragen({
|
||||
titel: 'Gesehen ab',
|
||||
text: 'Höher heisst: der Titel bleibt länger in „Fortsetzen" stehen. '
|
||||
+ 'Auch das wirkt rückwirkend.',
|
||||
name: 'anteil',
|
||||
beschriftung: 'Anteil der Laufzeit',
|
||||
auswahl: ANTEILE,
|
||||
wert: naechsteStufe(ANTEILE, stand.watchedFraction),
|
||||
setzen: v => wiedergabeSetzen({ watchedFraction: Number(v) }),
|
||||
})));
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Umkodieren',
|
||||
textVon(STUFEN, stand.encoder),
|
||||
'Gilt nur, wenn wirklich gerechnet werden muss — wird eine Datei bloss '
|
||||
+ 'umgepackt, bleibt das Bild unberührt. Vorsicht bei „Qualität": was in '
|
||||
+ 'Echtzeit laufen muss, darf nicht langsamer rechnen als es abspielt. '
|
||||
+ 'Auf einer schwachen Maschine kann das Bild dadurch stocken.',
|
||||
() => fragen({
|
||||
titel: 'Umkodieren',
|
||||
text: 'Die Stufe wirkt auf die nächste Wiedergabe, nicht auf eine laufende.',
|
||||
name: 'stufe',
|
||||
beschriftung: 'Abwägung',
|
||||
auswahl: STUFEN,
|
||||
wert: stand.encoder,
|
||||
setzen: v => wiedergabeSetzen({ encoder: v }),
|
||||
})));
|
||||
}
|
||||
|
||||
/** Ein Auswahldialog, der danach meldet, was er umgeworfen hat. */
|
||||
function fragen(o: {
|
||||
titel: string; text: string; name: string; beschriftung: string;
|
||||
auswahl: { wert: string; text: string }[]; wert: string;
|
||||
setzen: (wert: string) => Promise<{ reevaluated: number }>;
|
||||
}): void {
|
||||
dialog({
|
||||
titel: o.titel,
|
||||
text: o.text,
|
||||
felder: [{ name: o.name, beschriftung: o.beschriftung, auswahl: o.auswahl, wert: o.wert }],
|
||||
bestaetigen: 'Übernehmen',
|
||||
tun: async wert => {
|
||||
const ergebnis = await o.setzen(wert(o.name));
|
||||
// **Die Zahl gehört in die Rückmeldung.** Eine neue Schwelle räumt
|
||||
// „Fortsetzen" auf; ohne diesen Satz änderte sich die Startseite
|
||||
// scheinbar von selbst.
|
||||
hinweisZeigen(ergebnis.reevaluated === 0
|
||||
? 'Übernommen.'
|
||||
: ergebnis.reevaluated === 1
|
||||
? 'Übernommen — ein Eintrag wurde neu bewertet.'
|
||||
: `Übernommen — ${ergebnis.reevaluated} Einträge wurden neu bewertet.`);
|
||||
await laden();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Text zur gespeicherten Zahl — oder die Zahl selbst, wenn sie nicht passt. */
|
||||
function textVon(stufen: { wert: string; text: string }[], wert: string): string {
|
||||
return stufen.find(s => s.wert === wert)?.text ?? wert;
|
||||
}
|
||||
|
||||
/** Welche Stufe ein gespeicherter Wert am ehesten meint.
|
||||
*
|
||||
* In der Datenbank steht eine Zahl, keine Stufe — sie kann aus einer älteren
|
||||
* Fassung stammen oder von Hand gesetzt sein. Dann soll der Dialog nicht leer
|
||||
* aufgehen, sondern bei der nächstgelegenen stehen.
|
||||
*/
|
||||
function naechsteStufe(stufen: { wert: string; text: string }[], wert: number): string {
|
||||
let beste = stufen[0]!.wert;
|
||||
let abstand = Infinity;
|
||||
for (const s of stufen) {
|
||||
const d = Math.abs(Number(s.wert) - wert);
|
||||
if (d < abstand) { abstand = d; beste = s.wert; }
|
||||
}
|
||||
return beste;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
bibliotheken, unzugeordnet, ohneZuordnung, mehrfachfassungen, dateiHerausloesen,
|
||||
type Mehrfachtitel,
|
||||
} from '../../api';
|
||||
import { esc, knopf, meldung, hinweisZeigen } from '../../util';
|
||||
import { bearbeiten } from '../../bearbeiten';
|
||||
|
||||
import { abschnitt } from './teile';
|
||||
|
||||
/** Was sich nicht zuordnen liess. */
|
||||
|
||||
/** Titel, zu denen die Metadatenquelle nichts hergab.
|
||||
*
|
||||
* **Weil die Zahl allein nichts erklärt.** In der Übersicht standen 448
|
||||
* Filme, in Plex 451 — und nirgends, welche drei fehlen oder warum. Ein Film
|
||||
* ohne Zuordnung steht zwar in der Mediathek, aber unter seinem Dateinamen
|
||||
* und ohne Plakat; wer ihn sucht, findet ihn nicht, weil er anders heisst.
|
||||
*
|
||||
* Daneben: Titel mit mehr als einer Datei. Zwei Dateien an einem Film sind
|
||||
* meist zwei Fassungen und in Ordnung — manchmal aber zwei verschiedene
|
||||
* Filme, die bei der Quelle auf denselben Treffer liefen. An den Dateinamen
|
||||
* sieht man das in zwei Sekunden.
|
||||
*/
|
||||
export function ohneZuordnungBereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.classList.add('hidden');
|
||||
bereich.appendChild(abschnitt('Ohne Zuordnung'));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
let alle;
|
||||
try { alle = await bibliotheken(); }
|
||||
catch { return; }
|
||||
|
||||
const ergebnisse = await Promise.all(alle.map(async b => {
|
||||
try {
|
||||
const [offen, mehrfach] = await Promise.all([
|
||||
ohneZuordnung(b.id), mehrfachfassungen(b.id),
|
||||
]);
|
||||
return { b, offen, mehrfach };
|
||||
} catch { return null; }
|
||||
}));
|
||||
|
||||
const mitFunden = ergebnisse
|
||||
.filter(e => e !== null)
|
||||
.filter(e => e!.offen.length > 0 || e!.mehrfach.gesamt > 0);
|
||||
|
||||
if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
|
||||
bereich.classList.remove('hidden');
|
||||
|
||||
liste.innerHTML = '';
|
||||
|
||||
mitFunden.forEach(e => {
|
||||
const { b, offen, mehrfach } = e!;
|
||||
|
||||
if (offen.length > 0) {
|
||||
liste.appendChild(gruppe(
|
||||
`${b.name} — ${anzahl(offen.length, 'Titel')} ohne Zuordnung`,
|
||||
'Diese stehen unter ihrem Dateinamen in der Mediathek. '
|
||||
+ 'Über „Zuordnung korrigieren" lassen sie sich geradeziehen.',
|
||||
offen.map(t => ({
|
||||
titel: t.titel + (t.jahr ? ` (${t.jahr})` : ''),
|
||||
dazu: t.beispiel ?? `${anzahl(t.dateien, 'Datei')}`,
|
||||
// **Hier bearbeiten, nicht woandershin springen.** Wer eine
|
||||
// Liste durchgeht, will nach dem Geradeziehen beim nächsten
|
||||
// weitermachen — nicht jedes Mal zurücknavigieren.
|
||||
tun: () => bearbeiten(t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()),
|
||||
}))));
|
||||
}
|
||||
|
||||
if (mehrfach.gesamt > 0) {
|
||||
liste.appendChild(fassungsgruppe(
|
||||
`${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`,
|
||||
'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung. '
|
||||
+ 'Stehen hier zwei verschiedene Filme, war einer davon nicht mehr zu '
|
||||
+ 'sehen: „Eigener Film" löst ihn wieder heraus.',
|
||||
mehrfach.titel));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function anzahl(n: number, wort: string): string {
|
||||
return `${n} ${n === 1 ? wort : (wort === 'Datei' ? 'Dateien' : wort)}`;
|
||||
}
|
||||
|
||||
/** Dieselbe Gruppe, aber jede Datei einzeln mit einem Weg heraus. */
|
||||
function fassungsgruppe(
|
||||
ueberschrift: string, hinweis: string, titel: Mehrfachtitel[],
|
||||
): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'zeile';
|
||||
kopf.innerHTML = `<strong>${esc(ueberschrift)}</strong>`
|
||||
+ `<span class="winzig">${esc(hinweis)}</span>`;
|
||||
d.appendChild(kopf);
|
||||
|
||||
const innen = document.createElement('div');
|
||||
innen.className = 'ohnezuordnung';
|
||||
|
||||
titel.forEach(t => {
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'fassungstitel';
|
||||
|
||||
const name = document.createElement('button');
|
||||
name.className = 'ohnezuordnung-zeile';
|
||||
name.innerHTML = `<span class="oz-titel">${esc(t.titel + (t.jahr ? ` (${t.jahr})` : ''))}</span>`;
|
||||
name.onclick = () => bearbeiten(
|
||||
t.art === 'series' ? 'series' : 'movies', t.id, () => void laden());
|
||||
zeile.appendChild(name);
|
||||
|
||||
t.dateien.forEach(f => {
|
||||
const d2 = document.createElement('div');
|
||||
d2.className = 'fassungsdatei';
|
||||
|
||||
const n = document.createElement('span');
|
||||
n.className = 'oz-dazu';
|
||||
n.textContent = f.name;
|
||||
|
||||
// **Nur bei mehr als einer Datei sinnvoll** — und mehr als eine ist
|
||||
// hier die Bedingung, überhaupt in der Liste zu stehen.
|
||||
const raus = knopf('Eigener Film', 'btn klein');
|
||||
raus.onclick = async () => {
|
||||
raus.disabled = true;
|
||||
try {
|
||||
const neu = await dateiHerausloesen(f.id);
|
||||
hinweisZeigen(`„${neu.title}" ist jetzt ein eigener Film — ohne Zuordnung.`);
|
||||
await laden();
|
||||
} catch (fehler) {
|
||||
hinweisZeigen(meldung(fehler), 'fehler');
|
||||
raus.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
d2.append(n, raus);
|
||||
zeile.appendChild(d2);
|
||||
});
|
||||
|
||||
innen.appendChild(zeile);
|
||||
});
|
||||
|
||||
d.appendChild(innen);
|
||||
return d;
|
||||
}
|
||||
|
||||
function gruppe(
|
||||
ueberschrift: string, hinweis: string,
|
||||
zeilen: { titel: string; dazu: string; tun: () => void }[],
|
||||
): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'zeile';
|
||||
kopf.innerHTML = `<strong>${esc(ueberschrift)}</strong>`
|
||||
+ `<span class="winzig">${esc(hinweis)}</span>`;
|
||||
d.appendChild(kopf);
|
||||
|
||||
const innen = document.createElement('div');
|
||||
innen.className = 'ohnezuordnung';
|
||||
zeilen.forEach(z => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'ohnezuordnung-zeile';
|
||||
b.innerHTML = `<span class="oz-titel">${esc(z.titel)}</span>`
|
||||
+ `<span class="oz-dazu">${esc(z.dazu)}</span>`;
|
||||
b.onclick = z.tun;
|
||||
innen.appendChild(b);
|
||||
});
|
||||
d.appendChild(innen);
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
return bereich;
|
||||
}
|
||||
|
||||
export function unzugeordnetbereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
const kopf = abschnitt('Nicht zugeordnet');
|
||||
bereich.appendChild(kopf);
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
let alle;
|
||||
try { alle = await bibliotheken(); }
|
||||
catch { bereich.classList.add('hidden'); return; }
|
||||
|
||||
const ergebnisse = await Promise.all(alle.map(async b => {
|
||||
try { return { b, offen: await unzugeordnet(b.id) }; }
|
||||
catch { return null; }
|
||||
}));
|
||||
|
||||
const mitFunden = ergebnisse.filter(e => e !== null).filter(e => e!.offen.gesamt > 0);
|
||||
|
||||
// Nichts offen heisst: der Abschnitt hat hier nichts verloren.
|
||||
if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
|
||||
bereich.classList.remove('hidden');
|
||||
|
||||
liste.innerHTML = '';
|
||||
|
||||
mitFunden.forEach(eintrag => {
|
||||
const { b, offen } = eintrag!;
|
||||
|
||||
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(b.name)}</strong>`
|
||||
+ `<span class="winzig">${offen.gesamt} ${offen.gesamt === 1 ? 'Datei' : 'Dateien'} `
|
||||
+ 'ohne Zuordnung</span>';
|
||||
oben.appendChild(text);
|
||||
|
||||
// **Zum Verschicken.** Eine Liste, die man markieren und kopieren muss,
|
||||
// schickt niemand — und ohne die Pfade lässt sich nicht sagen, woran
|
||||
// die Erkennung scheitert.
|
||||
const holen = knopf('Liste herunterladen', 'btn');
|
||||
holen.onclick = () => {
|
||||
const zeilen = offen.dateien.map(f => `${f.pfad}\t${f.grund}`).join('\n');
|
||||
const blob = new Blob(
|
||||
[`# ${b.name} — ${offen.gesamt} ohne Zuordnung, davon ${offen.dateien.length} aufgeführt\n`
|
||||
+ zeilen + '\n'],
|
||||
{ type: 'text/plain;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `nicht-zugeordnet-${b.name}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
oben.appendChild(holen);
|
||||
d.appendChild(oben);
|
||||
|
||||
// **Nach Grund gebündelt, nicht als Liste von dreitausend Zeilen.** So
|
||||
// sieht man in zwei Zeilen, ob umzubenennen oder einzuordnen ist.
|
||||
const gruende = new Map<string, string[]>();
|
||||
offen.dateien.forEach(f => {
|
||||
if (!gruende.has(f.grund)) gruende.set(f.grund, []);
|
||||
gruende.get(f.grund)!.push(f.pfad);
|
||||
});
|
||||
|
||||
[...gruende.entries()]
|
||||
.sort((a, c) => c[1].length - a[1].length)
|
||||
.forEach(([grund, pfade]) => {
|
||||
const g = document.createElement('details');
|
||||
g.className = 'grundblock';
|
||||
|
||||
const z = document.createElement('summary');
|
||||
z.innerHTML = `<strong>${pfade.length}×</strong> ${esc(grund)}`;
|
||||
g.appendChild(z);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'protokoll';
|
||||
pre.textContent = pfade.slice(0, 50).join('\n')
|
||||
+ (pfade.length > 50 ? `\n… und ${pfade.length - 50} weitere` : '');
|
||||
g.appendChild(pre);
|
||||
|
||||
d.appendChild(g);
|
||||
});
|
||||
|
||||
liste.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
return bereich;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user