Vier Fehler, und bei zweien log der Kommentar daneben

**Der Einstellungsknopf lag hinter der Abspielleiste.** Gemessen: `.pl`
steht auf Stufe 150, `#chrome` auf gar keiner, und die kleine Leiste spannte
über die ganze Breite — genau über die unteren 72 Bildpunkte der
Seitenleiste, wo „Einstellungen" klebt. Weil der Spieler bei jedem Wechsel
der Adresse minimiert, stand sie zuverlässig im Weg, sobald man dorthin
wollte.

Die Leiste beginnt jetzt hinter der Seitenleiste. Nicht über die Stufen
gelöst, und das ist der Punkt: `#chrome` höher zu setzen hiesse, die
Seitenleiste läge auch über dem grossen Spieler — es ist dieselbe Hülle.
Dass diese Leiste Platz wegnimmt statt sich darüberzulegen, stand für
Inhalt, Hinweise und Auswahlleiste ohnehin schon da; die Seitenleiste war
die vergessene vierte. Gemessen, 1400 Bildpunkte breit: Leiste ab 240,
Knopf bei 0, keine Überschneidung, und ein Treffertest in der Knopfmitte
trifft den Knopf.

**Ein Klick auf eine Kategorie kam nie an.** Dort sprang der Code in die
Mediathek und suchte danach `.filterzeile select`, um den Wert zu setzen.
Die Filterzeile hat kein `<select>` — `auswahl()` baut einen Knopf, mit
Absicht und begründet. Der Zweig lief also nie, und man landete in der
ungefilterten Mediathek. Der Kommentar daneben behauptete das Gegenteil.

Das Genre steht jetzt in der Adresse: `#/m/{id}/mediathek/Horror`. Das
löst zugleich das zweite Problem — die Mediathek wird unter ihrem Weg
gemerkt, und stünde das Genre nur im Kopf, zeigte ein zweiter Besuch die
Liste des ersten. Geprüft: Klick setzt den Weg und die Pille, Neuladen
behält beides, Zurück führt auf die Kategorien, Vorwärts wieder hinein.

**Zurückgezogene Einladungen blieben für immer stehen.** Der Knopfblock
steckte ganz in `if (!e.revokedAt)`, eine erledigte Zeile hatte also gar
keinen Knopf. Die Serverbegründung — eine Zeile wegzuwerfen hiesse,
hinterher nicht mehr zu wissen, dass es den Link je gab — bleibt richtig,
sie gilt nur nicht ewig: eine Liste, aus der nichts verschwindet, wird
unlesbar, und dann sieht man die eine Zeile nicht mehr, um die es ging.

Deshalb kein umgedeutetes DELETE, sondern `?purge=true`, und das greift nur
bei einem Link, der ohnehin keine Tür mehr öffnet. Ein gültiger wird mit
einem deutschen Satz abgewiesen.

**Poster in der falschen Sprache.** „365 Tage" stand mit polnischem Plakat
da. Kein fehlender Anbieter: für den Schriftzug gab es die sprachgewichtete
Wahl längst, fürs Plakat wurde das blanke `poster_path` der Hauptantwort
genommen — bei TMDB das Bild der Originalsprache, wenn in der gewünschten
keines eingetragen ist. Die sortierte Liste lag im selben Abruf schon vor
und wurde ausschliesslich für den Schriftzug benutzt.

Plakat und Hintergrund nehmen jetzt dieselbe Rangfolge: eingestellte
Sprache, dann sprachlos, dann Englisch. Sprachlos vor Englisch ist keine
Verlegenheit — ein Bild ohne Sprachkennung trägt keinen Text, und ein
Plakat ohne Titel ist für einen deutschen Bestand besser als dasselbe mit
englischem. Im Bilderdialog stehen die Kandidaten jetzt ebenso sortiert.
Bestehende Titel ändern sich beim nächsten Auffrischen; von Hand gesetzte
Plakate bleiben unangetastet.

