diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md
index fea82fe..d3cdcb4 100644
--- a/docs/tables_frontend_overhaul_implementation_plan.md
+++ b/docs/tables_frontend_overhaul_implementation_plan.md
@@ -30,7 +30,7 @@ It is intentionally implementation-focused:
- Branch: `frontend/tables-overhaul`
- Last updated: `2026-04-12`
-- Current focus: `Phase 4`
+- Current focus: `Phase 5`
- Document mode: living plan and progress log
### Progress Log
@@ -76,6 +76,7 @@ It is intentionally implementation-focused:
| 2026-03-21 | Post-P3 fix 1 | Completed | Added a defensive visible-column fallback in the table canvas and tightened view-state normalization so a stale severity filter cannot collapse the grid to roll bands only. |
| 2026-04-11 | Post-P3 fix 2 | Completed | Simplified `/tables` by removing static prose and context controls, dropped the redundant selected-result inspector in favor of a floating action menu, and moved the canvas onto its own scroll region so sticky headers layer correctly beneath the context bar. |
| 2026-04-12 | Phase 4 planning | Planned | Expanded the `Curation` phase from a route placeholder into a concrete migration plan that moves queue-first curation out of `Tables` and into a dedicated workflow surface. |
+| 2026-04-12 | Phase 4 | Completed | Replaced the placeholder `/curation` route with a real queue-first workspace, added queue scope and context persistence, moved browse-to-curation handoff out of `Tables`, and preserved diagnostics and full-editor escape hatches without keeping queue work on the reference page. |
### Lessons Learned
@@ -565,22 +566,22 @@ Turn `/tables` into the canonical reference surface for reading and inspecting c
### Status
-`Planned`
+`Completed`
### Task Progress
| Task | Status | Notes |
| --- | --- | --- |
-| `P4.1` | Planned | Replace the current placeholder route with a real stateful `Curation` host page while keeping shell navigation and route identity stable. |
-| `P4.2` | Planned | Reuse the shared table-context and selection patterns from Phase 2 and Phase 3 so queue state, deep links, and restore behavior stay consistent with `Tables`. |
-| `P4.3` | Planned | Introduce one normalized queue-scope model for `All tables`, `Selected table`, and `Pinned set` instead of hard-coding the current table-only flow. |
-| `P4.4` | Planned | Introduce a shared queue descriptor and next-item selection pipeline so queue ordering and scope resolution are not trapped inside `Tables.razor`. |
-| `P4.5` | Planned | Build a queue-first split workspace with summary, source image, parsed preview, quick parse input, and predictable primary actions. |
-| `P4.6` | Planned | Rehost the current load, reparse, mark-curated, and save-and-advance mechanics from `Tables` with minimal churn instead of rewriting them from scratch. |
-| `P4.7` | Planned | Preserve full editor access as an escape hatch while making `Mark curated` and `Quick parse` the common fast path. |
-| `P4.8` | Planned | Keep save-and-advance inside the same workflow lane by retaining scope, queue ordering, and current work context after save. |
-| `P4.9` | Planned | Keep the action hierarchy disciplined so warning treatment is reserved for disruptive repair actions rather than normal save flow. |
-| `P4.10` | Planned | Remove raw diagnostics and provenance details from the default curation lane and link out to `Tools` when deep inspection is needed. |
+| `P4.1` | Completed | The placeholder route was replaced with an interactive `Curation` page host that owns queue scope, queue item, loading, quick-parse, and save-and-advance state. |
+| `P4.2` | Completed | `Curation` now restores and persists context through the shared table-context infrastructure and reuses the same deep-link object identifiers as `Tables` and diagnostics. |
+| `P4.3` | Completed | The new queue surface supports `All tables`, `Selected table`, and `Pinned set` scopes. |
+| `P4.4` | Completed | Queue ordering and next-item resolution now live in dedicated frontend curation helpers instead of inside `Tables.razor`. |
+| `P4.5` | Completed | The page now presents a stable queue-first split workspace with queue summary, source image, parsed preview, inline quick parse, and fixed primary actions. |
+| `P4.6` | Completed | The existing load, reparse, mark-curated, and save-and-advance mechanics were rehosted into `Curation` with minimal backend churn. |
+| `P4.7` | Completed | Full editor access remains available from the curation lane, while `Quick parse` and `Mark curated and continue` are the primary fast path. |
+| `P4.8` | Completed | Save-and-advance keeps the user in the same scope and walks the queue forward without reopening context. |
+| `P4.9` | Completed | Normal save flow stays on the primary accent action hierarchy and does not borrow warning treatment. |
+| `P4.10` | Completed | Diagnostics remain out of the default curation lane and are linked contextually through `Tools` instead of embedded inline. |
### Current Baseline
diff --git a/src/RolemasterDb.App/Components/Curation/CurationQueueBar.razor b/src/RolemasterDb.App/Components/Curation/CurationQueueBar.razor
new file mode 100644
index 0000000..100960f
--- /dev/null
+++ b/src/RolemasterDb.App/Components/Curation/CurationQueueBar.razor
@@ -0,0 +1,99 @@
+@using RolemasterDb.App.Frontend.Curation
+
+
+
+
+
+
+ @if (string.Equals(SelectedScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
+ {
+
+ Table scope
+
+ @foreach (var table in Tables)
+ {
+ @table.Label
+ }
+
+
+ }
+
+ @if (CurrentItem is not null)
+ {
+
+ Needs curation
+ @CurrentItem.TableName
+ Roll band @CurrentItem.RollBand
+ Severity @CurrentItem.ColumnLabel
+ @if (!string.IsNullOrWhiteSpace(CurrentItem.GroupLabel))
+ {
+ Variant @CurrentItem.GroupLabel
+ }
+ Result ID @CurrentItem.ResultId
+
+ }
+
+
+@code {
+
+ [Parameter]
+ public IReadOnlyList ScopeItems { get; set; } = [];
+
+ [Parameter]
+ public string SelectedScope { get; set; } = CurationQueueScopes.AllTables;
+
+ [Parameter, EditorRequired]
+ public EventCallback SelectedScopeChanged { get; set; }
+
+ [Parameter]
+ public IReadOnlyList Tables { get; set; } = [];
+
+ [Parameter]
+ public string SelectedTableSlug { get; set; } = string.Empty;
+
+ [Parameter, EditorRequired]
+ public EventCallback SelectedTableSlugChanged { get; set; }
+
+ [Parameter]
+ public CurationQueueItem? CurrentItem { get; set; }
+
+ [Parameter]
+ public bool IsBusy { get; set; }
+
+ [Parameter]
+ public string? TablesHref { get; set; }
+
+ [Parameter]
+ public string? DiagnosticsHref { get; set; }
+
+ private Task HandleTableChanged(ChangeEventArgs args) =>
+ SelectedTableSlugChanged.InvokeAsync(args.Value?.ToString() ?? string.Empty);
+
+}
\ No newline at end of file
diff --git a/src/RolemasterDb.App/Components/Curation/CurationQueueEmptyState.razor b/src/RolemasterDb.App/Components/Curation/CurationQueueEmptyState.razor
new file mode 100644
index 0000000..1834e50
--- /dev/null
+++ b/src/RolemasterDb.App/Components/Curation/CurationQueueEmptyState.razor
@@ -0,0 +1,27 @@
+
+
+
+ @if (!string.IsNullOrWhiteSpace(ActionHref) && !string.IsNullOrWhiteSpace(ActionLabel))
+ {
+
@ActionLabel
+ }
+
+
+@code {
+
+ [Parameter]
+ public string Title { get; set; } = "Queue empty";
+
+ [Parameter]
+ public string Message { get; set; } = string.Empty;
+
+ [Parameter]
+ public string? ActionHref { get; set; }
+
+ [Parameter]
+ public string? ActionLabel { get; set; }
+
+}
\ No newline at end of file
diff --git a/src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor b/src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor
new file mode 100644
index 0000000..c54a537
--- /dev/null
+++ b/src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor
@@ -0,0 +1,248 @@
+@using System.Collections.Generic
+@using System.Linq
+@using Microsoft.AspNetCore.Components.Web
+
+@if (IsLoading)
+{
+
+}
+else if (Model is null)
+{
+
+ @if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
+ {
+
@GetVisibleErrorMessage()
+ }
+ else
+ {
+
@EmptyMessage
+ }
+
+}
+else
+{
+
+ @if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
+ {
+
@GetVisibleErrorMessage()
+ }
+
+
+
+ @if (IsQuickParseMode)
+ {
+
+ }
+ else
+ {
+
+
+
+ }
+
+
+
+ @if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl))
+ {
+
+ }
+ else
+ {
+
+
No source image is available for this cell yet.
+
+ }
+
+
+
+ @if (!IsQuickParseMode)
+ {
+ @if (GetUsedLegendEntries(Model, LegendEntries) is { Count: > 0 } usedLegendEntries)
+ {
+
+ @foreach (var entry in usedLegendEntries)
+ {
+
+ @entry.Symbol
+ @entry.Label
+
+ }
+
+ }
+ }
+
+
+ @if (IsQuickParseMode)
+ {
+ Cancel
+
+ @(IsReparsing ? ReparseBusyLabel : ReparseActionLabel)
+
+ }
+ else
+ {
+ @if (ShowSecondaryAction)
+ {
+
+ @SecondaryActionLabel
+
+ }
+
+ @if (ShowPrimaryAction)
+ {
+
+ @(IsSaving ? PrimaryBusyLabel : PrimaryActionLabel)
+
+ }
+ }
+
+
+}
+
+@code {
+
+ [Parameter, EditorRequired]
+ public CriticalCellEditorModel? Model { get; set; }
+
+ [Parameter]
+ public bool IsLoading { get; set; }
+
+ [Parameter]
+ public bool IsSaving { get; set; }
+
+ [Parameter]
+ public bool IsReparsing { get; set; }
+
+ [Parameter]
+ public bool IsQuickParseMode { get; set; }
+
+ [Parameter]
+ public string? ErrorMessage { get; set; }
+
+ [Parameter]
+ public string? QuickParseErrorMessage { get; set; }
+
+ [Parameter]
+ public IReadOnlyList? LegendEntries { get; set; }
+
+ [Parameter]
+ public string LoadingMessage { get; set; } = "Loading curation preview...";
+
+ [Parameter]
+ public string EmptyMessage { get; set; } = "No curation preview is available.";
+
+ [Parameter]
+ public string PrimaryActionLabel { get; set; } = "Mark curated";
+
+ [Parameter]
+ public string PrimaryBusyLabel { get; set; } = "Saving...";
+
+ [Parameter]
+ public string PrimaryActionCssClass { get; set; } = "btn-ritual";
+
+ [Parameter]
+ public bool ShowPrimaryAction { get; set; } = true;
+
+ [Parameter]
+ public string SecondaryActionLabel { get; set; } = "Open full editor";
+
+ [Parameter]
+ public string SecondaryActionCssClass { get; set; } = "btn btn-secondary";
+
+ [Parameter]
+ public bool ShowSecondaryAction { get; set; } = true;
+
+ [Parameter]
+ public string ReparseActionLabel { get; set; } = "Parse";
+
+ [Parameter]
+ public string ReparseBusyLabel { get; set; } = "Parsing...";
+
+ [Parameter, EditorRequired]
+ public EventCallback OnPrimaryAction { get; set; }
+
+ [Parameter, EditorRequired]
+ public EventCallback OnSecondaryAction { get; set; }
+
+ [Parameter, EditorRequired]
+ public EventCallback OnEnterQuickParse { get; set; }
+
+ [Parameter, EditorRequired]
+ public EventCallback OnCancelQuickParse { get; set; }
+
+ [Parameter, EditorRequired]
+ public EventCallback OnReparse { get; set; }
+
+ private CriticalCellQuickParseEditor? quickParseEditor;
+ private bool shouldFocusQuickParseEditor;
+ private bool wasQuickParseMode;
+
+ protected override void OnParametersSet()
+ {
+ if (IsQuickParseMode && !wasQuickParseMode)
+ {
+ shouldFocusQuickParseEditor = true;
+ }
+
+ wasQuickParseMode = IsQuickParseMode;
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (shouldFocusQuickParseEditor && quickParseEditor is not null)
+ {
+ shouldFocusQuickParseEditor = false;
+ await quickParseEditor.FocusAsync();
+ }
+ }
+
+ private static IReadOnlyList GetUsedLegendEntries(CriticalCellEditorModel model, IReadOnlyList? legendEntries)
+ {
+ if (legendEntries is null || legendEntries.Count == 0)
+ {
+ return [];
+ }
+
+ var usedEffectCodes = model.Effects.Select(effect => effect.EffectCode).Concat(model.Branches.SelectMany(branch => branch.Effects.Select(effect => effect.EffectCode))).Where(effectCode => !string.IsNullOrWhiteSpace(effectCode)).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return legendEntries.Where(entry => usedEffectCodes.Contains(entry.EffectCode)).ToList();
+ }
+
+ private async Task HandleReparseClickAsync(MouseEventArgs _)
+ {
+ if (quickParseEditor is not null)
+ {
+ await quickParseEditor.ReparseAsync();
+ return;
+ }
+
+ await OnReparse.InvokeAsync();
+ }
+
+ private string? GetVisibleErrorMessage() =>
+ IsQuickParseMode && !string.IsNullOrWhiteSpace(QuickParseErrorMessage) ? QuickParseErrorMessage : ErrorMessage;
+
+}
\ No newline at end of file
diff --git a/src/RolemasterDb.App/Components/Pages/Curation.razor b/src/RolemasterDb.App/Components/Pages/Curation.razor
index 479030c..1474bf1 100644
--- a/src/RolemasterDb.App/Components/Pages/Curation.razor
+++ b/src/RolemasterDb.App/Components/Pages/Curation.razor
@@ -1,8 +1,616 @@
@page "/curation"
+@rendermode InteractiveServer
+@using RolemasterDb.App.Frontend.AppState
+@inject NavigationManager NavigationManager
+@inject LookupService LookupService
+@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState
+@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState
Curation
-
- Curation
- The dedicated queue-first curation workflow lands in Phase 4. The shell route exists now so navigation can move to the target product model before the workflow rewrite begins.
+
+ @if (referenceData is null)
+ {
+
+ Loading curation queue...
+
+ }
+ else if (!hasInitializedContext)
+ {
+
+ Restoring curation context...
+
+ }
+ else
+ {
+
+
+ @if (!string.IsNullOrWhiteSpace(pageError))
+ {
+ @pageError
+ }
+ else if (currentQueueItem is null)
+ {
+
+ }
+ else
+ {
+
+
+
+ }
+ }
+
+@if (isEditorOpen)
+{
+
+}
+
+@code {
+ private const string ContextDestination = "curation";
+
+ private readonly Dictionary tableDetailCache = new(StringComparer.OrdinalIgnoreCase);
+ private LookupReferenceData? referenceData;
+ private string selectedQueueScope = CurationQueueScopes.AllTables;
+ private string selectedTableSlug = string.Empty;
+ private bool hasInitializedContext;
+ private bool isCurationLoading;
+ private bool isCurationSaving;
+ private bool isCurationReparsing;
+ private bool isCurationQuickParseMode;
+ private string? pageError;
+ private string? curationError;
+ private string? curationQuickParseError;
+ private CurationQueueItem? currentQueueItem;
+ private CriticalTableDetail? currentTableDetail;
+ private CriticalCellEditorModel? curationModel;
+ private bool isEditorOpen;
+ private bool isEditorLoading;
+ private bool isEditorReparsing;
+ private bool isEditorSaving;
+ private string? editorLoadError;
+ private string? editorReparseError;
+ private string? editorSaveError;
+ private CriticalCellEditorModel? editorModel;
+ private CriticalCellEditorModel? editorComparisonBaselineModel;
+
+ private bool IsBusy =>
+ isCurationLoading || isCurationSaving || isCurationReparsing || isEditorLoading || isEditorReparsing || isEditorSaving;
+
+ protected override async Task OnInitializedAsync()
+ {
+ referenceData = await LookupService.GetReferenceDataAsync();
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender || hasInitializedContext || referenceData?.CriticalTables.Count is not > 0)
+ {
+ return;
+ }
+
+ await PinnedTablesState.InitializeAsync();
+
+ var initialContext = await TableContextState.RestoreAsync(NavigationManager.Uri, ContextDestination, referenceData.CriticalTables, TableContextMode.Curation);
+
+ selectedQueueScope = CurationQueueScopes.Normalize(initialContext.QueueScope);
+ selectedTableSlug = initialContext.TableSlug ?? string.Empty;
+ hasInitializedContext = true;
+
+ await LoadQueueAsync(initialContext);
+ await InvokeAsync(StateHasChanged);
+ }
+
+ private async Task HandleQueueScopeChangedAsync(string scope)
+ {
+ selectedQueueScope = CurationQueueScopes.Normalize(scope);
+ await LoadQueueAsync();
+ }
+
+ private async Task HandleSelectedTableChangedAsync(string tableSlug)
+ {
+ selectedTableSlug = tableSlug;
+ await LoadQueueAsync();
+ }
+
+ private async Task LoadQueueAsync(TableContextSnapshot? preferredContext = null)
+ {
+ isCurationLoading = true;
+ pageError = null;
+
+ try
+ {
+ var (detail, item) = await ResolveQueueItemAsync(preferredContext);
+ await ApplyResolvedQueueItemAsync(detail, item, "The selected cell could not be loaded for curation.");
+ }
+ catch (Exception exception)
+ {
+ currentTableDetail = null;
+ currentQueueItem = null;
+ curationModel = null;
+ curationError = null;
+ curationQuickParseError = null;
+ pageError = exception.Message;
+ }
+ finally
+ {
+ isCurationLoading = false;
+ }
+ }
+
+ private async Task ApplyResolvedQueueItemAsync(CriticalTableDetail? detail, CurationQueueItem? item, string loadFailureMessage)
+ {
+ currentTableDetail = detail;
+ currentQueueItem = item;
+ curationModel = null;
+ curationError = null;
+ curationQuickParseError = null;
+ isCurationQuickParseMode = false;
+
+ if (item is not null)
+ {
+ selectedTableSlug = item.TableSlug;
+ var response = await LookupService.GetCriticalCellEditorAsync(item.TableSlug, item.ResultId);
+ if (response is null)
+ {
+ curationError = loadFailureMessage;
+ }
+ else
+ {
+ curationModel = CriticalCellEditorModel.FromResponse(response);
+ }
+ }
+
+ await PersistAndSyncCurationContextAsync();
+ }
+
+ private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveQueueItemAsync(TableContextSnapshot? preferredContext = null)
+ {
+ var candidateSlugs = GetCandidateTableSlugs();
+ if (candidateSlugs.Count == 0)
+ {
+ return (null, null);
+ }
+
+ foreach (var slug in OrderCandidatesForRestore(candidateSlugs, preferredContext?.TableSlug))
+ {
+ var detail = await GetTableDetailAsync(slug);
+ if (detail is null)
+ {
+ continue;
+ }
+
+ if (preferredContext is not null && string.Equals(slug, preferredContext.TableSlug, StringComparison.OrdinalIgnoreCase) && CurationQueueResolver.FindCell(detail, preferredContext) is { IsCurated: false } preferredCell)
+ {
+ return (detail, CurationQueueResolver.CreateQueueItem(detail, preferredCell));
+ }
+
+ if (CurationQueueResolver.FindFirstUncurated(detail) is { } firstCell)
+ {
+ return (detail, CurationQueueResolver.CreateQueueItem(detail, firstCell));
+ }
+
+ if (string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
+ {
+ return (detail, null);
+ }
+ }
+
+ return (null, null);
+ }
+
+ private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveNextQueueItemAsync()
+ {
+ if (currentQueueItem is null)
+ {
+ return await ResolveQueueItemAsync();
+ }
+
+ var candidateSlugs = GetCandidateTableSlugs();
+ var currentTableIndex = candidateSlugs.FindIndex(slug => string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase));
+ if (currentTableIndex < 0)
+ {
+ return await ResolveQueueItemAsync();
+ }
+
+ for (var index = currentTableIndex; index < candidateSlugs.Count; index++)
+ {
+ var slug = candidateSlugs[index];
+ var detail = await GetTableDetailAsync(slug, forceRefresh: string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase));
+ if (detail is null)
+ {
+ continue;
+ }
+
+ CriticalTableCellDetail? nextCell = string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase) ? CurationQueueResolver.FindNextUncurated(detail, currentQueueItem.ResultId) : CurationQueueResolver.FindFirstUncurated(detail);
+
+ if (nextCell is not null)
+ {
+ return (detail, CurationQueueResolver.CreateQueueItem(detail, nextCell));
+ }
+
+ if (string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
+ {
+ return (detail, null);
+ }
+ }
+
+ return (null, null);
+ }
+
+ private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveCurrentQueueItemAsync()
+ {
+ if (currentQueueItem is null)
+ {
+ return (null, null);
+ }
+
+ var detail = await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true);
+ if (detail is null)
+ {
+ return (null, null);
+ }
+
+ var cell = detail.Cells.FirstOrDefault(item => item.ResultId == currentQueueItem.ResultId);
+ return cell is null ? (detail, null) : (detail, CurationQueueResolver.CreateQueueItem(detail, cell));
+ }
+
+ private async Task GetTableDetailAsync(string slug, bool forceRefresh = false)
+ {
+ if (!forceRefresh && tableDetailCache.TryGetValue(slug, out var cachedDetail))
+ {
+ return cachedDetail;
+ }
+
+ var detail = await LookupService.GetCriticalTableAsync(slug);
+ tableDetailCache[slug] = detail;
+ return detail;
+ }
+
+ private List GetCandidateTableSlugs()
+ {
+ if (referenceData?.CriticalTables.Count is not > 0)
+ {
+ return [];
+ }
+
+ return selectedQueueScope switch
+ {
+ CurationQueueScopes.SelectedTable when !string.IsNullOrWhiteSpace(selectedTableSlug) => [TableContextState.ResolveTableSlug(referenceData.CriticalTables, selectedTableSlug)],
+ CurationQueueScopes.PinnedSet => referenceData.CriticalTables.Where(table => PinnedTablesState.IsPinned(table.Key)).Select(table => table.Key).ToList(),
+ _ => referenceData.CriticalTables.Select(table => table.Key).ToList()
+ };
+ }
+
+ private static IReadOnlyList OrderCandidatesForRestore(IReadOnlyList candidates, string? preferredSlug)
+ {
+ if (string.IsNullOrWhiteSpace(preferredSlug))
+ {
+ return candidates;
+ }
+
+ var ordered = new List(candidates.Count);
+ foreach (var slug in candidates)
+ {
+ if (string.Equals(slug, preferredSlug, StringComparison.OrdinalIgnoreCase))
+ {
+ ordered.Add(slug);
+ break;
+ }
+ }
+
+ foreach (var slug in candidates)
+ {
+ if (!string.Equals(slug, preferredSlug, StringComparison.OrdinalIgnoreCase))
+ {
+ ordered.Add(slug);
+ }
+ }
+
+ return ordered;
+ }
+
+ private Task EnterCurationQuickParseMode()
+ {
+ if (curationModel is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ curationQuickParseError = null;
+ isCurationQuickParseMode = true;
+ return Task.CompletedTask;
+ }
+
+ private Task CancelCurationQuickParseMode()
+ {
+ if (isCurationReparsing)
+ {
+ return Task.CompletedTask;
+ }
+
+ curationQuickParseError = null;
+ isCurationQuickParseMode = false;
+ return Task.CompletedTask;
+ }
+
+ private async Task ReparseCurationCellAsync()
+ {
+ if (curationModel is null || currentQueueItem is null)
+ {
+ return;
+ }
+
+ isCurationReparsing = true;
+ curationQuickParseError = null;
+
+ try
+ {
+ var response = await LookupService.ReparseCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, curationModel.ToRequest());
+ if (response is null)
+ {
+ curationQuickParseError = "The selected cell could not be re-parsed.";
+ return;
+ }
+
+ curationModel = CriticalCellEditorModel.FromResponse(response);
+ isCurationQuickParseMode = false;
+ await InvokeAsync(StateHasChanged);
+ }
+ catch (Exception exception)
+ {
+ curationQuickParseError = exception.Message;
+ }
+ finally
+ {
+ isCurationReparsing = false;
+ }
+ }
+
+ private async Task MarkCellCuratedAsync()
+ {
+ if (curationModel is null || currentQueueItem is null)
+ {
+ return;
+ }
+
+ isCurationSaving = true;
+ curationError = null;
+
+ try
+ {
+ curationModel.IsCurated = true;
+ var response = await LookupService.UpdateCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, curationModel.ToRequest());
+ if (response is null)
+ {
+ curationError = "The selected cell could not be marked curated.";
+ return;
+ }
+
+ await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true);
+ var (detail, item) = await ResolveNextQueueItemAsync();
+ await ApplyResolvedQueueItemAsync(detail, item, "The next cell could not be loaded for curation.");
+ }
+ catch (Exception exception)
+ {
+ curationError = exception.Message;
+ }
+ finally
+ {
+ isCurationSaving = false;
+ }
+ }
+
+ private Task OpenCellEditorAsync()
+ {
+ if (curationModel is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ editorLoadError = null;
+ editorReparseError = null;
+ editorSaveError = null;
+ editorComparisonBaselineModel = null;
+ editorModel = curationModel.Clone();
+ isEditorLoading = false;
+ isEditorReparsing = false;
+ isEditorSaving = false;
+ isEditorOpen = true;
+ return Task.CompletedTask;
+ }
+
+ private async Task CloseCellEditorAsync()
+ {
+ isEditorOpen = false;
+ isEditorLoading = false;
+ isEditorReparsing = false;
+ isEditorSaving = false;
+ editorLoadError = null;
+ editorReparseError = null;
+ editorSaveError = null;
+ editorModel = null;
+ editorComparisonBaselineModel = null;
+ await InvokeAsync(StateHasChanged);
+ }
+
+ private async Task ReparseCellEditorAsync()
+ {
+ if (editorModel is null || currentQueueItem is null)
+ {
+ return;
+ }
+
+ isEditorReparsing = true;
+ editorReparseError = null;
+
+ try
+ {
+ var comparisonBaseline = editorModel.Clone();
+ var response = await LookupService.ReparseCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, editorModel.ToRequest());
+ if (response is null)
+ {
+ editorReparseError = "The selected cell could not be re-parsed.";
+ return;
+ }
+
+ editorComparisonBaselineModel = comparisonBaseline;
+ editorModel = CriticalCellEditorModel.FromResponse(response);
+ await InvokeAsync(StateHasChanged);
+ }
+ catch (Exception exception)
+ {
+ editorReparseError = exception.Message;
+ }
+ finally
+ {
+ isEditorReparsing = false;
+ }
+ }
+
+ private async Task SaveCellEditorAsync()
+ {
+ if (editorModel is null || currentQueueItem is null)
+ {
+ return;
+ }
+
+ isEditorSaving = true;
+ editorSaveError = null;
+
+ try
+ {
+ var response = await LookupService.UpdateCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, editorModel.ToRequest());
+ if (response is null)
+ {
+ editorSaveError = "The selected cell could not be saved.";
+ return;
+ }
+
+ await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true);
+ await CloseCellEditorAsync();
+
+ if (response.IsCurated)
+ {
+ var (nextDetail, nextItem) = await ResolveNextQueueItemAsync();
+ await ApplyResolvedQueueItemAsync(nextDetail, nextItem, "The next cell could not be loaded for curation.");
+ }
+ else
+ {
+ var (detail, item) = await ResolveCurrentQueueItemAsync();
+ await ApplyResolvedQueueItemAsync(detail, item, "The edited cell could not be reloaded for curation.");
+ }
+ }
+ catch (Exception exception)
+ {
+ editorSaveError = exception.Message;
+ }
+ finally
+ {
+ isEditorSaving = false;
+ }
+ }
+
+ private async Task PersistAndSyncCurationContextAsync()
+ {
+ var snapshot = BuildCurrentCurationContext();
+ await TableContextState.PersistAsync(ContextDestination, snapshot);
+
+ var targetUri = TableContextState.BuildUri("/curation", snapshot);
+ if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ NavigationManager.NavigateTo(targetUri, replace: true);
+ }
+
+ private TableContextSnapshot BuildCurrentCurationContext() =>
+ new(TableSlug: string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal) ? selectedTableSlug : currentQueueItem?.TableSlug, GroupKey: currentQueueItem?.GroupKey, ColumnKey: currentQueueItem?.ColumnKey, RollBand: currentQueueItem?.RollBand, ResultId: currentQueueItem?.ResultId, Mode: TableContextMode.Curation, QueueScope: selectedQueueScope);
+
+ private string BuildTablesUri()
+ {
+ var snapshot = new TableContextSnapshot(TableSlug: currentQueueItem?.TableSlug ?? selectedTableSlug, GroupKey: currentQueueItem?.GroupKey, ColumnKey: currentQueueItem?.ColumnKey, RollBand: currentQueueItem?.RollBand, ResultId: currentQueueItem?.ResultId, Mode: TableContextMode.Reference);
+
+ return TableContextState.BuildUri("/tables", snapshot);
+ }
+
+ private string? BuildDiagnosticsUri()
+ {
+ if (currentQueueItem is null)
+ {
+ return null;
+ }
+
+ var snapshot = new TableContextSnapshot(TableSlug: currentQueueItem.TableSlug, GroupKey: currentQueueItem.GroupKey, ColumnKey: currentQueueItem.ColumnKey, RollBand: currentQueueItem.RollBand, ResultId: currentQueueItem.ResultId, Mode: TableContextMode.Diagnostics);
+
+ return TableContextState.BuildUri("/tools/diagnostics", snapshot);
+ }
+
+ private IReadOnlyList BuildScopeItems() =>
+ [
+ new(CurationQueueScopes.AllTables, "All tables"),
+ new(CurationQueueScopes.SelectedTable, "Selected table"),
+ new(CurationQueueScopes.PinnedSet, "Pinned set", PinnedTablesState.Items.Count == 0 ? null : PinnedTablesState.Items.Count.ToString())
+ ];
+
+ private string BuildEmptyStateTitle() =>
+ selectedQueueScope switch
+ {
+ CurationQueueScopes.SelectedTable => "This table has no remaining queue items",
+ CurationQueueScopes.PinnedSet => "Pinned queue is empty",
+ _ => "Curation queue complete"
+ };
+
+ private string BuildEmptyStateMessage() =>
+ selectedQueueScope switch
+ {
+ CurationQueueScopes.SelectedTable => "Choose another table scope or return to reference mode to inspect already curated entries.",
+ CurationQueueScopes.PinnedSet when PinnedTablesState.Items.Count == 0 => "Pin one or more tables first, then return to the pinned queue lane.",
+ CurationQueueScopes.PinnedSet => "Every uncurated result in the pinned set has already been reviewed.",
+ _ => "No uncurated results remain in the current queue scope."
+ };
+
+}
\ No newline at end of file
diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor
index 98673ce..3a4bc78 100644
--- a/src/RolemasterDb.App/Components/Pages/Tables.razor
+++ b/src/RolemasterDb.App/Components/Pages/Tables.razor
@@ -2,6 +2,7 @@
@rendermode InteractiveServer
@using System
@using System.Linq
+@using RolemasterDb.App.Frontend.AppState
@inject NavigationManager NavigationManager
@inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState
@@ -87,26 +88,6 @@
}
-@if (isCurationOpen)
-{
-
-}
-
@if (isEditorOpen)
{
GetGroupSortOrder(cell.GroupKey)).ThenBy(cell => GetColumnSortOrder(cell.ColumnKey)).ThenBy(cell => GetRollBandSortOrder(cell.RollBand)).ToList();
-
- var currentIndex = orderedCells.FindIndex(cell => cell.ResultId == currentResultId);
- for (var index = currentIndex + 1; index < orderedCells.Count; index++)
- {
- if (!orderedCells[index].IsCurated)
- {
- return orderedCells[index].ResultId;
- }
- }
-
- return null;
- }
-
- private int GetGroupSortOrder(string? groupKey)
- {
- if (tableDetail is null || string.IsNullOrWhiteSpace(groupKey))
- {
- return 0;
- }
-
- return tableDetail.Groups.FirstOrDefault(group => string.Equals(group.Key, groupKey, StringComparison.OrdinalIgnoreCase))?.SortOrder ?? int.MaxValue;
- }
-
- private int GetColumnSortOrder(string columnKey)
- {
- if (tableDetail is null)
- {
- return int.MaxValue;
- }
-
- return tableDetail.Columns.FirstOrDefault(column => string.Equals(column.Key, columnKey, StringComparison.OrdinalIgnoreCase))?.SortOrder ?? int.MaxValue;
- }
-
- private int GetRollBandSortOrder(string rollBandLabel)
- {
- if (tableDetail is null)
- {
- return int.MaxValue;
- }
-
- return tableDetail.RollBands.FirstOrDefault(rollBand => string.Equals(rollBand.Label, rollBandLabel, StringComparison.OrdinalIgnoreCase))?.SortOrder ?? int.MaxValue;
- }
-
private async Task CloseCellEditorAsync()
{
isEditorOpen = false;
@@ -639,8 +389,18 @@
private Task OpenSelectedCellEditorAsync() =>
selectedCell is null ? Task.CompletedTask : OpenCellEditorAsync(selectedCell.ResultId);
- private Task OpenSelectedCellCurationAsync() =>
- selectedCell is null ? Task.CompletedTask : OpenCellCurationAsync(selectedCell.ResultId);
+ private async Task OpenSelectedCellCurationAsync()
+ {
+ if (SelectedCellDetail is not { } cellDetail || string.IsNullOrWhiteSpace(selectedTableSlug))
+ {
+ return;
+ }
+
+ var snapshot = new TableContextSnapshot(TableSlug: selectedTableSlug, GroupKey: cellDetail.GroupKey, ColumnKey: cellDetail.ColumnKey, RollBand: cellDetail.RollBand, ResultId: cellDetail.ResultId, Mode: TableContextMode.Curation, QueueScope: CurationQueueScopes.SelectedTable);
+
+ await TableContextState.PersistAsync("curation", snapshot);
+ NavigationManager.NavigateTo(TableContextState.BuildUri("/curation", snapshot));
+ }
private Task ToggleLegend()
{
diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor
index cc8a42f..39dc8ec 100644
--- a/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor
+++ b/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor
@@ -1,7 +1,5 @@
-@using System.Collections.Generic
-@using System.Linq
-@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
+@using RolemasterDb.App.Components.Curation
@using RolemasterDb.App.Features
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
@@ -15,8 +13,8 @@
@onpointerup="HandleDialogPointerUp"
@onpointercancel="HandleDialogPointerCancel"
@onpointerdown:stopPropagation="true"
- @onpointerup:stopPropagation="true"
- @onpointercancel:stopPropagation="true">
+ @onpointerup:stopPropagation="true"
+ @onpointercancel:stopPropagation="true">