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)}");
}