Merge p11-w4-archive-url-filters into dev (P11 11.D: Archive filters in URL, GenresView repoint)
This commit is contained in:
@@ -12,6 +12,11 @@ namespace DeepDrftPublic.Client.Pages;
|
|||||||
/// routes to its per-medium detail. Mirrors the <see cref="MediumBrowseBase"/> seam: the unfiltered
|
/// 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
|
/// first page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor
|
||||||
/// replays the card entrance animations.
|
/// replays the card entrance animations.
|
||||||
|
///
|
||||||
|
/// Filter state is URL-bound (Phase 11 §5): <c>q</c> / <c>medium</c> / <c>genre</c> enter via
|
||||||
|
/// <see cref="SupplyParameterFromQueryAttribute"/> and leave via <see cref="NavigationManager.NavigateTo(string)"/>.
|
||||||
|
/// The URL is the single source of truth — filter handlers only navigate; the seed-and-fetch reaction
|
||||||
|
/// lives in <see cref="OnParametersSetAsync"/> so back/forward history "just works" (§5.3 Option A).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class ArchiveView : ComponentBase, IDisposable
|
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 IReleaseDataService ReleaseData { get; set; }
|
||||||
[Inject] public required ITrackDataService TrackData { get; set; }
|
[Inject] public required ITrackDataService TrackData { get; set; }
|
||||||
[Inject] public required PersistentComponentState PersistentState { 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,
|
// 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).
|
// with no markup fork (Phase 9 extension discipline).
|
||||||
@@ -41,24 +54,50 @@ public partial class ArchiveView : ComponentBase, IDisposable
|
|||||||
|
|
||||||
private PersistingComponentStateSubscription _persistingSubscription;
|
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 =>
|
private bool HasActiveFilter =>
|
||||||
_selectedMedium is not null
|
_selectedMedium is not null
|
||||||
|| !string.IsNullOrWhiteSpace(_selectedGenre)
|
|| !string.IsNullOrWhiteSpace(_selectedGenre)
|
||||||
|| !string.IsNullOrWhiteSpace(SearchText);
|
|| !string.IsNullOrWhiteSpace(SearchText);
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
_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
|
// 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
|
// release join — see GetDistinctGenresAsync). It only renders interactively, so it is fetched
|
||||||
// lazily on the interactive pass rather than persisted.
|
// lazily on the interactive pass rather than persisted.
|
||||||
if (RendererInfo.IsInteractive)
|
if (RendererInfo.IsInteractive && _genres.Count == 0)
|
||||||
await LoadGenres();
|
await LoadGenres();
|
||||||
|
|
||||||
// The prerendered page is always the unfiltered first page. Restore it only when no filter is
|
// The prerendered page is always the unfiltered first page. Restore it only on the first
|
||||||
// active; a filtered interactive pass must fetch its own narrowed result instead.
|
// reaction and only when no filter is active; a filtered load (or a later filter change) must
|
||||||
if (!HasActiveFilter
|
// fetch its own narrowed result instead.
|
||||||
|
if (firstReaction
|
||||||
|
&& !HasActiveFilter
|
||||||
&& PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored)
|
&& PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored)
|
||||||
&& restored is not null)
|
&& restored is not null)
|
||||||
{
|
{
|
||||||
@@ -70,6 +109,22 @@ public partial class ArchiveView : ComponentBase, IDisposable
|
|||||||
await LoadReleases();
|
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<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
|
||||||
|
&& Enum.IsDefined(medium)
|
||||||
|
? medium
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ComposeFilterKey() => $"{SearchText}|{_selectedMedium}|{_selectedGenre}";
|
||||||
|
|
||||||
private async Task LoadGenres()
|
private async Task LoadGenres()
|
||||||
{
|
{
|
||||||
var result = await TrackData.GetGenres();
|
var result = await TrackData.GetGenres();
|
||||||
@@ -95,24 +150,41 @@ public partial class ArchiveView : ComponentBase, IDisposable
|
|||||||
_loading = false;
|
_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
|
// 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).
|
// burst reaches here.
|
||||||
private async Task OnSearchInput(string? value)
|
private void OnSearchInput(string? value) => NavigateToFilter(
|
||||||
{
|
search: string.IsNullOrWhiteSpace(value) ? null : value,
|
||||||
SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
|
medium: _selectedMedium,
|
||||||
await LoadReleases();
|
genre: _selectedGenre);
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnMediumSelected(ReleaseMedium? medium)
|
private void OnMediumSelected(ReleaseMedium? medium) => NavigateToFilter(
|
||||||
{
|
search: SearchText,
|
||||||
_selectedMedium = medium;
|
medium: medium,
|
||||||
await LoadReleases();
|
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;
|
var query = new List<string>(3);
|
||||||
await LoadReleases();
|
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
|
// Display label for a medium filter chip. Centralised so a new medium's label is one entry, not a
|
||||||
|
|||||||
@@ -22,5 +22,5 @@ public partial class GenresView : ComponentBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void OpenGenre(string genre)
|
private void OpenGenre(string genre)
|
||||||
=> Navigation.NavigateTo($"/tracks?genre={Uri.EscapeDataString(genre)}");
|
=> Navigation.NavigateTo($"/archive?genre={Uri.EscapeDataString(genre)}");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user