using DeepDrftModels.DTOs; using DeepDrftModels.Enums; using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; namespace DeepDrftPublic.Client.Pages; /// /// 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 seam: the unfiltered /// first page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor /// replays the card entrance animations. /// /// Filter state is URL-bound (Phase 11 §5): q / medium / genre enter via /// and leave via . /// The URL is the single source of truth — filter handlers only navigate; the seed-and-fetch reaction /// lives in so back/forward history "just works" (§5.3 Option A). /// 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; } [Inject] public required NavigationManager Navigation { get; set; } // Query-string-bound filter inputs (§5.1). All optional; an absent param means "no filter on that // axis". `medium` is the lowercase enum token the data service already speaks, parsed back with // Enum.TryParse(ignoreCase) + Enum.IsDefined (the BatchUpload / API posture). [SupplyParameterFromQuery(Name = "q")] public string? QueryParam { get; set; } [SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; } [SupplyParameterFromQuery(Name = "genre")] public string? GenreParam { 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(); private bool _loading = true; private List _releases = []; private List _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; // Idempotency guard for the OnParametersSet reaction (§5.3): a same-route query change re-fires // OnParametersSet, and the search debounce can race a rapid medium-chip nav. We only re-seed and // re-fetch when the composed filter triple actually changed. Sentinel != any real key so the first // reaction always runs. private string? _loadedFilterKey; private bool HasActiveFilter => _selectedMedium is not null || !string.IsNullOrWhiteSpace(_selectedGenre) || !string.IsNullOrWhiteSpace(SearchText); protected override Task OnInitializedAsync() { _persistingSubscription = PersistentState.RegisterOnPersisting(Persist); return Task.CompletedTask; } // The seed-and-fetch reaction lives here, not in OnInitialized: a same-route query change reuses // the component and fires only OnParametersSet (§5.3). Keyed off the composed filter triple so an // identical param set (debounce vs. chip-nav race) is a no-op. protected override async Task OnParametersSetAsync() { // Seed filter fields from the URL before the restore/fetch decision so HasActiveFilter reflects // the requested URL (a direct /archive?medium=mix load must fetch, not restore the bridge). SeedFromQuery(); var filterKey = ComposeFilterKey(); if (filterKey == _loadedFilterKey) return; var firstReaction = _loadedFilterKey is null; _loadedFilterKey = filterKey; // 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 && _genres.Count == 0) await LoadGenres(); // The prerendered page is always the unfiltered first page. Restore it only on the first // reaction and only when no filter is active; a filtered load (or a later filter change) must // fetch its own narrowed result instead. if (firstReaction && !HasActiveFilter && PersistentState.TryTakeFromJson>(PersistKey, out var restored) && restored is not null) { _releases = restored; _loading = false; return; } await LoadReleases(); } // Maps the query-string params onto the filter fields. `medium` parsed leniently (ignoreCase) and // validated with Enum.IsDefined so a stray token degrades to "All" rather than throwing. private void SeedFromQuery() { SearchText = string.IsNullOrWhiteSpace(QueryParam) ? null : QueryParam; _selectedGenre = string.IsNullOrWhiteSpace(GenreParam) ? null : GenreParam; _selectedMedium = !string.IsNullOrWhiteSpace(MediumParam) && Enum.TryParse(MediumParam, ignoreCase: true, out var medium) && Enum.IsDefined(medium) ? medium : null; } private string ComposeFilterKey() => $"{SearchText}|{_selectedMedium}|{_selectedGenre}"; 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; } // Filter handlers only navigate (§5.3 Option A): the query-param change re-runs the seed-and-fetch // reaction in OnParametersSet, keeping the URL the single source of truth and making back/forward // history correct for free. // Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a // burst reaches here. private void OnSearchInput(string? value) => NavigateToFilter( search: string.IsNullOrWhiteSpace(value) ? null : value, medium: _selectedMedium, genre: _selectedGenre); private void OnMediumSelected(ReleaseMedium? medium) => NavigateToFilter( search: SearchText, medium: medium, genre: _selectedGenre); private void OnGenreSelected(string? genre) => NavigateToFilter( search: SearchText, medium: _selectedMedium, genre: string.IsNullOrWhiteSpace(genre) ? null : genre); // Composes the /archive?... URL from the requested filter triple. Each axis is omitted when null // (matches the null-means-all scheme); values are escaped. Plain /archive when all three are clear. private void NavigateToFilter(string? search, ReleaseMedium? medium, string? genre) { var query = new List(3); if (!string.IsNullOrWhiteSpace(search)) query.Add($"q={Uri.EscapeDataString(search)}"); if (medium is not null) query.Add($"medium={medium.ToString()!.ToLowerInvariant()}"); if (!string.IsNullOrWhiteSpace(genre)) query.Add($"genre={Uri.EscapeDataString(genre)}"); var url = query.Count > 0 ? $"/archive?{string.Join('&', query)}" : "/archive"; Navigation.NavigateTo(url); } // 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(); }