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:
@@ -31,12 +31,15 @@ public class ReleaseController : ControllerBase
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET api/release?medium=session&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth)
|
// GET api/release?medium=session&q=text&genre=House&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth)
|
||||||
// Paged release list, optionally filtered to one medium. The matching medium's metadata satellite is
|
// Paged release list, optionally narrowed by medium, free-text search (q), and genre. The matching
|
||||||
// populated; the others are null. Public browse data, same auth posture as GET api/track/page.
|
// medium's metadata satellite is populated; the others are null. Backs the public /archive browser.
|
||||||
|
// Public browse data, same auth posture as GET api/track/page.
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult> GetReleases(
|
public async Task<ActionResult> GetReleases(
|
||||||
[FromQuery] string? medium = null,
|
[FromQuery] string? medium = null,
|
||||||
|
[FromQuery] string? q = null,
|
||||||
|
[FromQuery] string? genre = null,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
[FromQuery] string? sortColumn = null,
|
[FromQuery] string? sortColumn = null,
|
||||||
@@ -51,7 +54,8 @@ public class ReleaseController : ControllerBase
|
|||||||
parsedMedium = m;
|
parsedMedium = m;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, ct);
|
var filter = new ReleaseFilter { SearchText = q, Genre = genre };
|
||||||
|
var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, filter, ct);
|
||||||
if (!result.Success || result.Value is null)
|
if (!result.Success || result.Value is null)
|
||||||
{
|
{
|
||||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ namespace DeepDrftData;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IReleaseService
|
public interface IReleaseService
|
||||||
{
|
{
|
||||||
/// <summary>Paginated releases, optionally filtered to one medium. The matching medium's metadata satellite is included in the result. Omit medium for all releases.</summary>
|
/// <summary>Paginated releases, optionally narrowed by medium and a free-text/genre filter. The matching medium's metadata satellite is included in the result. Omit medium for all releases; omit filter for no search/genre narrowing.</summary>
|
||||||
Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
|
Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
|
||||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||||
ReleaseMedium? medium, CancellationToken cancellationToken = default);
|
ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
|
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
|
||||||
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
|
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public class ReleaseManager : IReleaseService
|
|||||||
|
|
||||||
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
|
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
|
||||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||||
ReleaseMedium? medium, CancellationToken cancellationToken = default)
|
ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -54,7 +54,9 @@ public class ReleaseManager : IReleaseService
|
|||||||
IsDescending = sortDescending,
|
IsDescending = sortDescending,
|
||||||
};
|
};
|
||||||
|
|
||||||
var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, cancellationToken);
|
// Collapse an all-null filter to null so the repository skips the predicate block entirely.
|
||||||
|
var effectiveFilter = filter is { IsEmpty: false } ? filter : null;
|
||||||
|
var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, effectiveFilter, cancellationToken);
|
||||||
|
|
||||||
var releaseIds = entityPage.Items.Select(r => r.Id).ToList();
|
var releaseIds = entityPage.Items.Select(r => r.Id).ToList();
|
||||||
var counts = await _repository.GetTrackCountsByReleaseIdsAsync(releaseIds, cancellationToken);
|
var counts = await _repository.GetTrackCountsByReleaseIdsAsync(releaseIds, cancellationToken);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using DeepDrftData.Data;
|
using DeepDrftData.Data;
|
||||||
|
using DeepDrftModels.DTOs;
|
||||||
using DeepDrftModels.Entities;
|
using DeepDrftModels.Entities;
|
||||||
using DeepDrftModels.Enums;
|
using DeepDrftModels.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -36,17 +37,37 @@ public class ReleaseRepository
|
|||||||
_ => query
|
_ => query
|
||||||
};
|
};
|
||||||
|
|
||||||
// Paged, optionally medium-filtered release list. The matching medium's satellite is Include'd;
|
// Paged release list, optionally narrowed by medium and a free-text/genre filter. The matching
|
||||||
// total count reflects the medium filter (applied before Skip/Take).
|
// medium's satellite is Include'd; total count reflects every applied predicate (all before
|
||||||
|
// Skip/Take). The filter predicates mirror TrackRepository.GetPagedFilteredAsync so the release
|
||||||
|
// browse path searches and filters identically to the track path.
|
||||||
public async Task<PagedResult<ReleaseEntity>> GetPagedByMediumAsync(
|
public async Task<PagedResult<ReleaseEntity>> GetPagedByMediumAsync(
|
||||||
PagingParameters<ReleaseEntity> paging,
|
PagingParameters<ReleaseEntity> paging,
|
||||||
ReleaseMedium? medium,
|
ReleaseMedium? medium,
|
||||||
|
ReleaseFilter? filter,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
IQueryable<ReleaseEntity> query = _context.Releases.Where(r => !r.IsDeleted);
|
IQueryable<ReleaseEntity> query = _context.Releases.Where(r => !r.IsDeleted);
|
||||||
if (medium.HasValue)
|
if (medium.HasValue)
|
||||||
query = query.Where(r => r.Medium == medium.Value);
|
query = query.Where(r => r.Medium == medium.Value);
|
||||||
|
|
||||||
|
if (filter is not null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.SearchText))
|
||||||
|
{
|
||||||
|
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
|
||||||
|
// EF-translatable where ToLower().Contains() is not. Title/Artist are non-null columns
|
||||||
|
// on the release itself, so no navigation guard is needed (unlike the track path).
|
||||||
|
var pattern = $"%{filter.SearchText}%";
|
||||||
|
query = query.Where(r =>
|
||||||
|
EF.Functions.ILike(r.Title, pattern)
|
||||||
|
|| EF.Functions.ILike(r.Artist, pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
||||||
|
query = query.Where(r => r.Genre == filter.Genre);
|
||||||
|
}
|
||||||
|
|
||||||
query = ApplyMediumInclude(query, medium);
|
query = ApplyMediumInclude(query, medium);
|
||||||
|
|
||||||
var totalCount = await query.CountAsync(ct);
|
var totalCount = await query.CountAsync(ct);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace DeepDrftModels.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-project release filter contract for the paged release read surface. Threaded alongside
|
||||||
|
/// (never inside) the external <c>PagingParameters<T></c>, which cannot carry a where-clause,
|
||||||
|
/// and beside the medium filter (a separate enum param, not a free-text field). Mirrors
|
||||||
|
/// <see cref="TrackFilter"/> for the release-cardinal browse path. An instance with all properties
|
||||||
|
/// null is equivalent to no filter — see <see cref="IsEmpty"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class ReleaseFilter
|
||||||
|
{
|
||||||
|
/// <summary>Free-text, case-insensitive LIKE across the release Title and Artist.</summary>
|
||||||
|
public string? SearchText { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Exact genre match.</summary>
|
||||||
|
public string? Genre { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when no predicate is set. An empty filter must produce identical results to a null
|
||||||
|
/// filter, so callers collapse it to null before querying.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEmpty =>
|
||||||
|
string.IsNullOrWhiteSpace(SearchText)
|
||||||
|
&& string.IsNullOrWhiteSpace(Genre);
|
||||||
|
}
|
||||||
@@ -29,7 +29,9 @@ public class ReleaseClient
|
|||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? sortColumn = null,
|
string? sortColumn = null,
|
||||||
bool sortDescending = false)
|
bool sortDescending = false,
|
||||||
|
string? search = null,
|
||||||
|
string? genre = null)
|
||||||
{
|
{
|
||||||
var queryArgs = new Dictionary<string, string?>
|
var queryArgs = new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
@@ -40,6 +42,12 @@ public class ReleaseClient
|
|||||||
if (!string.IsNullOrEmpty(medium))
|
if (!string.IsNullOrEmpty(medium))
|
||||||
queryArgs["medium"] = medium;
|
queryArgs["medium"] = medium;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(search))
|
||||||
|
queryArgs["q"] = search;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(genre))
|
||||||
|
queryArgs["genre"] = genre;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(sortColumn))
|
if (!string.IsNullOrEmpty(sortColumn))
|
||||||
queryArgs["sortColumn"] = sortColumn;
|
queryArgs["sortColumn"] = sortColumn;
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,116 @@
|
|||||||
@page "/archive"
|
@page "/archive"
|
||||||
|
@using DeepDrftModels.Enums
|
||||||
|
|
||||||
<PageTitle>DeepDrft Archive</PageTitle>
|
<PageTitle>DeepDrft Archive</PageTitle>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
||||||
<div class="archive-grid">
|
@* Search + filter affordances are interactive-only: the debounce timer and chip selection
|
||||||
@foreach (var medium in _media)
|
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-filter-row">
|
||||||
<div class="archive-card">
|
<MudChipSet T="string"
|
||||||
<MudIcon Icon="@medium.Icon" Class="archive-card-icon" />
|
SelectedValue="@_selectedGenre"
|
||||||
<MudText Typo="Typo.h5" Class="archive-card-title">@medium.Title</MudText>
|
SelectedValueChanged="@OnGenreSelected"
|
||||||
<MudText Typo="Typo.body2" Class="archive-card-blurb">@medium.Blurb</MudText>
|
SelectionMode="SelectionMode.ToggleSelection"
|
||||||
</div>
|
Class="archive-genre-chips">
|
||||||
</a>
|
@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>
|
</MudContainer>
|
||||||
</div>
|
</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;
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-grid {
|
.archive-search-row {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
justify-content: flex-start;
|
||||||
gap: 2rem;
|
padding: 0 0 12px 0;
|
||||||
margin-top: 1rem;
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
.archive-card-link {
|
||||||
@@ -14,35 +33,51 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-card {
|
.archive-release-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
width: 200px;
|
||||||
text-align: center;
|
cursor: pointer;
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 2.5rem 1.5rem;
|
|
||||||
border: 1px solid var(--mud-palette-lines-default);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: var(--mud-palette-surface);
|
overflow: hidden;
|
||||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
transition: transform 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-card:hover {
|
.archive-release-card:hover {
|
||||||
transform: translateY(-4px);
|
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. */
|
.archive-release-cover {
|
||||||
::deep .archive-card-icon {
|
width: 200px;
|
||||||
font-size: 56px;
|
height: 200px;
|
||||||
color: var(--mud-palette-primary);
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* archive-card-title / archive-card-blurb ride on MudText (child Razor component). */
|
.archive-release-cover--fallback {
|
||||||
::deep .archive-card-title {
|
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;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
::deep .archive-card-blurb {
|
::deep .archive-release-artist {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-empty {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ namespace DeepDrftPublic.Client.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IReleaseDataService
|
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(
|
Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
|
||||||
string? medium,
|
string? medium,
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? sortColumn = null,
|
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>
|
/// <summary>Single release with both metadata satellites (nulls for non-matching media).</summary>
|
||||||
Task<ApiResult<ReleaseDto>> GetById(long id);
|
Task<ApiResult<ReleaseDto>> GetById(long id);
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ public class ReleaseClientDataService : IReleaseDataService
|
|||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? sortColumn = null,
|
string? sortColumn = null,
|
||||||
bool sortDescending = false)
|
bool sortDescending = false,
|
||||||
=> _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending);
|
string? search = null,
|
||||||
|
string? genre = null)
|
||||||
|
=> _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending, search, genre);
|
||||||
|
|
||||||
public Task<ApiResult<ReleaseDto>> GetById(long id)
|
public Task<ApiResult<ReleaseDto>> GetById(long id)
|
||||||
=> _releaseClient.GetById(id);
|
=> _releaseClient.GetById(id);
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ public class ReleaseProxyController : ControllerBase
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Proxies the paged release list, forwarding the optional medium filter and sort params.</summary>
|
/// <summary>Proxies the paged release list, forwarding the optional medium, search (q), genre, and sort params.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult> GetReleases(
|
public async Task<ActionResult> GetReleases(
|
||||||
[FromQuery] string? medium = null,
|
[FromQuery] string? medium = null,
|
||||||
|
[FromQuery] string? q = null,
|
||||||
|
[FromQuery] string? genre = null,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
[FromQuery] string? sortColumn = null,
|
[FromQuery] string? sortColumn = null,
|
||||||
@@ -35,6 +37,10 @@ public class ReleaseProxyController : ControllerBase
|
|||||||
var query = $"api/release?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
|
var query = $"api/release?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
|
||||||
if (!string.IsNullOrWhiteSpace(medium))
|
if (!string.IsNullOrWhiteSpace(medium))
|
||||||
query += $"&medium={Uri.EscapeDataString(medium)}";
|
query += $"&medium={Uri.EscapeDataString(medium)}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(q))
|
||||||
|
query += $"&q={Uri.EscapeDataString(q)}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
|
query += $"&genre={Uri.EscapeDataString(genre)}";
|
||||||
if (!string.IsNullOrWhiteSpace(sortColumn))
|
if (!string.IsNullOrWhiteSpace(sortColumn))
|
||||||
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
|
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using DeepDrftData.Data;
|
||||||
|
using DeepDrftData.Repositories;
|
||||||
|
using DeepDrftModels.DTOs;
|
||||||
|
using DeepDrftModels.Entities;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Models.Common;
|
||||||
|
|
||||||
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query-shape tests for the release-cardinal browse path that backs the public /archive browser
|
||||||
|
/// (Phase 9 §8.H). Exercises <see cref="ReleaseRepository.GetPagedByMediumAsync"/>: the medium
|
||||||
|
/// narrowing, the genre filter, and the null/empty-filter passthrough.
|
||||||
|
///
|
||||||
|
/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That covers
|
||||||
|
/// the medium predicate, exact genre equality, and the count/ordering — every predicate except the
|
||||||
|
/// free-text branch. That branch uses <c>EF.Functions.ILike</c>, an Npgsql-only relational function
|
||||||
|
/// with no in-memory translation, so the SearchText case is a Postgres integration test gated on a DSN
|
||||||
|
/// (<see cref="GetPagedByMediumAsync_WithSearchText_MatchesTitleOrArtistCaseInsensitive"/>). Without a
|
||||||
|
/// configured database it is ignored rather than asserted against a provider that never runs the
|
||||||
|
/// predicate — mirroring <see cref="TrackFilterQueryTests"/>.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class ReleaseBrowseQueryTests
|
||||||
|
{
|
||||||
|
private DeepDrftContext _context = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
_context = new DeepDrftContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown() => _context.Dispose();
|
||||||
|
|
||||||
|
private ReleaseRepository CreateRepository()
|
||||||
|
=> new(_context, NullLogger<ReleaseRepository>.Instance);
|
||||||
|
|
||||||
|
private static ReleaseEntity Release(
|
||||||
|
string title, string artist, ReleaseMedium medium = ReleaseMedium.Cut, string? genre = null)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Artist = artist,
|
||||||
|
Medium = medium,
|
||||||
|
Genre = genre,
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task SeedAsync(params ReleaseEntity[] releases)
|
||||||
|
{
|
||||||
|
_context.Releases.AddRange(releases);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PagingParameters<ReleaseEntity> DefaultPaging()
|
||||||
|
=> new() { Page = 1, PageSize = 20, OrderBy = r => r.Id, IsDescending = false };
|
||||||
|
|
||||||
|
// Medium filter narrows to a single medium: only releases of that medium are returned, and
|
||||||
|
// TotalCount reflects the filtered set, not the table.
|
||||||
|
[Test]
|
||||||
|
public async Task GetPagedByMediumAsync_WithMedium_ReturnsOnlyThatMedium()
|
||||||
|
{
|
||||||
|
await SeedAsync(
|
||||||
|
Release("Cut One", "A", ReleaseMedium.Cut),
|
||||||
|
Release("Session One", "B", ReleaseMedium.Session),
|
||||||
|
Release("Mix One", "C", ReleaseMedium.Mix),
|
||||||
|
Release("Session Two", "D", ReleaseMedium.Session));
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var result = await repo.GetPagedByMediumAsync(
|
||||||
|
DefaultPaging(), ReleaseMedium.Session, filter: null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(result.TotalCount, Is.EqualTo(2));
|
||||||
|
Assert.That(result.Items.Select(r => r.Title), Is.EquivalentTo(new[] { "Session One", "Session Two" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing the medium filter (null) shows every medium — the all-releases archive default.
|
||||||
|
[Test]
|
||||||
|
public async Task GetPagedByMediumAsync_WithNullMedium_ReturnsAllMedia()
|
||||||
|
{
|
||||||
|
await SeedAsync(
|
||||||
|
Release("Cut One", "A", ReleaseMedium.Cut),
|
||||||
|
Release("Session One", "B", ReleaseMedium.Session),
|
||||||
|
Release("Mix One", "C", ReleaseMedium.Mix));
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var result = await repo.GetPagedByMediumAsync(
|
||||||
|
DefaultPaging(), medium: null, filter: null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(result.TotalCount, Is.EqualTo(3));
|
||||||
|
Assert.That(result.Items.Select(r => r.Title),
|
||||||
|
Is.EquivalentTo(new[] { "Cut One", "Session One", "Mix One" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genre filter narrows across all media: only releases of that exact genre are returned, regardless
|
||||||
|
// of medium, and TotalCount reflects the filtered set.
|
||||||
|
[Test]
|
||||||
|
public async Task GetPagedByMediumAsync_WithGenre_ReturnsOnlyThatGenreAcrossMedia()
|
||||||
|
{
|
||||||
|
await SeedAsync(
|
||||||
|
Release("Cut One", "A", ReleaseMedium.Cut, genre: "Techno"),
|
||||||
|
Release("Session One", "B", ReleaseMedium.Session, genre: "House"),
|
||||||
|
Release("Mix One", "C", ReleaseMedium.Mix, genre: "Techno"));
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var result = await repo.GetPagedByMediumAsync(
|
||||||
|
DefaultPaging(), medium: null, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(result.TotalCount, Is.EqualTo(2));
|
||||||
|
Assert.That(result.Items.Select(r => r.Title), Is.EquivalentTo(new[] { "Cut One", "Mix One" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medium and genre compose: the result is the intersection, narrowed both by medium and genre.
|
||||||
|
[Test]
|
||||||
|
public async Task GetPagedByMediumAsync_WithMediumAndGenre_ComposesBothPredicates()
|
||||||
|
{
|
||||||
|
await SeedAsync(
|
||||||
|
Release("Mix Techno", "A", ReleaseMedium.Mix, genre: "Techno"),
|
||||||
|
Release("Mix House", "B", ReleaseMedium.Mix, genre: "House"),
|
||||||
|
Release("Cut Techno", "C", ReleaseMedium.Cut, genre: "Techno"));
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var result = await repo.GetPagedByMediumAsync(
|
||||||
|
DefaultPaging(), ReleaseMedium.Mix, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(result.TotalCount, Is.EqualTo(1));
|
||||||
|
Assert.That(result.Items.Single().Title, Is.EqualTo("Mix Techno"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A null filter is a passthrough: same items and count as no filter at all. An all-null
|
||||||
|
// ReleaseFilter is collapsed to null by the manager, so the repository sees null here.
|
||||||
|
[Test]
|
||||||
|
public async Task GetPagedByMediumAsync_WithNullFilter_ReturnsAllReleases()
|
||||||
|
{
|
||||||
|
await SeedAsync(
|
||||||
|
Release("Cut One", "A", ReleaseMedium.Cut),
|
||||||
|
Release("Session One", "B", ReleaseMedium.Session),
|
||||||
|
Release("Mix One", "C", ReleaseMedium.Mix));
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var result = await repo.GetPagedByMediumAsync(
|
||||||
|
DefaultPaging(), medium: null, filter: null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(result.TotalCount, Is.EqualTo(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-deleted releases never surface in the browse list, with or without a filter.
|
||||||
|
[Test]
|
||||||
|
public async Task GetPagedByMediumAsync_ExcludesSoftDeletedReleases()
|
||||||
|
{
|
||||||
|
var deleted = Release("Gone", "A", ReleaseMedium.Cut, genre: "Techno");
|
||||||
|
deleted.IsDeleted = true;
|
||||||
|
await SeedAsync(
|
||||||
|
Release("Live", "B", ReleaseMedium.Cut, genre: "Techno"),
|
||||||
|
deleted);
|
||||||
|
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var result = await repo.GetPagedByMediumAsync(
|
||||||
|
DefaultPaging(), medium: null, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(result.TotalCount, Is.EqualTo(1));
|
||||||
|
Assert.That(result.Items.Single().Title, Is.EqualTo("Live"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free-text search across Title and Artist, case-insensitive. EF.Functions.ILike is Npgsql-only and
|
||||||
|
// does not translate on the in-memory provider, so this runs only against a real Postgres database
|
||||||
|
// supplied via the DEEPDRFT_TEST_PG environment variable. Without it the test is ignored rather than
|
||||||
|
// asserted against a provider that cannot execute the predicate.
|
||||||
|
[Test]
|
||||||
|
public async Task GetPagedByMediumAsync_WithSearchText_MatchesTitleOrArtistCaseInsensitive()
|
||||||
|
{
|
||||||
|
var dsn = Environment.GetEnvironmentVariable("DEEPDRFT_TEST_PG");
|
||||||
|
if (string.IsNullOrWhiteSpace(dsn))
|
||||||
|
Assert.Ignore("Set DEEPDRFT_TEST_PG to a Postgres connection string to run the ILike search test.");
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||||
|
.UseNpgsql(dsn)
|
||||||
|
.Options;
|
||||||
|
await using var pg = new DeepDrftContext(options);
|
||||||
|
await pg.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pg.Releases.AddRange(
|
||||||
|
Release("Nightfall", "jazzmin", ReleaseMedium.Session),
|
||||||
|
Release("All JAZZ Hands", "Brick", ReleaseMedium.Cut),
|
||||||
|
Release("Silence", "Nobody", ReleaseMedium.Mix));
|
||||||
|
await pg.SaveChangesAsync();
|
||||||
|
|
||||||
|
var repo = new ReleaseRepository(pg, NullLogger<ReleaseRepository>.Instance);
|
||||||
|
var result = await repo.GetPagedByMediumAsync(
|
||||||
|
DefaultPaging(), medium: null, new ReleaseFilter { SearchText = "jazz" }, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(result.Items.Select(r => r.Title),
|
||||||
|
Is.EquivalentTo(new[] { "Nightfall", "All JAZZ Hands" }),
|
||||||
|
"ILike matches 'jazz' case-insensitively in the release Title or Artist");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await pg.Database.EnsureDeletedAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user