140 lines
5.3 KiB
C#
140 lines
5.3 KiB
C#
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="MediumBrowseBase"/> 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();
|
|
}
|
|
|
|
// 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();
|
|
}
|