From 58df648bd5280d32e63a293beb08015fd239cc40 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 21 Mar 2026 13:57:16 +0100 Subject: [PATCH] Add shared pinned tables state --- ...s_frontend_overhaul_implementation_plan.md | 4 +- .../Components/Pages/Tables.razor | 36 ++++- .../Frontend/AppState/BrowserStorageKeys.cs | 1 + .../Frontend/AppState/PinnedTableEntry.cs | 8 ++ .../Frontend/AppState/PinnedTablesState.cs | 126 ++++++++++++++++++ src/RolemasterDb.App/Program.cs | 1 + 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/RolemasterDb.App/Frontend/AppState/PinnedTableEntry.cs create mode 100644 src/RolemasterDb.App/Frontend/AppState/PinnedTablesState.cs diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 773db81..504f31b 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -53,6 +53,7 @@ It is intentionally implementation-focused: | 2026-03-21 | P2.1 | Completed | Added canonical `/tools/diagnostics` and `/tools/api` routes with dedicated tooling page components, extracted the diagnostics and API content into shared tool components, and updated the `Tools` landing page to link to the new route structure. | | 2026-03-21 | P2.2 | Completed | Replaced the old `/diagnostics` and `/api` pages with lightweight compatibility routes that reuse a shared redirect component, preserve query strings and fragments, and replace browser history while forwarding into the canonical `Tools` routes. | | 2026-03-21 | P2.3 | Completed | Added a shared `RecentTablesState` service backed by browser storage, centralized the recents storage key, and started recording successful table visits from the `Tables` page so later omnibox and rail work has real shared data. | +| 2026-03-21 | P2.4 | Completed | Added a shared `PinnedTablesState` service with browser persistence, centralized the pin storage key, initialized pin state in the `Tables` page, and added a first live pin/unpin action plus pinned status chips so later omnibox and navigation work have real saved pins to consume. | ### Lessons Learned @@ -75,6 +76,7 @@ It is intentionally implementation-focused: - For route migration in Blazor, extracting the destination UI into shared components keeps canonical routes and temporary compatibility routes from drifting while the redirect phase is still pending. - Compatibility routes should stay declarative and shared. A single redirect component is enough for route forwarding and avoids copy-pasted `NavigationManager` lifecycle logic in page files. - Shared browser-backed state becomes more useful once one real page writes to it immediately. Recording recents from `Tables` now keeps later omnibox work from being blocked on synthetic placeholder data. +- Shared pinned-state services also need one live writer early. A minimal pin/unpin affordance in the current `Tables` page is enough to validate persistence before the larger navigation surfaces consume pins. ## Target Outcomes @@ -365,7 +367,7 @@ Establish the shared shell, tokens, typography, and theme system that every dest | `P2.1` | Completed | Canonical `Tools` child routes now exist, backed by dedicated route pages and shared tooling content components. | | `P2.2` | Completed | Old `/diagnostics` and `/api` routes now forward into the canonical `Tools` routes with shared redirect behavior. | | `P2.3` | Completed | Recent critical-table visits now persist through a shared app-state service and are recorded from the `Tables` page. | -| `P2.4` | Pending | Pinned tables state has not been introduced yet. | +| `P2.4` | Completed | Pinned tables now persist through a shared app-state service and can already be toggled from the `Tables` page. | | `P2.5` | Pending | Table-context URL parsing and serialization still needs a shared model. | | `P2.6` | Pending | The shell omnibox is still a placeholder trigger. | | `P2.7` | Pending | Shared primitives for chips, tabs, drawers, and inspector sections are not extracted yet. | diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index 0bff37b..543c525 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -6,6 +6,7 @@ @using System.Linq @inject IJSRuntime JSRuntime @inject LookupService LookupService +@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState @inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState Critical Tables @@ -29,6 +30,10 @@ @if (SelectedTableReference is { } selected) { + @if (PinnedTablesState.IsPinned(selected.Key)) + { + Pinned + } @($"{selected.CurationPercentage}%") } @@ -51,6 +56,10 @@ @table.Label + @if (PinnedTablesState.IsPinned(table.Key)) + { + Pinned + } @($"{table.CurationPercentage}%") @@ -95,7 +104,12 @@

@detail.DisplayName

@readingHint

-

Use the curation action or edit action on any filled result.

+
+ +

Use the curation action or edit action on any filled result.

