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:
Scarriffle
2026-10-06 18:24:44 +02:00
co-authored by Claude Opus 5
parent 9c2e2b5f47
commit 327e2e9195
16 changed files with 582 additions and 280 deletions
@@ -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;
}