From 343b0cdcfb5772cb4dcbe874aa210a64ae6f78e7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 6 Oct 2026 15:10:30 +0200 Subject: [PATCH] Ein Knopf, der nichts tut, ist schlimmer als keiner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der geplante Film zeigte weiter „Abspielen" und „Herunterladen", und wer drückte, bekam „Dieser Titel ist ab dem 6.10. um 18 Uhr verfügbar". Die Sperre im Server war richtig; die Oberfläche wusste nur nichts davon. Gemessen: die Liste führte `PublishAt` seit jeher mit, die Einzelabfrage `GET /api/v1/movies/{id}` nicht — und die Detailseite entscheidet genau daran. `publishAt` kam also immer als `null` an, die Prüfung lief ins Leere. Dasselbe fehlte bei `GET /api/v1/series/{id}` und bei den Folgen. Jetzt steht dort, wo sonst der Knopf stünde: „Bald ist es soweit", mit dem Termin darunter. Kein Versprechen, das beim Klicken zurückgenommen wird. Der Test prüfte bisher nur die Liste — darum fiel es nicht auf. Er fragt jetzt die Detailseite und schlägt ohne die Korrektur fehl. Co-Authored-By: Claude Opus 5 --- server/src/Flaro.Api/Endpoints/Contracts.cs | 3 ++- .../Endpoints/LibraryContentEndpoints.cs | 18 +++++++++++------- server/tests/Flaro.Api.Tests/FreigabeTests.cs | 10 ++++++++++ web/app/src/api.ts | 2 ++ web/app/src/style.css | 12 +++++++----- web/app/src/views/detail.ts | 18 ++++++++++++++---- 6 files changed, 46 insertions(+), 17 deletions(-) diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index fb14537..30be4eb 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -429,7 +429,8 @@ public sealed record SeriesResponse( public sealed record EpisodeResponse( Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, IReadOnlyList Files, - ItemStateResponse? State); + ItemStateResponse? State, + DateTimeOffset? PublishAt = null); public sealed record Page(IReadOnlyList Items, int Total); diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 590337d..a3ea99f 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -627,7 +627,8 @@ public static class LibraryContentEndpoints OriginalTitle: film.OriginalTitle, TitlePinned: film.TitlePinned, SortTitle: film.SortTitleValue, - SortTitlePinned: film.SortTitlePinned)); + SortTitlePinned: film.SortTitlePinned, + PublishAt: film.PublishAt)); }).RequireAuthorization(); app.MapGet("/api/v1/series/{id:guid}", async ( @@ -640,7 +641,7 @@ public static class LibraryContentEndpoints var folgen = await db.Episodes.AsNoTracking() .Where(f => f.SeriesId == id) - .Select(f => new { f.Id, f.SeasonNumber }) + .Select(f => new { f.Id, f.SeasonNumber, f.PublishAt }) .ToListAsync(); var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList()); @@ -661,7 +662,8 @@ public static class LibraryContentEndpoints State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id), TitlePinned: serie.TitlePinned, SortTitle: serie.SortTitleValue, - SortTitlePinned: serie.SortTitlePinned)); + SortTitlePinned: serie.SortTitlePinned, + PublishAt: folgen.Min(f => f.PublishAt))); }).RequireAuthorization(); // **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann @@ -681,7 +683,7 @@ public static class LibraryContentEndpoints .Where(f => f.SeasonNumber > diese.SeasonNumber || (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber)) .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) - .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage }) + .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt }) .FirstOrDefaultAsync(); // Keine weitere Folge ist kein Fehler — es war die letzte. @@ -706,7 +708,8 @@ public static class LibraryContentEndpoints naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber, naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild, dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(), - stände.GetValueOrDefault(naechste.Id))); + stände.GetValueOrDefault(naechste.Id), + naechste.PublishAt)); }).RequireAuthorization(); app.MapGet("/api/v1/series/{id:guid}/episodes", async ( @@ -719,7 +722,7 @@ public static class LibraryContentEndpoints var folgen = await abfrage .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) - .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage }) + .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt }) .ToListAsync(); var wurzel = await MediathekspfadFuer(db, wer, @@ -743,7 +746,8 @@ public static class LibraryContentEndpoints f.StillImage ?? serienBild, dateien.Where(d => d.EpisodeId == f.Id) .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(), - stände.GetValueOrDefault(f.Id))).ToList(); + stände.GetValueOrDefault(f.Id), + f.PublishAt)).ToList(); return Results.Ok(new Page(items, items.Count)); }).RequireAuthorization(); diff --git a/server/tests/Flaro.Api.Tests/FreigabeTests.cs b/server/tests/Flaro.Api.Tests/FreigabeTests.cs index e693129..f8a5d9b 100644 --- a/server/tests/Flaro.Api.Tests/FreigabeTests.cs +++ b/server/tests/Flaro.Api.Tests/FreigabeTests.cs @@ -165,6 +165,16 @@ public class FreigabeTests Assert.That(gestroemt.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); }); + // **Und die Detailseite muss es wissen.** Sie entscheidet daran, ob + // sie „Abspielen" zeigt oder den Termin. Die Liste führte `PublishAt` + // von Anfang an mit, die Einzelabfrage nicht — der Knopf stand also + // da, und wer ihn drückte, bekam eine Absage. Ein Knopf, der nichts + // tut, ist schlimmer als keiner. + var einzeln = await gast.GetFromJsonAsync( + $"/api/v1/movies/{filme.Items.Single().Id}"); + Assert.That(einzeln!.PublishAt, Is.Not.Null, + "die Detailseite braucht den Termin, sonst zeigt sie Abspielen an"); + // **Auch für Administratoren.** Eine Hintertür für eine Rolle wäre // eine Regel, die manchmal gilt; wer vorziehen will, hat den Knopf. var alsAdminGeladen = await alsAdmin.GetAsync($"/api/v1/files/{datei.Id}/download"); diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 3636feb..4cdf0c8 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -87,6 +87,8 @@ export type Folge = { id: string; season: number; episode: number; title: string | null; overview: string | null; stillImage: string | null; files: Datei[]; state: Stand | null; + /** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */ + publishAt?: string | null; }; /** Eine Kachel in „Weiterschauen" oder „Nächste Folge" — Film wie Folge. */ diff --git a/web/app/src/style.css b/web/app/src/style.css index d4ee333..c9dd33e 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1644,11 +1644,13 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; } .pruef-gruppe strong { font-size: 14px; } .pruef-gruppe .winzig { flex: 1 1 240px; } -/* „Verfügbar ab …" steht dort, wo sonst Abspielen stünde. */ +/* Steht dort, wo sonst Abspielen stünde — kein Knopf, der nichts tut. */ .verfuegbar-ab { - display: inline-flex; align-items: center; gap: var(--s-snug); - padding: 10px 16px; border-radius: var(--r-control); + display: inline-flex; align-items: center; gap: var(--s-regular); + padding: 10px 18px; border-radius: var(--r-control); background: var(--raised); border: 1px solid var(--line); - font-size: 14px; font-weight: 600; color: var(--text-dim); } -.verfuegbar-ab .icon svg { width: 18px; height: 18px; } +.verfuegbar-ab .icon svg { width: 22px; height: 22px; color: var(--accent); } +.verfuegbar-text { display: flex; flex-direction: column; line-height: 1.3; } +.verfuegbar-text strong { font-size: 14px; } +.verfuegbar-text span { font-size: 13px; color: var(--text-dim); } diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index e6dbd8c..cefcf61 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -251,9 +251,18 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem if (nochNicht) { const hinweisZeile = document.createElement('div'); hinweisZeile.className = 'verfuegbar-ab'; - hinweisZeile.append(icon('uhr'), document.createTextNode( - `Verfügbar ab ${geplantAb!.toLocaleString('de-CH', - { dateStyle: 'long', timeStyle: 'short' })}`)); + + const text = document.createElement('div'); + text.className = 'verfuegbar-text'; + const oben = document.createElement('strong'); + oben.textContent = 'Bald ist es soweit'; + const unten = document.createElement('span'); + unten.textContent = `Ab ${geplantAb!.toLocaleString('de-CH', + { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', + hour: '2-digit', minute: '2-digit' })} Uhr`; + text.append(oben, unten); + + hinweisZeile.append(icon('uhr'), text); reihe.appendChild(hinweisZeile); } @@ -585,7 +594,8 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem block.appendChild(welche); } block.appendChild(aktionsreihe( - { ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei)); + { ...({} as Film), id: folge.id, files: folge.files, + publishAt: folge.publishAt ?? null } as Film, folge.state, datei)); }); return block; }