+
@{ @@ -311,6 +325,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { + if (firstRender) + { + await RecentTablesState.InitializeAsync(); + await PinnedTablesState.InitializeAsync(); + } + if (!hasResolvedStoredTableSelection && referenceData?.CriticalTables.Count > 0) { try @@ -773,6 +793,20 @@ private Task PersistSelectedTableAsync(string tableSlug) => JSRuntime.InvokeVoidAsync("localStorage.setItem", SelectedTableStorageKey, tableSlug).AsTask(); + private Task TogglePinnedTableAsync() + { + if (SelectedTableReference is not { } selectedTable) + { + return Task.CompletedTask; + } + + return PinnedTablesState.ToggleAsync( + selectedTable.Key, + selectedTable.Label, + selectedTable.Family, + selectedTable.CurationPercentage); + } + private Task RecordRecentTableVisitAsync() { if (SelectedTableReference is not { } selectedTable) diff --git a/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs index e45ea0a..d7de01a 100644 --- a/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs +++ b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs @@ -3,5 +3,6 @@ namespace RolemasterDb.App.Frontend.AppState; public static class BrowserStorageKeys { public const string ThemeMode = "rolemaster.theme.mode"; + public const string PinnedTables = "rolemaster.tables.pinned"; public const string RecentTables = "rolemaster.tables.recent"; } diff --git a/src/RolemasterDb.App/Frontend/AppState/PinnedTableEntry.cs b/src/RolemasterDb.App/Frontend/AppState/PinnedTableEntry.cs new file mode 100644 index 0000000..9d3245e --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/PinnedTableEntry.cs @@ -0,0 +1,8 @@ +namespace RolemasterDb.App.Frontend.AppState; + +public sealed record PinnedTableEntry( + string Slug, + string Label, + string Family, + int CurationPercentage, + DateTimeOffset PinnedAtUtc); diff --git a/src/RolemasterDb.App/Frontend/AppState/PinnedTablesState.cs b/src/RolemasterDb.App/Frontend/AppState/PinnedTablesState.cs new file mode 100644 index 0000000..43757e8 --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/PinnedTablesState.cs @@ -0,0 +1,126 @@ +using System.Text.Json; + +namespace RolemasterDb.App.Frontend.AppState; + +public sealed class PinnedTablesState(BrowserStorageService browserStorage) +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private bool isInitialized; + + public IReadOnlyList Items { get; private set; } = []; + + public event Action? Changed; + + public async Task InitializeAsync() + { + if (isInitialized) + { + return; + } + + try + { + var storedValue = await browserStorage.GetItemAsync(BrowserStorageKeys.PinnedTables); + Items = DeserializeItems(storedValue); + isInitialized = true; + Changed?.Invoke(); + } + catch (JsonException) + { + Items = []; + isInitialized = true; + Changed?.Invoke(); + } + catch (InvalidOperationException) + { + // JS interop is unavailable during prerender. Retry on the next interactive render. + } + } + + public bool IsPinned(string slug) => + Items.Any(item => string.Equals(item.Slug, slug, StringComparison.OrdinalIgnoreCase)); + + public async Task ToggleAsync(string slug, string label, string family, int curationPercentage) + { + await InitializeAsync(); + + if (IsPinned(slug)) + { + await UnpinAsync(slug); + return; + } + + await PinAsync(slug, label, family, curationPercentage); + } + + private async Task PinAsync(string slug, string label, string family, int curationPercentage) + { + if (string.IsNullOrWhiteSpace(slug) || string.IsNullOrWhiteSpace(label)) + { + return; + } + + var updatedItems = new List + { + new( + slug.Trim(), + label.Trim(), + family.Trim(), + curationPercentage, + DateTimeOffset.UtcNow) + }; + + foreach (var item in Items) + { + if (string.Equals(item.Slug, slug, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + updatedItems.Add(item); + } + + Items = updatedItems; + await PersistAsync(); + Changed?.Invoke(); + } + + private async Task UnpinAsync(string slug) + { + Items = Items + .Where(item => !string.Equals(item.Slug, slug, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + await PersistAsync(); + Changed?.Invoke(); + } + + private async Task PersistAsync() + { + var serialized = JsonSerializer.Serialize(Items, SerializerOptions); + await browserStorage.SetItemAsync(BrowserStorageKeys.PinnedTables, serialized); + } + + private static IReadOnlyList DeserializeItems(string? storedValue) + { + if (string.IsNullOrWhiteSpace(storedValue)) + { + return []; + } + + var items = JsonSerializer.Deserialize>(storedValue, SerializerOptions); + if (items is null || items.Count == 0) + { + return []; + } + + return items + .Where(item => !string.IsNullOrWhiteSpace(item.Slug) && !string.IsNullOrWhiteSpace(item.Label)) + .GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase) + .Select(group => group + .OrderByDescending(item => item.PinnedAtUtc) + .First()) + .OrderByDescending(item => item.PinnedAtUtc) + .ToList(); + } +} diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index 1c9c952..66c9d38 100644 --- a/src/RolemasterDb.App/Program.cs +++ b/src/RolemasterDb.App/Program.cs @@ -13,6 +13,7 @@ builder.Services.AddDbContextFactory(options => options.Use builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();