diff --git a/DeepDrftPublic.Client/Common/ReleaseRoutes.cs b/DeepDrftPublic.Client/Common/ReleaseRoutes.cs new file mode 100644 index 0000000..e857627 --- /dev/null +++ b/DeepDrftPublic.Client/Common/ReleaseRoutes.cs @@ -0,0 +1,30 @@ +using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; + +namespace DeepDrftPublic.Client.Common; + +/// +/// The single source of truth for a release's dedicated detail route (Phase 11 §2). A release +/// resolves to its per-medium detail page purely from its id and — no +/// round-trip needed at call sites that already hold the medium (Archive cards, AlbumsView cards, +/// the player-bar title). The thin /tracks/{id} redirect page fetches by id to discover the +/// medium, then resolves through this same helper, so the medium→route table lives in exactly one +/// place. +/// +public static class ReleaseRoutes +{ + /// + /// The dedicated detail route for a release: /cuts/{id}, /sessions/{id}, or + /// /mixes/{id}. Cut is the default arm so a new medium without an entry here surfaces a + /// build-visible gap rather than a silent fallthrough — extend the switch when a fourth medium lands. + /// + public static string DetailHref(long id, ReleaseMedium medium) => medium switch + { + ReleaseMedium.Session => $"/sessions/{id}", + ReleaseMedium.Mix => $"/mixes/{id}", + _ => $"/cuts/{id}", + }; + + /// Convenience overload for call sites holding a . + public static string DetailHref(ReleaseDto release) => DetailHref(release.Id, release.Medium); +} diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor index 1794115..0c61db7 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor @@ -6,11 +6,23 @@ {
- + @* Title links to the release's dedicated detail page via the shared resolver (§2): the + TrackDto already carries Release { Id, Medium }, so no round-trip is needed. When no + release is attached there is no medium to resolve, so the title renders unlinked. *@ + @if (Track.Release is not null) + { + + + @Track.TrackName + + + } + else + { @Track.TrackName - + } - @Track.Release?.Artist diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor b/DeepDrftPublic.Client/Pages/AlbumsView.razor index 8283d80..ff0743b 100644 --- a/DeepDrftPublic.Client/Pages/AlbumsView.razor +++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor @@ -33,7 +33,7 @@
+ @onclick="@(() => OpenAlbum(album))"> @if (!string.IsNullOrEmpty(album.ImagePath)) {
/// Medium-filtered release gallery. Routed at /cuts (Cut releases) and parameterized by /// so the same component can back any medium's card grid without a fork. -/// Cards open the track gallery filtered to that release's album title, preserving the original -/// /albums ergonomics. +/// Cards open the release's dedicated detail page via +/// (a Cut routes to /cuts/{id}), the single source for medium→route resolution (Phase 11 §2). /// public partial class AlbumsView : ComponentBase, IDisposable { @@ -58,8 +59,8 @@ public partial class AlbumsView : ComponentBase, IDisposable return Task.CompletedTask; } - private void OpenAlbum(string album) - => Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}"); + private void OpenAlbum(ReleaseDto album) + => Navigation.NavigateTo(ReleaseRoutes.DetailHref(album)); public void Dispose() => _persistingSubscription.Dispose(); } diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor b/DeepDrftPublic.Client/Pages/ArchiveView.razor index 4dc3885..7f67ed4 100644 --- a/DeepDrftPublic.Client/Pages/ArchiveView.razor +++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor @@ -86,7 +86,7 @@ {
- +
@if (!string.IsNullOrEmpty(release.ImagePath)) { diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs b/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs index a7843ba..2a3b6ae 100644 --- a/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs +++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs @@ -115,16 +115,6 @@ public partial class ArchiveView : ComponentBase, IDisposable await LoadReleases(); } - // Per-medium detail target. Session/Mix open their own detail page; a Cut has no single-release - // detail page, so it opens the track gallery filtered to its release title — the same destination - // AlbumsView's Cut cards use, preserving the established navigation. - private static string DetailHref(ReleaseDto release) => release.Medium switch - { - ReleaseMedium.Session => $"/sessions/{release.Id}", - ReleaseMedium.Mix => $"/mixes/{release.Id}", - _ => $"/tracks?album={Uri.EscapeDataString(release.Title)}", - }; - // Display label for a medium filter chip. Centralised so a new medium's label is one entry, not a // markup change. "DJ Mix" matches the CMS Type-chip wording (§8.D). private static string MediumLabel(ReleaseMedium medium) => medium switch diff --git a/DeepDrftPublic.Client/Pages/TrackRedirect.razor b/DeepDrftPublic.Client/Pages/TrackRedirect.razor new file mode 100644 index 0000000..845f0ea --- /dev/null +++ b/DeepDrftPublic.Client/Pages/TrackRedirect.razor @@ -0,0 +1,26 @@ +@page "/tracks/{Id:long}" +@using DeepDrftPublic.Client.Services +@inject IReleaseDataService ReleaseData +@inject NavigationManager Navigation + +@* Addressable deep-link fallback for a bare release id (Phase 11 §2, shape iii). Unlike the player + bar / Archive / AlbumsView call sites, an external /tracks/{id} link carries only the id, so this + page fetches the release to discover its medium, then forwards through the same ReleaseRoutes + resolver — one medium→route table, no second source. replace:true keeps the router out of history + so Back skips this hop. Capture Id before the await per the InteractiveAuto route-param convention. *@ + +@code { + [Parameter] public long Id { get; set; } + + protected override async Task OnParametersSetAsync() + { + var id = Id; + var result = await ReleaseData.GetById(id); + + var target = result is { Success: true, Value: { } release } + ? ReleaseRoutes.DetailHref(release) + : "/cuts"; // Unknown id: fall back to the Cuts gallery rather than 404. + + Navigation.NavigateTo(target, forceLoad: false, replace: true); + } +} diff --git a/DeepDrftTests/ReleaseRoutesTests.cs b/DeepDrftTests/ReleaseRoutesTests.cs new file mode 100644 index 0000000..f94957b --- /dev/null +++ b/DeepDrftTests/ReleaseRoutesTests.cs @@ -0,0 +1,51 @@ +using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; +using DeepDrftPublic.Client.Common; + +namespace DeepDrftTests; + +/// +/// The medium→detail-route table is the single source of truth for release navigation (Phase 11 +/// §2): Archive cards, AlbumsView cards, the player-bar title, and the /tracks/{id} redirect page +/// all resolve through . These tests pin +/// each medium to its dedicated route and confirm the DTO overload (the call shape used everywhere +/// but the redirect page) agrees with the primitive overload (the shape the redirect page uses +/// after fetching the release by id). +/// +[TestFixture] +public class ReleaseRoutesTests +{ + [TestCase(ReleaseMedium.Cut, "/cuts/42")] + [TestCase(ReleaseMedium.Session, "/sessions/42")] + [TestCase(ReleaseMedium.Mix, "/mixes/42")] + public void DetailHref_ResolvesEachMediumToItsDedicatedRoute(ReleaseMedium medium, string expected) + { + Assert.That(ReleaseRoutes.DetailHref(42, medium), Is.EqualTo(expected)); + } + + [TestCase(ReleaseMedium.Cut, "/cuts/7")] + [TestCase(ReleaseMedium.Session, "/sessions/7")] + [TestCase(ReleaseMedium.Mix, "/mixes/7")] + public void DetailHref_DtoOverload_AgreesWithPrimitiveOverload(ReleaseMedium medium, string expected) + { + // The redirect page resolves a fetched ReleaseDto through this overload; every other call + // site does too. It must produce the same route as the (id, medium) primitive. + var release = new ReleaseDto { Id = 7, Medium = medium }; + + Assert.That(ReleaseRoutes.DetailHref(release), Is.EqualTo(expected)); + } + + [Test] + public void DetailHref_NonCutMediaNeverResolveToTheCutDefaultArm() + { + // The switch routes Cut via the default arm, so a new medium added without its own arm would + // silently fall through to /cuts. This pins every non-Cut medium to a distinct, non-/cuts + // route — a fourth medium lacking a route arm fails here rather than mis-routing to /cuts. + foreach (var medium in Enum.GetValues().Where(m => m != ReleaseMedium.Cut)) + { + var href = ReleaseRoutes.DetailHref(1, medium); + Assert.That(href, Does.Not.StartWith("/cuts/"), + $"Medium {medium} fell through to the Cut default arm ('{href}') — it needs its own route."); + } + } +}