diff --git a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs index ac5de8b..78664b3 100644 --- a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs @@ -25,6 +25,48 @@ public static class MatchEndpoints { public static void MapMatching(this IEndpointRouteBuilder app) { + // **Woher dieser Titel kommt.** + // + // Steht im Dialog über der Suche. Wenn eine Zuordnung völlig daneben + // liegt — „Dark Shadows" von 1966 statt „Dark" —, sagt der angezeigte + // Titel nichts mehr darüber, welche Datei da eigentlich gemeint ist. + // Der Ordner sagt es, und zwar sofort. + app.MapGet("/api/v1/{art:regex(^movies|series$)}/{id:guid}/herkunft", async ( + string art, Guid id, FlaroContext db) => + { + var dateien = art == "movies" + ? db.MediaFiles.AsNoTracking().Where(f => f.MovieId == id) + : db.MediaFiles.AsNoTracking().Where(f => f.EpisodeId != null + && db.Episodes.Any(e => e.Id == f.EpisodeId && e.SeriesId == id)); + + // **Gezählt wird vollständig, gezeigt nur eine Handvoll.** Hier + // stand erst ein `Take(50)` vor dem Zählen — bei 1200 Folgen + // behauptete der Dialog dann „50 Dateien". Eine Zahl, die bei + // genau dem Titel falsch wird, bei dem man hinsieht. + // + // **Ein Zug, dann im Speicher.** Der Ordner aus einem Pfad zu + // schneiden lässt sich nicht nach SQL übersetzen — der Versuch + // endete in einem 500er. Die Pfade eines einzelnen Titels sind + // auch bei 1200 Folgen ein paar hundert Kilobyte; das ist + // billiger als eine Abfrage, die nicht läuft. + var pfade = await dateien.Select(f => f.Path).ToListAsync(); + if (pfade.Count == 0) return Results.Ok(new Herkunft(null, 0, [])); + + var ordner = pfade + .Select(p => Path.GetDirectoryName(p) ?? "") + .Distinct(StringComparer.Ordinal) + .ToList(); + + return Results.Ok(new Herkunft( + ordner.Count == 1 ? ordner[0] : GemeinsamerAnfang(ordner), + pfade.Count, + pfade.Order(StringComparer.Ordinal) + .Take(6) + .Select(Path.GetFileName) + .Where(n => n is not null) + .ToList()!)); + }).RequireAuthorization(FlaroPolicies.Administrator); + app.MapGet("/api/v1/movies/{id:guid}/matches", async ( Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) => { @@ -85,4 +127,36 @@ public static class MatchEndpoints return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year)); }).RequireAuthorization(FlaroPolicies.Administrator); } + + /// Der längste Pfad, mit dem alle anfangen — oder null. + /// + /// + /// Verglichen wird nach Ordnerbestandteilen, nicht nach Zeichen: sonst + /// ergäben /media/Serien/Dark und /media/Serien/Darkwing den + /// gemeinsamen Anfang /media/Serien/Dark, und der bezeichnet einen + /// Ordner, in dem nur eines von beidem liegt. + /// + private static string? GemeinsamerAnfang(IReadOnlyList pfade) + { + if (pfade.Count == 0) return null; + + var teile = pfade.Select(p => p.Split('/', StringSplitOptions.RemoveEmptyEntries)).ToList(); + var gemeinsam = new List(); + + for (var i = 0; i < teile[0].Length; i++) + { + var stueck = teile[0][i]; + if (teile.Any(t => i >= t.Length || t[i] != stueck)) break; + gemeinsam.Add(stueck); + } + + return gemeinsam.Count == 0 ? null : "/" + string.Join('/', gemeinsam); + } } + +/// Woher ein Titel im Dateisystem stammt. +/// +/// Der gemeinsame Ordner aller Dateien; null, wenn es keinen gibt. +/// Wie viele Dateien an diesem Titel hängen. +/// Ein paar Dateinamen — mehr braucht niemand zum Wiedererkennen. +public sealed record Herkunft(string? Ordner, int Dateien, IReadOnlyList Beispiele); diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 7befc0e..cd1198c 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -20,7 +20,15 @@ pfade.EnsureExists(); builder.Services.AddSingleton(pfade); builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddDbContext(o => o.UseSqlite($"Data Source={pfade.DatabaseFile}")); +// **Mit Wartezeit statt sofortigem Abbruch.** +// +// SQLite lässt genau einen Schreiber zu. Ohne `Default Timeout` scheitert +// jeder, der dazwischenkommt, auf der Stelle mit „database is locked" — +// während eines Scans über tausende Dateien ist das jede zweite Anfrage aus +// der Oberfläche. Dreissig Sekunden sind grosszügig; gebraucht werden +// Millisekunden. +builder.Services.AddDbContext(o => + o.UseSqlite($"Data Source={pfade.DatabaseFile};Default Timeout=30")); builder.Services .AddAuthentication(TokenAuthenticationHandler.SchemeName) @@ -124,7 +132,28 @@ var app = builder.Build(); // hochfährt, soll nicht erst eine Migration von Hand anstossen müssen. using (var bereich = app.Services.CreateScope()) { - await bereich.ServiceProvider.GetRequiredService().Database.MigrateAsync(); + var db = bereich.ServiceProvider.GetRequiredService(); + + // **WAL, und zwar vor der Migration.** + // + // In der Vorgabe („delete") sperrt ein Schreiber die ganze Datei: solange + // der Scan schreibt, kommt *keine* Leseanfrage durch. Genau so sah es in + // der Oberfläche aus — Bilddialog und Protokoll liefen während eines + // Laufs sporadisch ins Leere. Mit WAL lesen beliebig viele weiter, + // während einer schreibt. + // + // Die Einstellung hält sich in der Datei; einmal gesetzt bleibt sie. Sie + // trotzdem bei jedem Start zu setzen kostet nichts und deckt den Fall ab, + // dass jemand mit einer frischen Datei kommt. + await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;"); + + // Ohne das wartet SQLite nach jeder Schreibung auf die Platte. Mit WAL + // ist „normal" die übliche Wahl: ein Absturz des Prozesses kostet nichts, + // nur ein Stromausfall mitten im Schreiben könnte die letzte Transaktion + // kosten — und die ist bei einem Medienserver ein Scanfortschritt. + await db.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;"); + + await db.Database.MigrateAsync(); } app.UseExceptionHandler(); diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 1adcfc1..300e6e8 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -711,6 +711,12 @@ export const metadatenSetzen = (werte: { apiKey?: string; language?: string }) = export const vorschlaege = (art: 'movies' | 'series', id: string, suche?: string, jahr?: number) => anfrage(`/${art}/${id}/matches${abfrage({ query: suche, year: jahr })}`); +/** Woher ein Titel stammt — Ordner und ein paar Dateinamen. */ +export type Herkunft = { ordner: string | null; dateien: number; beispiele: string[] }; + +export const herkunft = (art: 'movies' | 'series', id: string) => + anfrage(`/${art}/${id}/herkunft`); + export const zuordnen = (art: 'movies' | 'series', id: string, tmdbId: number) => anfrage<{ tmdbId: number; imdbId: string | null; locked: boolean; title: string; year: number | null }>( `/${art}/${id}/match`, { method: 'PUT', body: JSON.stringify({ tmdbId }) }); diff --git a/web/app/src/main.ts b/web/app/src/main.ts index feca85b..94ac729 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -1,6 +1,7 @@ import './style.css'; import { systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken, + reihenfolgeSetzen, type Hinweis, type Bibliothek, } from './api'; import { icons, icon } from './icons'; @@ -133,6 +134,9 @@ function kopfleisteBauen(): void { /* ---------- Seitenleiste ---------- */ +/** Ob die Seitenleiste gerade umsortiert wird. */ +let leisteBearbeiten = false; + function leisteBauen(): void { leiste.innerHTML = ''; @@ -142,31 +146,112 @@ function leisteBauen(): void { // Zeile „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme // die Entscheidung zurück, sie zu trennen. if (mediatheken.length) { - gruppe('Mediatheken'); - mediatheken.forEach(b => eintrag( - b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount)); + gruppe('Mediatheken', istAdmin() && mediatheken.length > 1); + mediatheken.forEach((b, i) => eintrag( + b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i)); } if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); } - function gruppe(text: string): void { + /** Eine Gruppenüberschrift — bei den Mediatheken mit dem Stift daneben. + * + * **Der Griff sitzt dort, wo die Reihenfolge wirkt.** Vorher standen die + * Pfeile in den Einstellungen: man schob dort etwas und musste danach in + * die Seitenleiste sehen, ob es das war, was man wollte. + */ + function gruppe(text: string, mitStift = false): void { const d = document.createElement('div'); d.className = 'nav-gruppe'; - d.textContent = text; + + const t = document.createElement('span'); + t.textContent = text; + d.appendChild(t); + + if (mitStift) { + const stift = document.createElement('button'); + stift.className = 'nav-bearbeiten' + (leisteBearbeiten ? ' an' : ''); + stift.innerHTML = leisteBearbeiten + ? '' + icons.haken + '' + : '' + icons.stift + ''; + stift.title = leisteBearbeiten ? 'Fertig' : 'Seitenleiste bearbeiten'; + stift.setAttribute('aria-label', stift.title); + stift.onclick = () => { leisteBearbeiten = !leisteBearbeiten; leisteBauen(); }; + d.appendChild(stift); + } + leiste.appendChild(d); } function eintrag( - text: string, ziel: string, bild: keyof typeof icons, anzahl?: number | null, + text: string, ziel: string, bild: keyof typeof icons, + anzahl?: number | null, platz?: number, ): void { const b = document.createElement('button'); b.dataset.ziel = ziel; b.innerHTML = `${icons[bild]}` + `${esc(text)}` - + (anzahl != null ? `${anzahl}` : ''); - b.onclick = () => { location.hash = ziel; }; + + (anzahl != null && !leisteBearbeiten ? `${anzahl}` : ''); + + // Im Bearbeitungsmodus führt die Zeile nirgendwohin — sonst wechselt man + // beim Zielen auf einen Pfeil versehentlich die Ansicht. + if (leisteBearbeiten && platz != null) { + b.classList.add('verschiebbar'); + b.appendChild(pfeil('hoch', platz, platz - 1, platz === 0, text)); + b.appendChild(pfeil('runter', platz, platz + 1, platz === mediatheken.length - 1, text)); + } else { + b.onclick = () => { location.hash = ziel; }; + } + leiste.appendChild(b); } + + /** Ein Pfeil zum Schieben. + * + * **Zwei Knöpfe statt Ziehen und Ablegen.** Ziehen ist schöner, aber mit + * der Tastatur unbedienbar und auf dem Telefon ein Glücksspiel. + */ + function pfeil( + richtung: 'hoch' | 'runter', von: number, nach: number, gesperrt: boolean, name: string, + ): HTMLElement { + const p = document.createElement('span'); + p.className = 'nav-pfeil' + (gesperrt ? ' gesperrt' : ''); + p.setAttribute('role', 'button'); + if (!gesperrt) p.setAttribute('tabindex', '0'); + p.setAttribute('aria-label', `„${name}" nach ${richtung === 'hoch' ? 'oben' : 'unten'}`); + p.innerHTML = icons.hoch; + if (richtung === 'runter') p.style.transform = 'rotate(180deg)'; + + const los = (e: Event) => { + e.stopPropagation(); + if (gesperrt) return; + void schieben(von, nach); + }; + p.onclick = los; + p.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); los(e); } }; + return p; + } +} + +/** Schiebt eine Mediathek und schickt die ganze neue Reihenfolge. + * + * **Die ganze Liste, nicht „eins hoch".** Zwei Benutzer, die gleichzeitig + * schieben, kämen sonst in einen Zustand, den keiner von beiden wollte. + */ +async function schieben(von: number, nach: number): Promise { + if (nach < 0 || nach >= mediatheken.length) return; + + const neu = [...mediatheken]; + const [eintrag] = neu.splice(von, 1); + neu.splice(nach, 0, eintrag!); + + // Sofort zeichnen, damit der Klick sich sofort anfühlt; der Server bekommt + // dieselbe Liste und widerspricht beim nächsten Laden, falls doch etwas + // schiefging. + mediatheken = neu; + leisteBauen(); + + try { await reihenfolgeSetzen(neu.map(b => b.id)); } + catch { await zaehlerAuffrischen(); } } /* ---------- Router ---------- */ diff --git a/web/app/src/style.css b/web/app/src/style.css index 53f4f20..294606f 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -164,8 +164,34 @@ svg { width: 20px; height: 20px; display: block; flex: none; } .nav-gruppe { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--text-faint); padding: var(--s-loose) var(--s-loose) var(--s-snug); + display: flex; align-items: center; justify-content: space-between; gap: var(--s-snug); } +/* Der Stift erscheint beim Überfahren der Seitenleiste — ständig sichtbar + * wäre er ein Knopf, den man einmal im Jahr braucht, an einer Stelle, an der + * jeden Tag etwas anderes gesucht wird. Bei Tastaturfokus immer. */ +.nav-bearbeiten { + background: none; border: 0; padding: 2px; border-radius: var(--r-knopf); + color: var(--text-faint); opacity: 0; transition: opacity .12s ease; +} +#chrome:hover .nav-bearbeiten, +.nav-bearbeiten:focus-visible, +.nav-bearbeiten.an { opacity: 1; } +.nav-bearbeiten:hover { color: var(--text); } +.nav-bearbeiten.an { color: var(--accent); } +.nav-bearbeiten svg { width: 15px; height: 15px; display: block; } + +/* Im Bearbeitungsmodus trägt die Zeile zwei Pfeile statt der Zahl. */ +#chrome button.verschiebbar { cursor: default; } +.nav-pfeil { + display: inline-flex; align-items: center; justify-content: center; + width: 20px; height: 20px; flex: none; + border-radius: var(--r-knopf); color: var(--text-muted); +} +.nav-pfeil:hover { background: var(--raised); color: var(--text); } +.nav-pfeil.gesperrt { opacity: .25; pointer-events: none; } +.nav-pfeil svg { width: 14px; height: 14px; } + #chrome button { display: flex; align-items: center; gap: var(--s-regular); background: none; border: 0; text-align: left; @@ -933,6 +959,19 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m .dlg-breit { width: min(860px, 100vw - 48px); } +/* Die Herkunft im Zuordnungsdialog — der Pfad bricht um statt abzuschneiden, + * weil ausgerechnet das Ende die Antwort trägt. */ +.quelle { + display: flex; flex-direction: column; gap: 2px; + padding: var(--s-snug) var(--s-regular); + background: var(--raised); border-radius: var(--r-knopf); + border-left: 2px solid var(--accent); +} +.quelle-pfad { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--t-klein); color: var(--text); word-break: break-all; +} + .bildblock { display: flex; flex-direction: column; gap: var(--s-snug); } .bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; } diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 5634032..ae4853b 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -4,7 +4,7 @@ import { vermisste, vermisstesEntfernen, type VermissteDatei, protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, - mediathekFreigeben, reihenfolgeSetzen, mediathekZuruecksetzen, + mediathekFreigeben, mediathekZuruecksetzen, type Pruefeintrag, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, @@ -14,7 +14,7 @@ import { } from '../api'; import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../util'; import { dialog, nachfragen } from '../dialog'; -import { icon, icons } from '../icons'; +import { icon } from '../icons'; /** Die Verwaltung. * @@ -545,21 +545,6 @@ function vermisstenbereich(geaendert: () => void): HTMLElement { /* ---------- Bibliotheken ---------- */ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement { - /** Die Liste, wie sie gerade steht — für das Umsortieren. */ - let alleBibliotheken: Bibliothek[] = []; - - /** Schiebt einen Eintrag und schickt die ganze neue Reihenfolge. */ - async function schieben(von: number, nach: number): Promise { - if (nach < 0 || nach >= alleBibliotheken.length) return; - - const neu = [...alleBibliotheken]; - const [eintrag] = neu.splice(von, 1); - neu.splice(nach, 0, eintrag!); - - await reihenfolgeSetzen(neu.map(b => b.id)); - await laden(); - geaendert(); - } const bereich = document.createElement('section'); @@ -613,15 +598,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.')); return; } - alleBibliotheken = alle; - alle.forEach((b, i) => liste.appendChild(block(b, i))); + alle.forEach(b => liste.appendChild(block(b))); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); } } - function block(b: Bibliothek, platz: number): HTMLElement { + function block(b: Bibliothek): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung'; @@ -649,12 +633,35 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle const los = knopf('Scannen', 'btn'); los.onclick = async () => { - los.disabled = true; + laufendSetzen(true); try { await scanStarten(b.id); await verfolgen(true); } - catch (fehler) { stand.textContent = meldung(fehler); } - los.disabled = false; + 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.'; @@ -664,7 +671,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle + 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. ' + 'Bei vielen Titeln dauert es.', bestaetigen: 'Auffrischen', - tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(true); }, + tun: async () => { + laufendSetzen(true); + await metadatenAuffrischen(b.id); + void verfolgen(true); + }, }); const umbenennen = knopf('Umbenennen', 'btn'); @@ -721,15 +732,35 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle const s = await scanstand(b.id); // **Was der Server sagt, nicht was wir raten.** Vorher stand hier // minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert. - if (s.state === 'running') stand.textContent = s.step ?? 'Wird durchsucht…'; - else if (s.state === 'enriching') stand.textContent = s.step ?? 'Metadaten werden geholt…'; - else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; } + // **Die 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; + } - // **Die Zahl mitlaufen lassen, nicht erst am Ende.** Der Scan schreibt - // laufend; wer dabei zusieht, soll das auch sehen. Alle fünf Runden, - // nicht jede: ein Abruf je Sekunde für eine Zahl wäre Verschwendung. - if (melden && i > 0 && i % 5 === 0) await laden(); - else if (s.state === 'finished') { + // **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) { await laden(); geaendert(); } + + 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. @@ -739,11 +770,16 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle 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 @@ -760,30 +796,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle } }; - // **Umsortieren über zwei Pfeile, nicht über Ziehen.** + // **Umsortiert wird in der Seitenleiste, nicht hier.** // - // Ziehen und Ablegen ist schöner, aber mit der Tastatur unbedienbar und - // auf dem Telefon ein Glücksspiel. Zwei Knöpfe kann jeder bedienen, und - // der Server bekommt die ganze Liste statt eines „eins hoch" — zwei - // Benutzer, die gleichzeitig schieben, kämen sonst in einen Zustand, den - // keiner von beiden wollte. - const hoch = knopf('', 'btn rund'); - hoch.innerHTML = icons.hoch; - hoch.setAttribute('aria-label', `„${b.name}“ nach oben`); - hoch.disabled = platz === 0; - hoch.onclick = () => void schieben(platz, platz - 1); - - const runter = knopf('', 'btn rund'); - runter.innerHTML = icons.hoch; - runter.style.transform = 'rotate(180deg)'; - runter.setAttribute('aria-label', `„${b.name}“ nach unten`); - runter.disabled = platz === alleBibliotheken.length - 1; - runter.onclick = () => void schieben(platz, platz + 1); - + // 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( - hoch, runter, los, auffrischen, pruefung, umbenennen, zuruecksetzen, entfernen); + 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 diff --git a/web/app/src/zuordnung.ts b/web/app/src/zuordnung.ts index bf0fa10..9af5ae6 100644 --- a/web/app/src/zuordnung.ts +++ b/web/app/src/zuordnung.ts @@ -1,4 +1,4 @@ -import { vorschlaege, zuordnen } from './api'; +import { vorschlaege, zuordnen, herkunft } from './api'; import { esc, knopf, feld, fehlerzeile, leer, meldung } from './util'; import { icons } from './icons'; @@ -35,6 +35,36 @@ export function zuordnungsdialog( const koerper = document.createElement('div'); koerper.className = 'dlg-koerper'; + // **Woher der Titel stammt, über allem anderen.** + // + // Liegt die Zuordnung völlig daneben, sagt der angezeigte Titel nichts mehr + // darüber, welche Datei hier eigentlich gemeint ist — „Dark Shadows" von + // 1966 kann alles sein. Der Ordner sagt es in einer Zeile. + const quelle = document.createElement('div'); + quelle.className = 'quelle'; + quelle.innerHTML = 'Herkunft wird gelesen…'; + + void herkunft(art, id).then(h => { + quelle.innerHTML = ''; + + const ordner = document.createElement('div'); + ordner.className = 'quelle-pfad'; + ordner.textContent = h.ordner ?? 'Kein gemeinsamer Ordner'; + ordner.title = h.ordner ?? ''; + quelle.appendChild(ordner); + + const dazu = document.createElement('span'); + dazu.className = 'winzig'; + const namen = h.beispiele.join(' · '); + dazu.textContent = h.dateien === 0 + ? 'Keine Datei mehr zugeordnet.' + : `${h.dateien} ${h.dateien === 1 ? 'Datei' : 'Dateien'}` + + (namen ? ` — ${namen}${h.dateien > h.beispiele.length ? ' …' : ''}` : ''); + quelle.appendChild(dazu); + }).catch(() => { + quelle.innerHTML = 'Herkunft nicht lesbar.'; + }); + const hinweis = document.createElement('p'); hinweis.className = 'hinweis'; hinweis.textContent = 'Der gewählte Eintrag ersetzt Titel, Beschreibung, Bilder ' @@ -53,7 +83,7 @@ export function zuordnungsdialog( const treffer = document.createElement('div'); treffer.className = 'einstellungen'; - koerper.append(hinweis, reihe, treffer); + koerper.append(quelle, hinweis, reihe, treffer); d.append(kopf, koerper); d.addEventListener('close', () => d.remove());