676 Tests grün, davon zehn neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 17:46:46 +02:00
co-authored by Claude Opus 5
parent c6df9fdab0
commit 2809821c9b
9 changed files with 391 additions and 25 deletions
@@ -81,13 +81,38 @@ public static class EinladungEndpoints
// **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse, // **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse,
// hinterher nicht mehr zu wissen, dass es den Link je gab — und genau // hinterher nicht mehr zu wissen, dass es den Link je gab — und genau
// das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht. // 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 ( 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); var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct);
if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr."); 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); await db.SaveChangesAsync(ct);
return Results.NoContent(); return Results.NoContent();
}); });
+71 -11
View File
@@ -137,8 +137,14 @@ public sealed class TmdbClient(
// durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen. // durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen.
if (film is null or { Id: 0 }) return null; 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), ImdbId = Leer(film.ExternalIds?.ImdbId),
RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null, RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null,
Rating = film.VoteAverage > 0 ? film.VoteAverage : 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; 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), ImdbId = Leer(serie.ExternalIds?.ImdbId),
// Die übliche Länge einer Folge, nicht die der ganzen Serie. // Die übliche Länge einer Folge, nicht die der ganzen Serie.
RuntimeMinutes = serie.EpisodeRunTime?.FirstOrDefault(l => l > 0), RuntimeMinutes = serie.EpisodeRunTime?.FirstOrDefault(l => l > 0),
@@ -425,8 +437,12 @@ public sealed class TmdbClient(
void Sammeln(List<TmdbImage>? bilder, string art, string groesse) void Sammeln(List<TmdbImage>? 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)) 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)) .Take(24))
{ {
liste.Add(new Bildkandidat( liste.Add(new Bildkandidat(
@@ -443,22 +459,66 @@ public sealed class TmdbClient(
[property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops, [property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops,
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos); [property: JsonPropertyName("logos")] List<TmdbImage>? Logos);
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache) /// <summary>Wie gut ein Bild zur eingestellten Sprache passt — kleiner ist besser.</summary>
///
/// <remarks>
/// <para>
/// Eingestellte Sprache, dann sprachlos, dann Englisch, dann der Rest.
/// </para>
/// <para>
/// <b>Sprachlos vor Englisch, und das ist keine Verlegenheit.</b> 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.
/// </para>
/// </remarks>
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;
/// <summary>Das passendste Bild einer Liste — oder <c>null</c>, wenn sie leer ist.</summary>
private static RemoteImage? Bestes(List<TmdbImage>? 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; if (alle is not { Count: > 0 }) return null;
var beste = alle var beste = alle
.OrderBy(l => string.Equals(l.Language, sprache, StringComparison.OrdinalIgnoreCase) ? 0 .OrderBy(b => Sprachrang(b.Language, sprache))
: string.IsNullOrEmpty(l.Language) ? 1 .ThenByDescending(b => b.VoteAverage ?? 0)
: string.Equals(l.Language, "en", StringComparison.OrdinalIgnoreCase) ? 2
: 3)
.ThenByDescending(l => l.VoteAverage ?? 0)
.First(); .First();
return Bild(beste.FilePath, LogoSize); return Bild(beste.FilePath, groesse);
} }
/// <summary>Plakat und Hintergrund in der Sprache, die eingestellt ist.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Fehler, den das behebt.</b> Für den Schriftzug gab es diese
/// Wahl längst; für Plakat und Hintergrund wurde das blanke
/// <c>poster_path</c> 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.
/// </para>
/// <para>
/// <b>Die Liste kommt ohnehin mit.</b> <c>append_to_response=…,images</c>
/// 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.
/// </para>
/// </remarks>
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) => private static string? Freigabe(TmdbReleaseDates? quelle, string land) =>
Leer(quelle?.Results? Leer(quelle?.Results?
.FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))? .FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))?
@@ -260,4 +260,75 @@ public class EinladungTests
Assert.That(leer.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); Assert.That(leer.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}); });
} }
/// <summary>Eine zurückgezogene Einladung lässt sich endgültig entfernen.</summary>
///
/// <remarks>
/// <b>Warum das überhaupt nötig ist.</b> 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.
/// </remarks>
[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<List<Einladungseintrag>>("/api/v1/invites");
Assert.Multiple(() =>
{
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(liste, Is.Empty, "die Zeile muss die Liste verlassen");
});
}
/// <summary>Ein gültiger Link lässt sich nicht wegwischen.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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<List<Einladungseintrag>>("/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");
});
}
/// <summary>Ein aufgebrauchter Link darf weg, auch ohne Zurückziehen.</summary>
[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));
}
} }
@@ -0,0 +1,144 @@
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Metadata.Tests;
/// <summary>Welches Plakat und welcher Hintergrund genommen werden.</summary>
///
/// <remarks>
/// <para>
/// <b>Der gemeldete Fehler.</b> „365 Tage" stand mit polnischem Plakat da,
/// obwohl in der eingestellten Sprache gesucht wurde. TMDB liefert im
/// Einzelabruf das Feld <c>poster_path</c> 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
/// (<c>append_to_response=…,images</c>) und wurde ausschliesslich für den
/// Schriftzug benutzt.
/// </para>
/// <para>
/// <b>Sprachlos vor Englisch.</b> 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.
/// </para>
/// </remarks>
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<TmdbClient>.Instance);
private const string Suche =
"""{"results":[{"id":500,"title":"Probe","release_date":"2020-02-07"}]}""";
/// <summary>Ein Einzelabruf mit Bilderliste und einem Rückfallfeld.</summary>
/// <summary>Ein Einzelabruf mit Bilderliste und einem Rückfallfeld.</summary>
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<Core.Metadata.MovieMetadata?> 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"));
}
/// <summary>Bei gleicher Sprache entscheiden die Stimmen.</summary>
[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"));
}
/// <summary>Ohne Liste bleibt es beim Feld der Hauptantwort.</summary>
///
/// <remarks>
/// Ein alter Zwischenspeicher oder eine abgespeckte Antwort darf nicht
/// dazu führen, dass gar kein Plakat mehr herauskommt.
/// </remarks>
[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"));
});
}
/// <summary>Der Hintergrund folgt derselben Regel.</summary>
[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"));
}
/// <summary>Eine andere eingestellte Sprache verschiebt die Rangfolge mit.</summary>
[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"));
}
}
+5
View File
@@ -1188,6 +1188,11 @@ export const einladungen = () => anfrage<Einladung[]>('/invites');
export const einladungZurueckziehen = (id: string) => export const einladungZurueckziehen = (id: string) =>
anfrage<void>(`/invites/${id}`, { method: 'DELETE' }); anfrage<void>(`/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<void>(`/invites/${id}?purge=true`, { method: 'DELETE' });
/** Was hinter einem Link steckt — ohne Anmeldung abrufbar. */ /** Was hinter einem Link steckt — ohne Anmeldung abrufbar. */
export const einladungAnsehen = (token: string) => export const einladungAnsehen = (token: string) =>
anfrage<Einladungsschau>(`/invites/${encodeURIComponent(token)}`); anfrage<Einladungsschau>(`/invites/${encodeURIComponent(token)}`);
+5 -1
View File
@@ -475,8 +475,12 @@ function zeichnen(): void {
// anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger // anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger
// als eine Fehlermeldung über eine Kennung, die niemand gelesen hat. // als eine Fehlermeldung über eine Kennung, die niemand gelesen hat.
if (b) { 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, einhaengen(weg, inhalt,
() => mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen), () => mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen,
teile[3] ? decodeURIComponent(teile[3]) : ''),
{ merken: true }); { merken: true });
} else location.hash = '#/'; } else location.hash = '#/';
} else if (teile[0] === 'suche' && kennung) { } else if (teile[0] === 'suche' && kennung) {
+18
View File
@@ -1013,6 +1013,21 @@ body.pl-offen { overflow: hidden; }
.pl.pl-klein { .pl.pl-klein {
inset: auto 0 0 0; inset: auto 0 0 0;
height: 72px; 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 /* **`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, Hülle ihre Kinder; in der Leiste zog das die Beschriftung in die Mitte,
weit weg vom Vorschaubild, an dem sie hängt. */ 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 { 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; } #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-gruppe { display: none; }
.nav-fuss { margin: 0; padding: 0; display: flex; } .nav-fuss { margin: 0; padding: 0; display: flex; }
#content { margin-left: 0; padding: var(--s-loose); } #content { margin-left: 0; padding: var(--s-loose); }
+17 -2
View File
@@ -1,6 +1,6 @@
import { import {
benutzer, benutzerAnlegen, adminrechtSetzen, besitzUebergeben, benutzerLoeschen, benutzer, benutzerAnlegen, adminrechtSetzen, besitzUebergeben, benutzerLoeschen,
einladungAnlegen, einladungen, einladungZurueckziehen, sitzung, einladungAnlegen, einladungen, einladungZurueckziehen, einladungEntfernen, sitzung,
type Einladung, type Rolle, type Einladung, type Rolle,
} from '../../api'; } from '../../api';
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util'; import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util';
@@ -216,7 +216,7 @@ export function benutzerbereich(): HTMLElement {
reihe.className = 'knopfreihe'; reihe.className = 'knopfreihe';
reihe.appendChild(zeile); reihe.appendChild(zeile);
if (!e.revokedAt) { if (!e.revokedAt && e.valid) {
const weg = knopf('Zurückziehen', 'btn'); const weg = knopf('Zurückziehen', 'btn');
weg.onclick = async () => { weg.onclick = async () => {
weg.disabled = true; weg.disabled = true;
@@ -224,6 +224,21 @@ export function benutzerbereich(): HTMLElement {
catch (fehler) { weg.disabled = false; d.appendChild(fehlerzeile(meldung(fehler))); } catch (fehler) { weg.disabled = false; d.appendChild(fehlerzeile(meldung(fehler))); }
}; };
reihe.appendChild(weg); 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); d.appendChild(reihe);
+33 -9
View File
@@ -23,6 +23,7 @@ export function mediathek(
servername: string, servername: string,
reiter: string, reiter: string,
oeffnen: (art: Art, id: string) => void, oeffnen: (art: Art, id: string) => void,
startgenre = '',
): HTMLElement { ): HTMLElement {
const wurzel = document.createElement('div'); const wurzel = document.createElement('div');
const welcher: Reiter = const welcher: Reiter =
@@ -74,7 +75,18 @@ export function mediathek(
function raster(ziel: HTMLElement): void { function raster(ziel: HTMLElement): void {
let suche = ''; let suche = '';
let ordnung: Ordnung = 'title'; 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 `<select>`, das es gar nicht gibt. Steht das Genre im Weg,
* braucht es diesen Griff nicht: die Ansicht liest ihn beim Bauen.
*
* **Und der Zwischenspeicher stimmt dann auch.** Die Mediathek wird
* unter ihrem Weg gemerkt. Stünde das Genre nur im Kopf, zeigte ein
* zweiter Besuch die Liste des ersten. */
let genre = startgenre;
let tippt: number | undefined; let tippt: number | undefined;
let gesehen: '' | 'offen' | 'gesehen' = ''; let gesehen: '' | 'offen' | 'gesehen' = '';
@@ -109,7 +121,18 @@ export function mediathek(
return { huelle, wahl }; return { huelle, wahl };
} }
const genrePille = pille('Alle Genres', [], () => genre, w => { genre = w; }); /** Der Weg zu dieser Mediathek, mit Genre, wenn eines gesetzt ist. */
const wegMit = (g: string) =>
`#/m/${bibliothek.id}/mediathek` + (g ? `/${encodeURIComponent(g)}` : '');
// **Die Pille schreibt die Adresse, statt nur neu zu laden.** Dadurch
// überlebt der Filter das Neuladen der Seite, lässt sich weitergeben,
// und der gemerkte Aufbau gehört immer zu genau dem Genre, unter dem er
// gemerkt wurde.
const genrePille = pille('Alle Genres', [], () => genre, w => {
genre = w;
location.hash = wegMit(w);
});
const standPille = pille('Alle', [ const standPille = pille('Alle', [
['offen', 'Noch nicht gesehen'], ['offen', 'Noch nicht gesehen'],
@@ -806,14 +829,15 @@ export function mediathek(
// Zuordnung Genre→Farbe müsste jedes Genre kennen, das TMDB je // Zuordnung Genre→Farbe müsste jedes Genre kennen, das TMDB je
// erfindet. So bekommt jedes eine eigene und immer dieselbe. // erfindet. So bekommt jedes eine eigene und immer dieselbe.
k.style.background = farbeAus(name); k.style.background = farbeAus(name);
// **Das Genre steht im Weg, nicht in einem Griff ins DOM.**
//
// Hier stand einmal ein `setTimeout`, das nach dem Sprung ein
// `.filterzeile select` suchte und dessen Wert setzte. Die
// Filterzeile hat aber gar kein `<select>` — `auswahl()` baut
// einen Knopf, und zwar mit Absicht. Der Zweig lief also nie, und
// man landete in der ungefilterten Mediathek.
k.onclick = () => { k.onclick = () => {
location.hash = `#/m/${bibliothek.id}/mediathek`; location.hash = `#/m/${bibliothek.id}/mediathek/${encodeURIComponent(name)}`;
// 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<HTMLSelectElement>('.filterzeile select');
if (wahl) { wahl.value = name; wahl.dispatchEvent(new Event('change')); }
}, 0);
}; };
gitter.appendChild(k); gitter.appendChild(k);
}); });