Add shared pinned tables state

This commit is contained in:
2026-03-21 13:57:16 +01:00
parent 40b01d707f
commit 58df648bd5
6 changed files with 174 additions and 2 deletions

View File

@@ -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>
<p class="table-browser-edit-hint">Use the curation action or edit action on any filled result.</p>
<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)

View File

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

View File

@@ -0,0 +1,8 @@
namespace RolemasterDb.App.Frontend.AppState;
public sealed record PinnedTableEntry(
string Slug,
string Label,
string Family,
int CurationPercentage,
DateTimeOffset PinnedAtUtc);

View 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();
}
}

View File

@@ -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>();