Der Orden des Phönix stand vor dem Stein der Weisen

Alphabetisch stimmt das, nur sieht man sie andersherum. Es gibt jetzt einen
Sortiertitel: „Harry Potter 1", „Harry Potter 2" — und die Reihe steht in
der Ordnung, in der man sie schaut. Leer gelassen bleibt alles wie bisher,
beim Titel ohne Artikel davor.

Er hat einen eigenen Merker, nicht den des Titels. Wer die Reihenfolge
festlegt, soll den Titel trotzdem weiter automatisch bekommen; beides an
einem Schalter hiesse, für das eine auf das andere zu verzichten. Eine
Korrektur der Zuordnung lässt ihn ebenfalls stehen.

Und WALL·E bleibt nicht mehr unter E. Die Kachel wurde nach einer Korrektur
zwar neu beschriftet, blieb aber an ihrem Platz — jetzt rutscht sie dorthin,
wo sie hingehört. Liegt der Platz im noch nicht geladenen Teil der Liste,
geht sie weg und sagt wohin, statt falsch stehenzubleiben.

Dazu ein Dialog, in dem alles zu einem Titel beieinander steht: Titel,
Sortiertitel, Bilder, Zuordnung. Die vier Menüpunkte gab es schon, jeder für
sich richtig — zusammen aber kein Ort. Wer einen Film geraderückt, macht
selten nur eines davon. Die Abkürzungen bleiben.

