Add shared pinned tables state
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
<PageTitle>Critical Tables</PageTitle>
|
||||
@@ -29,6 +30,10 @@
|
||||
@if (SelectedTableReference is { } selected)
|
||||
{
|
||||
<span class="table-select-trigger-chips">
|
||||
@if (PinnedTablesState.IsPinned(selected.Key))
|
||||
{
|
||||
<span class="chip">Pinned</span>
|
||||
}
|
||||
<span class="chip">@($"{selected.CurationPercentage}%")</span>
|
||||
</span>
|
||||
}
|
||||
@@ -51,6 +56,10 @@
|
||||
<strong class="table-select-option-title">@table.Label</strong>
|
||||
</span>
|
||||
<span class="table-select-option-chips">
|
||||
@if (PinnedTablesState.IsPinned(table.Key))
|
||||
{
|
||||
<span class="chip">Pinned</span>
|
||||
}
|
||||
<span class="chip">@($"{table.CurationPercentage}%")</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -95,7 +104,12 @@
|
||||
<h2 class="panel-title">@detail.DisplayName</h2>
|
||||
<p class="table-browser-reading-hint">@readingHint</p>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button type="button" class="btn btn-link" @onclick="TogglePinnedTableAsync">
|
||||
@(PinnedTablesState.IsPinned(detail.Slug) ? "Unpin table" : "Pin table")
|
||||
</button>
|
||||
<p class="table-browser-edit-hint">Use the curation action or edit action on any filled result.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@{
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace RolemasterDb.App.Frontend.AppState;
|
||||
|
||||
public sealed record PinnedTableEntry(
|
||||
string Slug,
|
||||
string Label,
|
||||
string Family,
|
||||
int CurationPercentage,
|
||||
DateTimeOffset PinnedAtUtc);
|
||||
126
src/RolemasterDb.App/Frontend/AppState/PinnedTablesState.cs
Normal file
126
src/RolemasterDb.App/Frontend/AppState/PinnedTablesState.cs
Normal file
@@ -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<PinnedTableEntry> 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<PinnedTableEntry>
|
||||
{
|
||||
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<PinnedTableEntry> DeserializeItems(string? storedValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedValue))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var items = JsonSerializer.Deserialize<List<PinnedTableEntry>>(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();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ builder.Services.AddDbContextFactory<RolemasterDbContext>(options => options.Use
|
||||
builder.Services.AddSingleton<CriticalImportArtifactLocator>();
|
||||
builder.Services.AddScoped<LookupService>();
|
||||
builder.Services.AddScoped<BrowserStorageService>();
|
||||
builder.Services.AddScoped<PinnedTablesState>();
|
||||
builder.Services.AddScoped<RecentTablesState>();
|
||||
builder.Services.AddScoped<ThemeState>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user