feat: replace /archive with release-cardinal searchable browser (Phase 9 §8.H)

Retire the three-card overview for a search + medium + genre browser over all
releases. Adds q/genre filter params to the api/release paged read path,
mirroring the existing api/track/page TrackFilter pattern.
This commit is contained in:
daniel-c-harvey
2026-06-13 20:47:50 -04:00
parent 18f4b596f2
commit 737c423d9c
13 changed files with 607 additions and 59 deletions
@@ -29,7 +29,9 @@ public class ReleaseClient
int page,
int pageSize,
string? sortColumn = null,
bool sortDescending = false)
bool sortDescending = false,
string? search = null,
string? genre = null)
{
var queryArgs = new Dictionary<string, string?>
{
@@ -40,6 +42,12 @@ public class ReleaseClient
if (!string.IsNullOrEmpty(medium))
queryArgs["medium"] = medium;
if (!string.IsNullOrEmpty(search))
queryArgs["q"] = search;
if (!string.IsNullOrEmpty(genre))
queryArgs["genre"] = genre;
if (!string.IsNullOrEmpty(sortColumn))
queryArgs["sortColumn"] = sortColumn;
+106 -21
View File
@@ -1,31 +1,116 @@
@page "/archive"
@using DeepDrftModels.Enums
<PageTitle>DeepDrft Archive</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
<div class="archive-grid">
@foreach (var medium in _media)
@* Search + filter affordances are interactive-only: the debounce timer and chip selection
need WASM. During prerender/non-interactive they are hidden, matching TracksView's gate.
The release grid still prerenders so the archive is meaningful before hydration. *@
@if (RendererInfo.IsInteractive)
{
<div class="archive-search-row">
<MudTextField T="string"
Value="@SearchText"
ValueChanged="@OnSearchInput"
Immediate="true"
DebounceInterval="400"
Placeholder="Search releases or artists"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
Class="archive-search-field"/>
</div>
<div class="archive-filter-row">
<MudToggleGroup T="ReleaseMedium?"
Value="@_selectedMedium"
ValueChanged="@OnMediumSelected"
SelectionMode="SelectionMode.SingleSelection"
Color="Color.Primary"
Size="Size.Small"
Class="archive-medium-toggle">
<MudToggleItem T="ReleaseMedium?" Value="@(null)">All</MudToggleItem>
@foreach (var medium in _media)
{
<MudToggleItem T="ReleaseMedium?" Value="@medium">@MediumLabel(medium)</MudToggleItem>
}
</MudToggleGroup>
</div>
@if (_genres.Count > 0)
{
<a href="@medium.Route" class="archive-card-link">
<div class="archive-card">
<MudIcon Icon="@medium.Icon" Class="archive-card-icon" />
<MudText Typo="Typo.h5" Class="archive-card-title">@medium.Title</MudText>
<MudText Typo="Typo.body2" Class="archive-card-blurb">@medium.Blurb</MudText>
</div>
</a>
<div class="archive-filter-row">
<MudChipSet T="string"
SelectedValue="@_selectedGenre"
SelectedValueChanged="@OnGenreSelected"
SelectionMode="SelectionMode.ToggleSelection"
Class="archive-genre-chips">
@foreach (var genre in _genres)
{
<MudChip T="string" Value="@genre.Genre">@genre.Genre</MudChip>
}
</MudChipSet>
</div>
}
</div>
}
@if (_loading)
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="archive-card-center">
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
</div>
</MudItem>
}
</MudGrid>
}
else if (_releases.Count == 0)
{
<div class="archive-empty">
<MudText Typo="Typo.h6">No releases found</MudText>
</div>
}
else
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var release in _releases)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="archive-card-center">
<a href="@DetailHref(release)" class="archive-card-link">
<div class="archive-release-card">
@if (!string.IsNullOrEmpty(release.ImagePath))
{
<div class="archive-release-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(release.ImagePath)');">
</div>
}
else
{
<div class="archive-release-cover archive-release-cover--fallback"></div>
}
<div class="archive-release-body">
<MudText Typo="Typo.subtitle1" Class="archive-release-title text-truncate">
@release.Title
</MudText>
<MudText Typo="Typo.caption" Class="archive-release-artist text-truncate">
@release.Artist
</MudText>
</div>
</div>
</a>
</div>
</MudItem>
}
</MudGrid>
}
</MudContainer>
</div>
@code {
private record MediumCard(string Title, string Blurb, string Route, string Icon);
private static readonly MediumCard[] _media =
[
new("Cuts", "Studio recordings — singles, EPs, and albums.", "/cuts", Icons.Material.Filled.Album),
new("Sessions", "Single live takes, each with its own hero image.", "/sessions", Icons.Material.Filled.Piano),
new("Mixes", "Long-form continuous mixes with high-resolution waveforms.", "/mixes", Icons.Material.Filled.GraphicEq),
];
}
@@ -0,0 +1,149 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
/// <summary>
/// The public archive: a release-cardinal searchable browser over every release across all media
/// (Phase 9 §8.H, decision H2). Replaces the former three-card medium overview. Search (Title /
/// Artist), an enum-driven medium filter, and a genre filter narrow the release list; each card
/// routes to its per-medium detail. Mirrors the <see cref="TracksView"/> seam: the unfiltered first
/// page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor replays
/// the card entrance animations.
/// </summary>
public partial class ArchiveView : ComponentBase, IDisposable
{
private const string PersistKey = "archive-releases";
// A large page covers the full library in one fetch — the archive has no pager, matching the
// medium galleries (AlbumsView / MediumBrowseBase) which also pull pageSize: 100.
private const int PageSize = 100;
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
// Medium filter chips are enum-driven so a fourth medium surfaces a chip from one lookup entry,
// with no markup fork (Phase 9 extension discipline).
private static readonly ReleaseMedium[] _media = Enum.GetValues<ReleaseMedium>();
private bool _loading = true;
private List<ReleaseDto> _releases = [];
private List<GenreSummaryDto> _genres = [];
// null medium == All; null genre == no genre filter. SearchText null/empty == no search.
private ReleaseMedium? _selectedMedium;
private string? _selectedGenre;
private string? SearchText { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
private bool HasActiveFilter =>
_selectedMedium is not null
|| !string.IsNullOrWhiteSpace(_selectedGenre)
|| !string.IsNullOrWhiteSpace(SearchText);
protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
// The genre chip source is the release-cardinal distinct-genre list (already sourced from the
// release join — see GetDistinctGenresAsync). It only renders interactively, so it is fetched
// lazily on the interactive pass rather than persisted.
if (RendererInfo.IsInteractive)
await LoadGenres();
// The prerendered page is always the unfiltered first page. Restore it only when no filter is
// active; a filtered interactive pass must fetch its own narrowed result instead.
if (!HasActiveFilter
&& PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored)
&& restored is not null)
{
_releases = restored;
_loading = false;
return;
}
await LoadReleases();
}
private async Task LoadGenres()
{
var result = await TrackData.GetGenres();
if (result is { Success: true, Value: { } genres })
_genres = genres;
}
private async Task LoadReleases()
{
_loading = true;
var result = await ReleaseData.GetPaged(
medium: _selectedMedium?.ToString().ToLowerInvariant(),
page: 1,
pageSize: PageSize,
search: SearchText,
genre: _selectedGenre);
_releases = result is { Success: true, Value.Items: { } items }
? items.ToList()
: [];
_loading = false;
}
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
// burst reaches here. Re-fetches with the composed filter (search + medium + genre).
private async Task OnSearchInput(string? value)
{
SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
await LoadReleases();
}
private async Task OnMediumSelected(ReleaseMedium? medium)
{
_selectedMedium = medium;
await LoadReleases();
}
private async Task OnGenreSelected(string? genre)
{
_selectedGenre = string.IsNullOrWhiteSpace(genre) ? null : genre;
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
{
ReleaseMedium.Cut => "Cuts",
ReleaseMedium.Session => "Sessions",
ReleaseMedium.Mix => "Mixes",
_ => medium.ToString(),
};
private Task Persist()
{
// Only the unfiltered first page is safe to restore onto a later plain /archive visit. A
// filtered render leaves the cache untouched so the bridge never serves narrowed results to an
// unfiltered load.
if (_releases.Count > 0 && !HasActiveFilter)
PersistentState.PersistAsJson(PersistKey, _releases);
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
}
@@ -2,11 +2,30 @@
padding-top: 16px;
}
.archive-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 2rem;
margin-top: 1rem;
.archive-search-row {
display: flex;
justify-content: flex-start;
padding: 0 0 12px 0;
}
/* archive-search-field rides on MudTextField, whose root is a child Razor component element.
Blazor isolation does not stamp the scope attribute there, so ::deep is required. */
::deep .archive-search-field {
max-width: 420px;
width: 100%;
}
.archive-filter-row {
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
padding: 0 0 12px 0;
}
.archive-card-center {
display: flex;
justify-content: center;
width: 100%;
}
.archive-card-link {
@@ -14,35 +33,51 @@
color: inherit;
}
.archive-card {
.archive-release-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 2.5rem 1.5rem;
border: 1px solid var(--mud-palette-lines-default);
width: 200px;
cursor: pointer;
border-radius: 8px;
background-color: var(--mud-palette-surface);
transition: transform 120ms ease, box-shadow 120ms ease;
overflow: hidden;
transition: transform 120ms ease;
}
.archive-card:hover {
.archive-release-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent);
}
/* archive-card-icon rides on MudIcon (child Razor component); ::deep pierces its output. */
::deep .archive-card-icon {
font-size: 56px;
color: var(--mud-palette-primary);
.archive-release-cover {
width: 200px;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* archive-card-title / archive-card-blurb ride on MudText (child Razor component). */
::deep .archive-card-title {
.archive-release-cover--fallback {
background-color: var(--mud-palette-dark, #1a2238);
}
.archive-release-body {
padding: 8px 4px 0 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* archive-release-title / archive-release-artist ride on MudText (child Razor component); ::deep
pierces into its output since Blazor isolation does not scope-stamp child component roots. */
::deep .archive-release-title {
font-weight: 600;
}
::deep .archive-card-blurb {
::deep .archive-release-artist {
opacity: 0.7;
}
.archive-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
@@ -12,13 +12,15 @@ namespace DeepDrftPublic.Client.Services;
/// </summary>
public interface IReleaseDataService
{
/// <summary>Paged releases, optionally filtered to one medium ("cut" | "session" | "mix").</summary>
/// <summary>Paged releases, optionally narrowed by medium ("cut" | "session" | "mix"), free-text search, and genre.</summary>
Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
string? medium,
int page,
int pageSize,
string? sortColumn = null,
bool sortDescending = false);
bool sortDescending = false,
string? search = null,
string? genre = null);
/// <summary>Single release with both metadata satellites (nulls for non-matching media).</summary>
Task<ApiResult<ReleaseDto>> GetById(long id);
@@ -24,8 +24,10 @@ public class ReleaseClientDataService : IReleaseDataService
int page,
int pageSize,
string? sortColumn = null,
bool sortDescending = false)
=> _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending);
bool sortDescending = false,
string? search = null,
string? genre = null)
=> _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending, search, genre);
public Task<ApiResult<ReleaseDto>> GetById(long id)
=> _releaseClient.GetById(id);