Beim Bauen gefunden: das Zurücksetzen des Sortiertitels endete in einer 500.
SortTitle.For ist eine C#-Methode, und EF Core übersetzt sie nicht nach SQL.
Es geht um eine Zeile — die wird jetzt geladen und zurückgeschrieben, statt
die Regel ein zweites Mal als SQL zu schreiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-05 22:25:31 +02:00
co-authored by Claude Opus 5
parent e70c33f7e2
commit fcf40e4394
16 changed files with 1722 additions and 13 deletions
+22
View File
@@ -51,6 +51,12 @@ export type Film = {
publishAt: string | null;
/** Der Titel in der Sprache des Originals, wenn er abweicht. */
originalTitle: string | null;
/** Ob der Titel von Hand gesetzt wurde. */
titlePinned: boolean;
/** Wonach einsortiert wird — leer in Listen älterer Server. */
sortTitle: string | null;
/** Ob der Sortiertitel von Hand gesetzt wurde. */
sortTitlePinned: boolean;
};
export type Serie = {
id: string; title: string; year: number | null; overview: string | null;
@@ -69,6 +75,9 @@ export type Serie = {
publishAt: string | null;
/** Der Titel in der Sprache des Originals, wenn er abweicht. */
originalTitle: string | null;
titlePinned: boolean;
sortTitle: string | null;
sortTitlePinned: boolean;
};
export type Folge = {
id: string; season: number; episode: number;
@@ -862,6 +871,19 @@ export const titelSetzen = (art: 'movies' | 'series', id: string, titel: string)
export const titelFreigeben = (art: 'movies' | 'series', id: string) =>
anfrage<void>(`/${art}/${id}/title`, { method: 'DELETE' });
/** Wonach einsortiert wird, wenn nicht nach dem Titel.
*
* **Für Reihen.** „…Orden des Phönix" kommt alphabetisch vor „…Stein der
* Weisen", obwohl der Stein der erste Teil ist. Mit „Harry Potter 1",
* „Harry Potter 2" stehen sie so, wie man sie sieht. */
export const sortiertitelSetzen = (art: 'movies' | 'series', id: string, titel: string) =>
anfrage<void>(`/${art}/${id}/sorttitle`,
{ method: 'PUT', body: JSON.stringify({ title: titel }) });
/** Zurück zur errechneten Reihenfolge — Artikel vorn abgezogen. */
export const sortiertitelZuruecksetzen = (art: 'movies' | 'series', id: string) =>
anfrage<void>(`/${art}/${id}/sorttitle`, { method: 'DELETE' });
/** Woher ein Titel stammt — Ordner und ein paar Dateinamen. */
export type Herkunft = { ordner: string | null; dateien: number; beispiele: string[] };
+294
View File
@@ -0,0 +1,294 @@
import {
film as filmLaden, serie as serieLaden, bildAdresse,
titelSetzen, titelFreigeben, sortiertitelSetzen, sortiertitelZuruecksetzen,
type Film, type Serie,
} from './api';
import { esc, knopf, feld, fehlerzeile, meldung, hinweisZeigen } from './util';
import { icons, icon } from './icons';
import { bilddialog } from './bilder';
import { zuordnungsdialog } from './zuordnung';
/** Was sich nach dem Bearbeiten geändert hat. */
export type Bearbeitungsergebnis = {
/** Der angezeigte Titel. */
titel: string;
/** Wonach einsortiert wird — darauf achtet die Liste. */
sortiertitel: string | null;
/** Ob der Eintrag in einem anderen aufgegangen ist. Dann gibt es ihn nicht mehr. */
verschmolzen: boolean;
/** Welcher Eintrag das jetzt ist — nach einem Zusammenlegen ein anderer. */
id: string;
};
/** Alles zu einem Titel an einer Stelle.
*
* **Warum es diesen Dialog gibt, obwohl es die Menüpunkte schon gab.**
*
* Titel ändern, Bilder bearbeiten, Zuordnung korrigieren standen als drei
* Einträge im Kontextmenü — jeder für sich richtig, zusammen aber kein Ort.
* Wer einen Film geraderücken will, macht selten nur eines davon: erst sehen,
* was überhaupt zugeordnet ist, dann das Plakat, dann der Name. Dreimal Menü
* aufklappen für eine zusammenhängende Arbeit.
*
* Die Menüpunkte bleiben als Abkürzung. Wer weiss, was er will, soll nicht
* durch einen Dialog müssen.
*/
export function bearbeiten(
art: 'movies' | 'series',
id: string,
fertig?: (ergebnis: Bearbeitungsergebnis) => void,
): void {
const d = document.createElement('dialog');
d.className = 'dlg dlg-breit';
/** Die Kennung kann sich ändern — beim Zusammenlegen überlebt eine andere. */
let jetzige = id;
let verschmolzen = false;
const kopf = document.createElement('div');
kopf.className = 'dlg-kopf';
const h = document.createElement('h2');
h.textContent = 'Bearbeiten';
const zu = document.createElement('button');
zu.className = 'dlg-zu';
zu.innerHTML = icons.schliessen;
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => d.close();
kopf.append(h, zu);
const koerper = document.createElement('div');
koerper.className = 'dlg-koerper';
koerper.innerHTML = '<div class="spinner"></div>';
d.append(kopf, koerper);
d.addEventListener('close', () => {
d.remove();
if (zuletzt) {
fertig?.({
titel: zuletzt.title,
sortiertitel: zuletzt.sortTitle,
verschmolzen,
id: jetzige,
});
}
});
d.addEventListener('click', e => { if (e.target === d) d.close(); });
document.body.appendChild(d);
d.showModal();
/** Der zuletzt geladene Stand — daraus entsteht die Rückmeldung. */
let zuletzt: Film | Serie | null = null;
void laden();
async function laden(): Promise<void> {
try {
zuletzt = art === 'movies' ? await filmLaden(jetzige) : await serieLaden(jetzige);
} catch (fehler) {
koerper.innerHTML = '';
koerper.appendChild(fehlerzeile(meldung(fehler)));
return;
}
zeichnen(zuletzt);
}
function zeichnen(eintrag: Film | Serie): void {
koerper.innerHTML = '';
h.textContent = `Bearbeiten — ${eintrag.title}`;
koerper.append(
kopfzeile(eintrag),
abschnittTitel(eintrag),
abschnittSortierung(eintrag),
abschnittWeiteres(eintrag),
);
}
/* ---------- Kopf: Plakat und was zugeordnet ist ---------- */
function kopfzeile(eintrag: Film | Serie): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'bearb-kopf';
const plakat = document.createElement('div');
plakat.className = 'bearb-plakat';
const adresse = bildAdresse(eintrag.posterImage);
if (adresse) {
const bild = document.createElement('img');
bild.src = adresse;
bild.alt = '';
bild.draggable = false;
plakat.appendChild(bild);
}
const text = document.createElement('div');
text.className = 'bearb-kopftext';
text.innerHTML =
`<strong>${esc(eintrag.title)}</strong>`
+ (eintrag.year ? `<span class="winzig">${eintrag.year}</span>` : '')
+ (eintrag.originalTitle && eintrag.originalTitle !== eintrag.title
? `<span class="hinweis">Original: ${esc(eintrag.originalTitle)}</span>` : '');
reihe.append(plakat, text);
return reihe;
}
/* ---------- Titel ---------- */
function abschnittTitel(eintrag: Film | Serie): HTMLElement {
const block = abschnitt('Titel',
'Was in der Mediathek steht. Von Hand gesetzt bleibt er stehen, auch '
+ 'wenn die Metadaten erneut geholt werden.');
const eingabe = feld('Titel');
eingabe.value = eintrag.title;
const speichern = knopf('Speichern', 'btn btn-primary');
const frei = knopf('Wieder automatisch', 'btn');
frei.disabled = !eintrag.titlePinned;
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
reihe.append(eingabe, speichern, frei);
speichern.onclick = () => void tun(speichern, reihe, async () => {
const neu = eingabe.value.trim();
if (neu.length === 0) throw new Error('Ein Titel darf nicht leer sein.');
await titelSetzen(art, jetzige, neu);
hinweisZeigen('Titel gespeichert.');
await laden();
});
frei.onclick = () => void tun(frei, reihe, async () => {
await titelFreigeben(art, jetzige);
hinweisZeigen('Der Titel kommt beim nächsten Auffrischen wieder aus der Quelle.');
await laden();
});
block.appendChild(reihe);
if (eintrag.titlePinned) block.appendChild(merker('Von Hand gesetzt'));
return block;
}
/* ---------- Sortiertitel ---------- */
function abschnittSortierung(eintrag: Film | Serie): HTMLElement {
const block = abschnitt('Sortiertitel',
'Wonach einsortiert wird, wenn „Nach Titel" sortiert ist. Leer lassen, '
+ 'und es bleibt beim Titel ohne Artikel davor. Für Reihen: „Harry Potter 1", '
+ '„Harry Potter 2" — dann stehen sie so, wie man sie sieht, und nicht '
+ 'alphabetisch durcheinander.');
const eingabe = feld('Sortiertitel');
eingabe.value = eintrag.sortTitle ?? '';
const speichern = knopf('Speichern', 'btn btn-primary');
const frei = knopf('Wieder automatisch', 'btn');
frei.disabled = !eintrag.sortTitlePinned;
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
reihe.append(eingabe, speichern, frei);
speichern.onclick = () => void tun(speichern, reihe, async () => {
const neu = eingabe.value.trim();
if (neu.length === 0) {
throw new Error('Ein Sortiertitel darf nicht leer sein. '
+ 'Zum Zurücksetzen gibt es „Wieder automatisch".');
}
await sortiertitelSetzen(art, jetzige, neu);
hinweisZeigen(`Wird jetzt unter „${neu}" einsortiert.`);
await laden();
});
frei.onclick = () => void tun(frei, reihe, async () => {
await sortiertitelZuruecksetzen(art, jetzige);
hinweisZeigen('Wird wieder nach dem Titel einsortiert.');
await laden();
});
block.appendChild(reihe);
if (eintrag.sortTitlePinned) block.appendChild(merker('Von Hand gesetzt'));
return block;
}
/* ---------- Bilder und Zuordnung ---------- */
function abschnittWeiteres(eintrag: Film | Serie): HTMLElement {
const block = abschnitt('Bilder und Zuordnung',
'Plakat, Hintergrund und Schriftzug — und welchem Eintrag der '
+ 'Metadatenquelle dieser Titel entspricht.');
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
const bilder = knopf('Bilder bearbeiten', 'btn');
bilder.prepend(icon('stift'));
// **Der Dialog bleibt offen dahinter.** Wer das Plakat wechselt, ist mit
// dem Bearbeiten meist nicht fertig — ihn hier zuzumachen hiesse, danach
// wieder von vorn anzufangen.
bilder.onclick = () => bilddialog(art, jetzige, eintrag.title, () => void laden());
const zuordnung = knopf('Zuordnung korrigieren', 'btn');
zuordnung.prepend(icon('suche'));
// **Ohne Jahr.** Das angezeigte stammt aus der Zuordnung, die gerade
// korrigiert werden soll — bei einem falschen Treffer grenzt es die Suche
// auf genau das falsche Jahr ein.
zuordnung.onclick = () => zuordnungsdialog(art, jetzige, eintrag.title, null, stand => {
if (stand.merged) {
// Diesen Eintrag gibt es nicht mehr; weiterzubearbeiten hiesse, auf
// ein Loch zu zeigen. Der Dialog folgt dem überlebenden.
verschmolzen = true;
jetzige = stand.id;
hinweisZeigen(`Mit „${stand.title}" zusammengelegt.`);
}
void laden();
});
reihe.append(bilder, zuordnung);
block.appendChild(reihe);
return block;
}
/* ---------- Hilfsbausteine ---------- */
function abschnitt(titel: string, text: string): HTMLElement {
const b = document.createElement('section');
b.className = 'bearb-abschnitt';
const h3 = document.createElement('h3');
h3.textContent = titel;
const p = document.createElement('p');
p.className = 'hinweis';
p.textContent = text;
b.append(h3, p);
return b;
}
function merker(text: string): HTMLElement {
const m = document.createElement('span');
m.className = 'bearb-merker';
m.innerHTML = icons.haken;
m.append(document.createTextNode(text));
return m;
}
/** Führt etwas aus, sperrt solange die Knöpfe und zeigt, was schiefging. */
async function tun(
ausloeser: HTMLButtonElement, reihe: HTMLElement, arbeit: () => Promise<void>,
): Promise<void> {
const alle = [...reihe.querySelectorAll('button')];
alle.forEach(b => { b.disabled = true; });
reihe.parentElement?.querySelector('.fehler')?.remove();
try {
await arbeit();
} catch (fehler) {
reihe.after(fehlerzeile(meldung(fehler)));
alle.forEach(b => { b.disabled = false; });
void ausloeser;
}
}
}
+37 -1
View File
@@ -8,6 +8,7 @@ import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue';
import { bilddialog } from './bilder';
import { zuordnungsdialog } from './zuordnung';
import { titeldialog } from './titel';
import { bearbeiten } from './bearbeiten';
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
export type Kachel = {
@@ -32,6 +33,10 @@ export type Kachel = {
offen?: number | null;
/** Wie viele Folgen es überhaupt sind — um die Plakette wiederherzustellen. */
gesamt?: number | null;
/** Wonach einsortiert wird. **Damit die Kachel weiss, wohin sie gehört**,
* wenn sich der Titel ändert — sonst stand „WALL·E" nach der Korrektur
* weiter unter E, bis man die Seite neu lud. */
sortTitel?: string | null;
/** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */
freigabe?: string | null;
};
@@ -47,6 +52,7 @@ export const alsKachel = (e: Film | Serie): Kachel => ({
stand: e.state,
offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
gesamt: 'episodeCount' in e ? e.episodeCount : null,
sortTitel: e.sortTitle,
freigabe: e.publishAt,
});
@@ -319,6 +325,24 @@ function kachelpunkte(
if (istAdmin()) {
const art = k.art === 'serien' ? 'series' : 'movies';
// **Ein Ort für alles, und die Abkürzungen bleiben.** Titel, Sortiertitel,
// Bilder und Zuordnung gehören zusammen: wer einen Film geraderückt, macht
// selten nur eines davon. Wer weiss, was er will, nimmt weiter den
// einzelnen Punkt darunter.
punkte.push({
text: 'Bearbeiten',
icon: 'stift',
tun: () => bearbeiten(art, k.id, ergebnis => {
if (ergebnis.verschmolzen) {
knopf.remove();
hinweisZeigen(`„${ergebnis.titel}" wurde mit einem vorhandenen Eintrag zusammengelegt.`);
return;
}
void kachelAuffrischen(k, knopf);
}),
});
punkte.push({
text: 'Bilder bearbeiten',
icon: 'stift',
@@ -377,14 +401,26 @@ function kachelpunkte(
* Eintrag. Alles neu zu laden kostet einen Netzabruf über mehrere hundert
* Titel und setzt die Ansicht an den Anfang zurück.
*/
async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise<void> {
export async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise<void> {
let frisch: Film | Serie;
try { frisch = k.art === 'serien' ? await serie(k.id) : await film(k.id); }
catch { return; } // Still bleiben: die alte Kachel ist besser als eine Meldung.
const vorher = k.sortTitel;
k.titel = frisch.title;
k.zweite = frisch.year ? String(frisch.year) : null;
k.bild = frisch.posterImage;
k.sortTitel = frisch.sortTitle;
// **Die Liste erfährt, dass die Kachel woandershin gehört.** Sie allein
// weiss, ob der neue Platz überhaupt geladen ist.
if (vorher !== k.sortTitel) {
knopf.dispatchEvent(new CustomEvent('kachel-umsortiert', {
bubbles: true,
detail: { id: k.id, sortTitel: k.sortTitel, titel: k.titel },
}));
}
const text = knopf.querySelector('.kacheltext');
if (text) {
+39
View File
@@ -1543,3 +1543,42 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
@media (prefers-reduced-motion: reduce) {
.auswahlleiste:not(.hidden) { animation: none; }
}
/* ---------- Bearbeiten: alles zu einem Titel an einer Stelle ---------- */
.bearb-kopf {
display: flex; gap: var(--s-regular); align-items: center;
padding-bottom: var(--s-regular); border-bottom: 1px solid var(--line);
margin-bottom: var(--s-section);
}
.bearb-plakat {
width: 72px; aspect-ratio: 2 / 3; flex: 0 0 auto;
border-radius: var(--r-control); overflow: hidden; background: var(--placeholder);
}
.bearb-plakat img { width: 100%; height: 100%; object-fit: cover; }
.bearb-kopftext { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.bearb-kopftext strong { font-size: 17px; }
.bearb-abschnitt { margin-bottom: var(--s-section); }
.bearb-abschnitt:last-child { margin-bottom: 0; }
.bearb-abschnitt h3 {
font-size: 14px; font-weight: 600; margin: 0 0 4px;
text-transform: uppercase; letter-spacing: .04em; color: var(--text-dim);
}
.bearb-abschnitt .hinweis { margin: 0 0 var(--s-snug); }
/* Das Eingabefeld nimmt den Platz, die Knöpfe nur, was sie brauchen. */
.bearb-abschnitt .knopfreihe .feld { flex: 1 1 220px; min-width: 0; }
/* **Sagt, dass hier jemand von Hand eingegriffen hat.** Ohne das sieht ein
gesetzter Wert aus wie ein geholter, und niemand weiss, warum das
Auffrischen ihn in Ruhe lässt. */
.bearb-merker {
display: inline-flex; align-items: center; gap: 5px;
margin-top: var(--s-snug);
font-size: 12px; color: var(--accent);
}
.bearb-merker svg { width: 14px; height: 14px; }
@media (max-width: 560px) {
.bearb-abschnitt .knopfreihe .feld { flex: 1 1 100%; }
}
+4
View File
@@ -20,6 +20,7 @@ import { kontextmenue, menueAnZiel, type Menuepunkt } from '../menue';
import { bilddialog } from '../bilder';
import { zuordnungsdialog } from '../zuordnung';
import { titeldialog } from '../titel';
import { bearbeiten } from '../bearbeiten';
/** Die Verwaltung.
*
@@ -337,6 +338,9 @@ function pruefbereich(geaendert: () => void): HTMLElement {
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',
+50
View File
@@ -396,6 +396,56 @@ export function mediathek(
nachAuswahl();
}
/* ---------- Umsortieren nach einer Korrektur ----------
*
* **„WALL·E" stand nach der Korrektur weiter unter E.**
*
* Die Kachel wurde zwar neu beschriftet, blieb aber an ihrem Platz — bis
* man die Seite neu lud. Jetzt rutscht sie dorthin, wo sie hingehört.
*
* **Und wenn der Platz gar nicht geladen ist, geht sie.** Bei 447 Titeln
* sind zweihundert da; ein Film, der von E nach W wandert, hat seinen
* neuen Platz oft noch gar nicht vor sich. Ihn ans Ende zu hängen wäre
* falsch, ihn stehenzulassen auch — also sagt die Liste, wohin er ging,
* und nimmt ihn weg.
*/
gitter.addEventListener('kachel-umsortiert', e => {
const { id, sortTitel, titel } = (e as CustomEvent<{
id: string; sortTitel: string | null; titel: string;
}>).detail;
// Nur beim Sortieren nach Titel steht die Reihenfolge auf dem
// Sortiertitel. Nach Jahr oder Zugang entscheidet etwas anderes, und
// das kennt die Kachel nicht.
if (ordnung !== 'title') return;
const k = gitter.querySelector<HTMLElement>(`.kachel[data-id="${CSS.escape(id)}"]`);
if (!k) return;
const schluessel = (sortTitel ?? titel).toLocaleLowerCase('de');
const andere = [...gitter.querySelectorAll<HTMLElement>('.kachel')].filter(x => x !== k);
const davor = andere.find(x => {
const seiner = bekannt.get(x.dataset.id ?? '');
const sein = (seiner?.sortTitel ?? seiner?.titel ?? '').toLocaleLowerCase('de');
return sein.localeCompare(schluessel, 'de') > 0;
});
if (davor) { gitter.insertBefore(k, davor); return; }
// Hinter allem Geladenen — also irgendwo im noch nicht geholten Teil.
if (!amEnde) {
k.remove();
bekannt.delete(id);
gewaehlt.delete(id);
nachAuswahl();
hinweisZeigen(`„${titel}" steht jetzt weiter hinten in der Liste.`);
return;
}
gitter.appendChild(k);
});
/** Streicht man an den Rand, wandert die Seite mit.
*
* **Sonst endet jede Auswahl am unteren Bildrand.** Bei 447 Titeln sind