diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs b/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs index ef5b97d..2acffe6 100644 --- a/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs +++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs @@ -12,6 +12,11 @@ namespace DeepDrftPublic.Client.Pages; /// 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 { @@ -24,6 +29,14 @@ public partial class ArchiveView : ComponentBase, IDisposable [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). @@ -41,24 +54,50 @@ public partial class ArchiveView : ComponentBase, IDisposable 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 async Task OnInitializedAsync() + 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) + if (RendererInfo.IsInteractive && _genres.Count == 0) 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 + // 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) { @@ -70,6 +109,22 @@ public partial class ArchiveView : ComponentBase, IDisposable 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(); @@ -95,24 +150,41 @@ public partial class ArchiveView : ComponentBase, IDisposable _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. Re-fetches with the composed filter (search + medium + genre). - private async Task OnSearchInput(string? value) - { - SearchText = string.IsNullOrWhiteSpace(value) ? null : value; - await LoadReleases(); - } + // burst reaches here. + private void OnSearchInput(string? value) => NavigateToFilter( + search: string.IsNullOrWhiteSpace(value) ? null : value, + medium: _selectedMedium, + genre: _selectedGenre); - private async Task OnMediumSelected(ReleaseMedium? medium) - { - _selectedMedium = medium; - await LoadReleases(); - } + private void OnMediumSelected(ReleaseMedium? medium) => NavigateToFilter( + search: SearchText, + medium: medium, + genre: _selectedGenre); - private async Task OnGenreSelected(string? genre) + 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) { - _selectedGenre = string.IsNullOrWhiteSpace(genre) ? null : genre; - await LoadReleases(); + 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 diff --git a/DeepDrftPublic.Client/Pages/GenresView.razor.cs b/DeepDrftPublic.Client/Pages/GenresView.razor.cs index 1348d12..755a6d5 100644 --- a/DeepDrftPublic.Client/Pages/GenresView.razor.cs +++ b/DeepDrftPublic.Client/Pages/GenresView.razor.cs @@ -22,5 +22,5 @@ public partial class GenresView : ComponentBase } private void OpenGenre(string genre) - => Navigation.NavigateTo($"/tracks?genre={Uri.EscapeDataString(genre)}"); + => Navigation.NavigateTo($"/archive?genre={Uri.EscapeDataString(genre)}"); }