Merge p11-w2-releaseroutes-resolver into dev (P11 11.B: ReleaseRoutes.DetailHref resolver + repoint, /tracks/{id} redirect)
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
using DeepDrftModels.DTOs;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="ReleaseMedium"/> — no
|
||||||
|
/// round-trip needed at call sites that already hold the medium (Archive cards, AlbumsView cards,
|
||||||
|
/// the player-bar title). The thin <c>/tracks/{id}</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReleaseRoutes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The dedicated detail route for a release: <c>/cuts/{id}</c>, <c>/sessions/{id}</c>, or
|
||||||
|
/// <c>/mixes/{id}</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
public static string DetailHref(long id, ReleaseMedium medium) => medium switch
|
||||||
|
{
|
||||||
|
ReleaseMedium.Session => $"/sessions/{id}",
|
||||||
|
ReleaseMedium.Mix => $"/mixes/{id}",
|
||||||
|
_ => $"/cuts/{id}",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Convenience overload for call sites holding a <see cref="ReleaseDto"/>.</summary>
|
||||||
|
public static string DetailHref(ReleaseDto release) => DetailHref(release.Id, release.Medium);
|
||||||
|
}
|
||||||
@@ -6,11 +6,23 @@
|
|||||||
{
|
{
|
||||||
<div class="track-meta-row">
|
<div class="track-meta-row">
|
||||||
<div class="track-meta-identity">
|
<div class="track-meta-identity">
|
||||||
<a href="@($"/track/{Track.EntryKey}")" style="text-decoration: none;">
|
@* 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)
|
||||||
|
{
|
||||||
|
<a href="@ReleaseRoutes.DetailHref(Track.Release)" style="text-decoration: none;">
|
||||||
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||||
@Track.TrackName
|
@Track.TrackName
|
||||||
</MudText>
|
</MudText>
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||||
|
@Track.TrackName
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
<MudText Typo="Typo.subtitle2" Class="track-meta-sep"> - </MudText>
|
<MudText Typo="Typo.subtitle2" Class="track-meta-sep"> - </MudText>
|
||||||
<MudText Typo="Typo.caption" Class="track-meta-artist text-truncate">
|
<MudText Typo="Typo.caption" Class="track-meta-artist text-truncate">
|
||||||
@Track.Release?.Artist
|
@Track.Release?.Artist
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="album-card"
|
<div class="album-card"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@onclick="@(() => OpenAlbum(album.Title))">
|
@onclick="@(() => OpenAlbum(album))">
|
||||||
@if (!string.IsNullOrEmpty(album.ImagePath))
|
@if (!string.IsNullOrEmpty(album.ImagePath))
|
||||||
{
|
{
|
||||||
<div class="album-card-cover"
|
<div class="album-card-cover"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
using DeepDrftModels.Enums;
|
using DeepDrftModels.Enums;
|
||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
using DeepDrftPublic.Client.Services;
|
using DeepDrftPublic.Client.Services;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Models.Common;
|
using Models.Common;
|
||||||
@@ -9,8 +10,8 @@ namespace DeepDrftPublic.Client.Pages;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Medium-filtered release gallery. Routed at <c>/cuts</c> (Cut releases) and parameterized by
|
/// Medium-filtered release gallery. Routed at <c>/cuts</c> (Cut releases) and parameterized by
|
||||||
/// <see cref="Medium"/> so the same component can back any medium's card grid without a fork.
|
/// <see cref="Medium"/> 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
|
/// Cards open the release's dedicated detail page via <see cref="ReleaseRoutes.DetailHref(ReleaseDto)"/>
|
||||||
/// /albums ergonomics.
|
/// (a Cut routes to <c>/cuts/{id}</c>), the single source for medium→route resolution (Phase 11 §2).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AlbumsView : ComponentBase, IDisposable
|
public partial class AlbumsView : ComponentBase, IDisposable
|
||||||
{
|
{
|
||||||
@@ -58,8 +59,8 @@ public partial class AlbumsView : ComponentBase, IDisposable
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenAlbum(string album)
|
private void OpenAlbum(ReleaseDto album)
|
||||||
=> Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}");
|
=> Navigation.NavigateTo(ReleaseRoutes.DetailHref(album));
|
||||||
|
|
||||||
public void Dispose() => _persistingSubscription.Dispose();
|
public void Dispose() => _persistingSubscription.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
{
|
{
|
||||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||||
<div class="archive-card-center">
|
<div class="archive-card-center">
|
||||||
<a href="@DetailHref(release)" class="archive-card-link">
|
<a href="@ReleaseRoutes.DetailHref(release)" class="archive-card-link">
|
||||||
<div class="archive-release-card">
|
<div class="archive-release-card">
|
||||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -115,16 +115,6 @@ public partial class ArchiveView : ComponentBase, IDisposable
|
|||||||
await LoadReleases();
|
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
|
// 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).
|
// markup change. "DJ Mix" matches the CMS Type-chip wording (§8.D).
|
||||||
private static string MediumLabel(ReleaseMedium medium) => medium switch
|
private static string MediumLabel(ReleaseMedium medium) => medium switch
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using DeepDrftModels.DTOs;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="ReleaseRoutes.DetailHref(long, ReleaseMedium)"/>. 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).
|
||||||
|
/// </summary>
|
||||||
|
[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<ReleaseMedium>().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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user