diff --git a/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs b/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs index 65aa1d6..c77cc2c 100644 --- a/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs @@ -81,13 +81,38 @@ public static class EinladungEndpoints // **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse, // hinterher nicht mehr zu wissen, dass es den Link je gab — und genau // das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht. + // + // **Und mit `purge=true` doch.** Die Begründung oben bleibt richtig, + // sie gilt nur nicht ewig: eine Liste, aus der nichts je verschwindet, + // wird irgendwann unlesbar, und dann sieht man die eine Zeile nicht + // mehr, um die es ging. Weggeworfen wird deshalb nur, was ohnehin + // keine Tür mehr öffnet — zurückgezogen, abgelaufen oder + // aufgebraucht. Ein gültiger Link lässt sich nicht wegwischen; erst + // zurückziehen, dann entfernen, und das ist ein Handgriff mehr mit + // Absicht. verwalten.MapDelete("/{id:guid}", async ( - Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct) => + Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct, + bool purge = false) => { var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct); if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr."); - einladung.RevokedAt ??= uhr.GetUtcNow(); + var jetzt = uhr.GetUtcNow(); + + if (purge) + { + if (einladung.Gilt(jetzt)) + { + return Einwand.Bitte( + "Dieser Einladungslink gilt noch. Zieh ihn erst zurück, dann lässt er sich entfernen."); + } + + db.Einladungen.Remove(einladung); + await db.SaveChangesAsync(ct); + return Results.NoContent(); + } + + einladung.RevokedAt ??= jetzt; await db.SaveChangesAsync(ct); return Results.NoContent(); }); diff --git a/server/src/Flaro.Metadata/TmdbClient.cs b/server/src/Flaro.Metadata/TmdbClient.cs index 463f546..fe5067f 100644 --- a/server/src/Flaro.Metadata/TmdbClient.cs +++ b/server/src/Flaro.Metadata/TmdbClient.cs @@ -137,8 +137,14 @@ public sealed class TmdbClient( // durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen. if (film is null or { Id: 0 }) return null; - return Als(film, "") with + var grund = Als(film, ""); + var (plakat, hintergrund) = Bildwahl( + film.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop); + + return grund with { + Poster = plakat, + Backdrop = hintergrund, ImdbId = Leer(film.ExternalIds?.ImdbId), RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null, Rating = film.VoteAverage > 0 ? film.VoteAverage : null, @@ -207,8 +213,14 @@ public sealed class TmdbClient( if (serie is null or { Id: 0 }) return null; - return Als(serie, "") with + var grund = Als(serie, ""); + var (plakat, hintergrund) = Bildwahl( + serie.Images, Sprachkürzel(einstellung.Language), grund.Poster, grund.Backdrop); + + return grund with { + Poster = plakat, + Backdrop = hintergrund, ImdbId = Leer(serie.ExternalIds?.ImdbId), // Die übliche Länge einer Folge, nicht die der ganzen Serie. RuntimeMinutes = serie.EpisodeRunTime?.FirstOrDefault(l => l > 0), @@ -425,8 +437,12 @@ public sealed class TmdbClient( void Sammeln(List? bilder, string art, string groesse) { + // **Erst die Sprache, dann die Stimmen.** Sonst steht oben, was + // der Welt gefällt, und nicht, was hierher passt — und wer von + // Hand wählt, scrollt an zwanzig englischen Plakaten vorbei. foreach (var b in (bilder ?? []).Where(b => !string.IsNullOrWhiteSpace(b.FilePath)) - .OrderByDescending(b => b.VoteAverage ?? 0) + .OrderBy(b => Sprachrang(b.Language, sprache)) + .ThenByDescending(b => b.VoteAverage ?? 0) .Take(24)) { liste.Add(new Bildkandidat( @@ -443,22 +459,66 @@ public sealed class TmdbClient( [property: JsonPropertyName("backdrops")] List? Backdrops, [property: JsonPropertyName("logos")] List? Logos); - private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache) + /// Wie gut ein Bild zur eingestellten Sprache passt — kleiner ist besser. + /// + /// + /// + /// Eingestellte Sprache, dann sprachlos, dann Englisch, dann der Rest. + /// + /// + /// Sprachlos vor Englisch, und das ist keine Verlegenheit. Ein + /// Bild ohne Sprachkennung trägt keinen Text — ein Plakat ohne Titel, + /// ein Hintergrund ohne eingebrannten Schriftzug. Das ist für einen + /// deutschen Bestand besser als dasselbe Bild mit englischem Titel + /// darüber. + /// + /// + private static int Sprachrang(string? sprache, string gewuenscht) => + string.Equals(sprache, gewuenscht, StringComparison.OrdinalIgnoreCase) ? 0 + : string.IsNullOrEmpty(sprache) ? 1 + : string.Equals(sprache, "en", StringComparison.OrdinalIgnoreCase) ? 2 + : 3; + + /// Das passendste Bild einer Liste — oder null, wenn sie leer ist. + private static RemoteImage? Bestes(List? bilder, string sprache, string groesse) { - var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList(); + var alle = bilder?.Where(b => !string.IsNullOrWhiteSpace(b.FilePath)).ToList(); if (alle is not { Count: > 0 }) return null; var beste = alle - .OrderBy(l => string.Equals(l.Language, sprache, StringComparison.OrdinalIgnoreCase) ? 0 - : string.IsNullOrEmpty(l.Language) ? 1 - : string.Equals(l.Language, "en", StringComparison.OrdinalIgnoreCase) ? 2 - : 3) - .ThenByDescending(l => l.VoteAverage ?? 0) + .OrderBy(b => Sprachrang(b.Language, sprache)) + .ThenByDescending(b => b.VoteAverage ?? 0) .First(); - return Bild(beste.FilePath, LogoSize); + return Bild(beste.FilePath, groesse); } + /// Plakat und Hintergrund in der Sprache, die eingestellt ist. + /// + /// + /// + /// Der Fehler, den das behebt. Für den Schriftzug gab es diese + /// Wahl längst; für Plakat und Hintergrund wurde das blanke + /// poster_path der Hauptantwort genommen. Das ist bei TMDB das + /// Bild der Originalsprache, wenn in der gewünschten keines eingetragen + /// ist — gemeldet an „365 Tage", das mit polnischem Plakat dastand, + /// obwohl sprachneutrale Plakate vorlagen. + /// + /// + /// Die Liste kommt ohnehin mit. append_to_response=…,images + /// liefert sie beim selben Abruf; sie zu benutzen kostet keine Anfrage. + /// Fehlt sie — ein alter Zwischenspeicher, eine abgespeckte Antwort —, + /// bleibt es beim Feld der Hauptantwort. + /// + /// + private static (RemoteImage? Plakat, RemoteImage? Hintergrund) Bildwahl( + TmdbImages? bilder, string sprache, RemoteImage? rueckfallPlakat, RemoteImage? rueckfallHintergrund) => + (Bestes(bilder?.Posters, sprache, PosterSize) ?? rueckfallPlakat, + Bestes(bilder?.Backdrops, sprache, BackdropSize) ?? rueckfallHintergrund); + + private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache) => + Bestes(bilder?.Logos, sprache, LogoSize); + private static string? Freigabe(TmdbReleaseDates? quelle, string land) => Leer(quelle?.Results? .FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))? diff --git a/server/tests/Flaro.Api.Tests/EinladungTests.cs b/server/tests/Flaro.Api.Tests/EinladungTests.cs index c58e6f7..2b30d2a 100644 --- a/server/tests/Flaro.Api.Tests/EinladungTests.cs +++ b/server/tests/Flaro.Api.Tests/EinladungTests.cs @@ -260,4 +260,75 @@ public class EinladungTests Assert.That(leer.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); }); } + + /// Eine zurückgezogene Einladung lässt sich endgültig entfernen. + /// + /// + /// Warum das überhaupt nötig ist. Zurückziehen behält die Spur + /// absichtlich — man will wissen, dass es den Link gab, wenn jemand + /// Unerwartetes im Haushalt auftaucht. Nur verlässt die Zeile die Liste + /// dann nie, und eine Liste, aus der nichts verschwindet, wird + /// irgendwann unlesbar. Weggeworfen wird deshalb nur, was ohnehin keine + /// Tür mehr öffnet. + /// + [Test] + public async Task Eine_zurueckgezogene_Einladung_laesst_sich_entfernen() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + (await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}")).EnsureSuccessStatusCode(); + var weg = await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}?purge=true"); + + var liste = await alsAdmin.GetFromJsonAsync>("/api/v1/invites"); + + Assert.Multiple(() => + { + Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(liste, Is.Empty, "die Zeile muss die Liste verlassen"); + }); + } + + /// Ein gültiger Link lässt sich nicht wegwischen. + /// + /// + /// Sonst wäre „Entfernen" ein stilles Zurückziehen mit Gedächtnisverlust: + /// der Link gälte weiter, stünde aber nirgends mehr. Erst zurückziehen, + /// dann entfernen — ein Handgriff mehr, mit Absicht. + /// + [Test] + public async Task Ein_gueltiger_Link_laesst_sich_nicht_entfernen() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var weg = await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}?purge=true"); + var liste = await alsAdmin.GetFromJsonAsync>("/api/v1/invites"); + + Assert.Multiple(() => + { + Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(liste, Has.Count.EqualTo(1)); + Assert.That(liste![0].Valid, Is.True, "und er gilt danach immer noch"); + }); + } + + /// Ein aufgebrauchter Link darf weg, auch ohne Zurückziehen. + [Test] + public async Task Ein_aufgebrauchter_Link_laesst_sich_entfernen() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin, wieOft: 1); + + (await Einloesen(server.OhneGedaechtnis(), einladung.Token, "gast")).EnsureSuccessStatusCode(); + var weg = await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}?purge=true"); + + Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + } } diff --git a/server/tests/Flaro.Metadata.Tests/Bildwahltests.cs b/server/tests/Flaro.Metadata.Tests/Bildwahltests.cs new file mode 100644 index 0000000..917fc85 --- /dev/null +++ b/server/tests/Flaro.Metadata.Tests/Bildwahltests.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace Flaro.Metadata.Tests; + +/// Welches Plakat und welcher Hintergrund genommen werden. +/// +/// +/// +/// Der gemeldete Fehler. „365 Tage" stand mit polnischem Plakat da, +/// obwohl in der eingestellten Sprache gesucht wurde. TMDB liefert im +/// Einzelabruf das Feld poster_path der Originalsprache, wenn in der +/// gewünschten keines eingetragen ist — und genau dieses Feld wurde +/// genommen. Die sprachsortierte Liste lag in derselben Antwort schon vor +/// (append_to_response=…,images) und wurde ausschliesslich für den +/// Schriftzug benutzt. +/// +/// +/// Sprachlos vor Englisch. Ein Bild ohne Sprachkennung trägt keinen +/// Text. Für einen deutschen Bestand ist ein Plakat ohne Titel besser als +/// dasselbe mit englischem Titel darüber. +/// +/// +public class Bildwahltests +{ + private static TmdbClient Client(Attrappe attrappe, string sprache = "de-DE") => + new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) }, + FesteEinstellungen.MitSchluessel(sprache), NullLogger.Instance); + + private const string Suche = + """{"results":[{"id":500,"title":"Probe","release_date":"2020-02-07"}]}"""; + + /// Ein Einzelabruf mit Bilderliste und einem Rückfallfeld. + /// Ein Einzelabruf mit Bilderliste und einem Rückfallfeld. + private static Attrappe MitBildern(string posters, string backdrops = "[]") => + Attrappe.NachPfad( + ("search/movie", Suche), + ("movie/500", + "{\"id\":500,\"title\":\"Probe\",\"release_date\":\"2020-02-07\"," + + "\"poster_path\":\"/original.jpg\",\"backdrop_path\":\"/grund.jpg\"," + + "\"images\":{\"posters\":" + posters + + ",\"backdrops\":" + backdrops + "}}")); + + private static Task Holen(Attrappe a, string sprache = "de-DE") => + Client(a, sprache).FindMovieAsync("Probe", 2020); + + [Test] + public async Task Das_deutsche_Plakat_sticht_das_der_Originalsprache() + { + var film = await Holen(MitBildern(""" + [{"file_path":"/pl.jpg","iso_639_1":"pl","vote_average":9.0}, + {"file_path":"/de.jpg","iso_639_1":"de","vote_average":1.0}] + """)); + + Assert.That(film!.Poster!.Url, Does.EndWith("/de.jpg"), + "die eingestellte Sprache gewinnt, auch gegen die besseren Stimmen"); + } + + [Test] + public async Task Ohne_deutsches_Plakat_gewinnt_das_sprachlose() + { + var film = await Holen(MitBildern(""" + [{"file_path":"/en.jpg","iso_639_1":"en","vote_average":9.0}, + {"file_path":"/ohne.jpg","iso_639_1":null,"vote_average":1.0}, + {"file_path":"/pl.jpg","iso_639_1":"pl","vote_average":8.0}] + """)); + + Assert.That(film!.Poster!.Url, Does.EndWith("/ohne.jpg"), + "ein Plakat ohne Text ist besser als eines mit englischem"); + } + + [Test] + public async Task Gibt_es_nur_Englisch_und_Fremdes_gewinnt_Englisch() + { + var film = await Holen(MitBildern(""" + [{"file_path":"/pl.jpg","iso_639_1":"pl","vote_average":9.0}, + {"file_path":"/en.jpg","iso_639_1":"en","vote_average":1.0}] + """)); + + Assert.That(film!.Poster!.Url, Does.EndWith("/en.jpg")); + } + + /// Bei gleicher Sprache entscheiden die Stimmen. + [Test] + public async Task Unter_Gleichsprachigen_gewinnen_die_Stimmen() + { + var film = await Holen(MitBildern(""" + [{"file_path":"/de-schwach.jpg","iso_639_1":"de","vote_average":2.0}, + {"file_path":"/de-stark.jpg","iso_639_1":"de","vote_average":8.0}] + """)); + + Assert.That(film!.Poster!.Url, Does.EndWith("/de-stark.jpg")); + } + + /// Ohne Liste bleibt es beim Feld der Hauptantwort. + /// + /// + /// Ein alter Zwischenspeicher oder eine abgespeckte Antwort darf nicht + /// dazu führen, dass gar kein Plakat mehr herauskommt. + /// + [Test] + public async Task Ohne_Bilderliste_bleibt_das_Feld_der_Hauptantwort() + { + var attrappe = Attrappe.NachPfad( + ("search/movie", Suche), + ("movie/500", """ + {"id":500,"title":"Probe","release_date":"2020-02-07", + "poster_path":"/original.jpg","backdrop_path":"/grund.jpg"} + """)); + + var film = await Holen(attrappe); + + Assert.Multiple(() => + { + Assert.That(film!.Poster!.Url, Does.EndWith("/original.jpg")); + Assert.That(film.Backdrop!.Url, Does.EndWith("/grund.jpg")); + }); + } + + /// Der Hintergrund folgt derselben Regel. + [Test] + public async Task Der_Hintergrund_bevorzugt_das_Bild_ohne_Schrift() + { + var film = await Holen(MitBildern("[]", """ + [{"file_path":"/mit-titel.jpg","iso_639_1":"en","vote_average":9.0}, + {"file_path":"/ohne-titel.jpg","iso_639_1":null,"vote_average":1.0}] + """)); + + Assert.That(film!.Backdrop!.Url, Does.EndWith("/ohne-titel.jpg")); + } + + /// Eine andere eingestellte Sprache verschiebt die Rangfolge mit. + [Test] + public async Task Die_Rangfolge_haengt_an_der_eingestellten_Sprache() + { + var bilder = """ + [{"file_path":"/de.jpg","iso_639_1":"de","vote_average":9.0}, + {"file_path":"/fr.jpg","iso_639_1":"fr","vote_average":1.0}] + """; + + var film = await Holen(MitBildern(bilder), "fr-FR"); + + Assert.That(film!.Poster!.Url, Does.EndWith("/fr.jpg")); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 5609cc5..fd74cdd 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1188,6 +1188,11 @@ export const einladungen = () => anfrage('/invites'); export const einladungZurueckziehen = (id: string) => anfrage(`/invites/${id}`, { method: 'DELETE' }); +/** Wirft die Zeile endgültig weg — nur bei einem Link, der ohnehin nichts + * mehr öffnet. Ein gültiger wird vom Server abgewiesen. */ +export const einladungEntfernen = (id: string) => + anfrage(`/invites/${id}?purge=true`, { method: 'DELETE' }); + /** Was hinter einem Link steckt — ohne Anmeldung abrufbar. */ export const einladungAnsehen = (token: string) => anfrage(`/invites/${encodeURIComponent(token)}`); diff --git a/web/app/src/main.ts b/web/app/src/main.ts index 324e031..fe2dcd3 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -475,8 +475,12 @@ function zeichnen(): void { // anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger // als eine Fehlermeldung über eine Kennung, die niemand gelesen hat. if (b) { + // Das vierte Segment ist das Genre: `#/m/{id}/mediathek/Horror`. Es + // gehört zum Weg und damit auch zum gemerkten Aufbau — zwei Genres + // sind zwei Ansichten, nicht eine mit wechselndem Inhalt. einhaengen(weg, inhalt, - () => mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen), + () => mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen, + teile[3] ? decodeURIComponent(teile[3]) : ''), { merken: true }); } else location.hash = '#/'; } else if (teile[0] === 'suche' && kennung) { diff --git a/web/app/src/style.css b/web/app/src/style.css index cb6f9fe..6d771f6 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1013,6 +1013,21 @@ body.pl-offen { overflow: hidden; } .pl.pl-klein { inset: auto 0 0 0; height: 72px; + /* **Die Leiste fängt hinter der Seitenleiste an.** + * + * Gemessen: `.pl` liegt auf Stufe 150, `#chrome` auf gar keiner. Die + * Leiste spannte deshalb über die ganze Breite und legte sich über die + * unteren 72 Bildpunkte der Seitenleiste — genau dort, wo „Einstellungen" + * klebt. Und weil der Spieler bei jedem Wechsel der Adresse minimiert, + * stand sie zuverlässig im Weg, sobald man dorthin wollte. + * + * **Nicht über die Stufen gelöst, und das ist der Punkt.** `#chrome` + * über 150 zu heben hiesse, die Seitenleiste läge auch über dem grossen + * Spieler — es ist dieselbe Hülle. Dass diese Leiste Platz wegnimmt, + * statt sich darüberzulegen, steht weiter unten ohnehin schon für den + * Inhalt, die Hinweise und die Auswahlleiste; die Seitenleiste war die + * vergessene vierte. */ + left: var(--sidebar-w); /* **`place-items` muss ausdrücklich zurück.** Im ganzen Bild zentriert die Hülle ihre Kinder; in der Leiste zog das die Beschriftung in die Mitte, weit weg vom Vorschaubild, an dem sie hängt. */ @@ -1098,6 +1113,9 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; } } #chrome button { border-left: 0; border-bottom: 3px solid transparent; white-space: nowrap; } #chrome button.check { border-left-color: transparent; border-bottom-color: var(--accent); background: none; } + /* Hier ist die Seitenleiste eine Zeile oben; unten ist sie nicht im Weg, + und die Leiste darf die volle Breite nehmen. */ + .pl.pl-klein { left: 0; } .nav-gruppe { display: none; } .nav-fuss { margin: 0; padding: 0; display: flex; } #content { margin-left: 0; padding: var(--s-loose); } diff --git a/web/app/src/views/einstellungen/benutzer.ts b/web/app/src/views/einstellungen/benutzer.ts index da036ed..138b9fa 100644 --- a/web/app/src/views/einstellungen/benutzer.ts +++ b/web/app/src/views/einstellungen/benutzer.ts @@ -1,6 +1,6 @@ import { benutzer, benutzerAnlegen, adminrechtSetzen, besitzUebergeben, benutzerLoeschen, - einladungAnlegen, einladungen, einladungZurueckziehen, sitzung, + einladungAnlegen, einladungen, einladungZurueckziehen, einladungEntfernen, sitzung, type Einladung, type Rolle, } from '../../api'; import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util'; @@ -216,7 +216,7 @@ export function benutzerbereich(): HTMLElement { reihe.className = 'knopfreihe'; reihe.appendChild(zeile); - if (!e.revokedAt) { + if (!e.revokedAt && e.valid) { const weg = knopf('Zurückziehen', 'btn'); weg.onclick = async () => { weg.disabled = true; @@ -224,6 +224,21 @@ export function benutzerbereich(): HTMLElement { catch (fehler) { weg.disabled = false; d.appendChild(fehlerzeile(meldung(fehler))); } }; reihe.appendChild(weg); + } else { + // **Erst wenn er nichts mehr öffnet.** Eine erledigte Zeile hatte + // bisher überhaupt keinen Knopf und blieb für immer stehen; die Liste + // wuchs und wurde unlesbar. Weggeworfen wird mit Rückfrage, wie beim + // Löschen eines Kontos — danach weiss niemand mehr, dass es den Link + // gab, und genau das ist der Grund, warum es ein eigener Schritt ist. + const entfernen = knopf('Entfernen', 'btn btn-gefahr'); + entfernen.onclick = () => nachfragen({ + titel: 'Einladung entfernen', + text: 'Diese Zeile verschwindet aus der Liste. Danach ist nicht mehr ' + + 'nachzusehen, dass es diesen Link gab.', + bestaetigen: 'Entfernen', + tun: async () => { await einladungEntfernen(e.id); await laden(); }, + }); + reihe.appendChild(entfernen); } d.appendChild(reihe); diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index d60b554..e043a03 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -23,6 +23,7 @@ export function mediathek( servername: string, reiter: string, oeffnen: (art: Art, id: string) => void, + startgenre = '', ): HTMLElement { const wurzel = document.createElement('div'); const welcher: Reiter = @@ -74,7 +75,18 @@ export function mediathek( function raster(ziel: HTMLElement): void { let suche = ''; let ordnung: Ordnung = 'title'; - let genre = ''; + + /** Das Genre steht in der Adresse, nicht nur im Kopf. + * + * **Warum in der Adresse.** Ein Klick auf eine Kategorie sprang bisher + * in die Mediathek und versuchte danach, den Filter im DOM zu setzen — + * an einem `` — `auswahl()` baut + // einen Knopf, und zwar mit Absicht. Der Zweig lief also nie, und + // man landete in der ungefilterten Mediathek. k.onclick = () => { - location.hash = `#/m/${bibliothek.id}/mediathek`; - // Der Filter wird über die Adresse gesetzt, sobald das Raster - // steht — sonst schriebe man in ein Element, das es nicht gibt. - window.setTimeout(() => { - const wahl = document.querySelector('.filterzeile select'); - if (wahl) { wahl.value = name; wahl.dispatchEvent(new Event('change')); } - }, 0); + location.hash = `#/m/${bibliothek.id}/mediathek/${encodeURIComponent(name)}`; }; gitter.appendChild(k); });