diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 7774256..cfdd4d1 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -51,6 +51,7 @@ It is intentionally implementation-focused: | 2026-03-21 | Post-P1 fix 3 | Completed | Restored layout-level shell interactivity by rendering routed content in `InteractiveServer` mode, which re-enabled shell event handlers such as the hamburger menu and theme selector. | | 2026-03-21 | Post-P1 fix 4 | Completed | Added early theme bootstrapping in `App.razor` and `theme.js` so the stored mode is applied before hydration and remains visible after refresh. | | 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. | ### Lessons Learned @@ -71,6 +72,7 @@ It is intentionally implementation-focused: - In Blazor Web Apps, page-level render modes do not automatically make layout-level controls interactive in the way this shell expects. The routed shell itself needs an interactive render boundary. - Persisted theme state should be applied before Blazor hydrates, not only after layout initialization. Otherwise refresh can look broken even when storage writes succeed. - 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. ## Target Outcomes @@ -359,7 +361,7 @@ Establish the shared shell, tokens, typography, and theme system that every dest | Task | Status | Notes | | --- | --- | --- | | `P2.1` | Completed | Canonical `Tools` child routes now exist, backed by dedicated route pages and shared tooling content components. | -| `P2.2` | Pending | Old `/diagnostics` and `/api` routes still need to become compatibility forwards. | +| `P2.2` | Completed | Old `/diagnostics` and `/api` routes now forward into the canonical `Tools` routes with shared redirect behavior. | | `P2.3` | Pending | Recent tables state has not been introduced yet. | | `P2.4` | Pending | Pinned tables state has not been introduced yet. | | `P2.5` | Pending | Table-context URL parsing and serialization still needs a shared model. | diff --git a/src/RolemasterDb.App/Components/Pages/Api.razor b/src/RolemasterDb.App/Components/Pages/Api.razor index 3126bcb..61ab01d 100644 --- a/src/RolemasterDb.App/Components/Pages/Api.razor +++ b/src/RolemasterDb.App/Components/Pages/Api.razor @@ -2,146 +2,4 @@ API Surface -
-
-

Reference data

-

GET /api/reference-data

-
{
-  "attackTables": [
-    {
-      "key": "broadsword",
-      "label": "Broadsword",
-      "attackKind": "melee",
-      "fumbleMinRoll": 1,
-      "fumbleMaxRoll": 2
-    }
-  ],
-  "criticalTables": [
-    {
-      "key": "mana",
-      "label": "Mana Critical Strike Table",
-      "family": "standard",
-      "sourceDocument": "Mana.pdf",
-      "notes": "Imported from PDF XML extraction."
-    }
-  ]
-}
-
- -
-

Attack lookup

-

POST /api/lookup/attack

-
{
-  "attackTable": "broadsword",
-  "armorType": "AT10",
-  "roll": 111,
-  "criticalRoll": 72
-}
-
- -
-

Critical lookup

-

POST /api/lookup/critical

-
{
-  "criticalType": "mana",
-  "column": "E",
-  "roll": 100,
-  "group": null
-}
-

Response now includes table metadata, roll-band bounds, raw imported cell text, parse status, and parsed JSON alongside the gameplay description.

-
- -
-

Cell editor load

-

GET /api/tables/critical/{slug}/cells/{resultId}

-
{
-  "resultId": 412,
-  "tableSlug": "slash",
-  "tableName": "Slash Critical Strike Table",
-  "rollBand": "66-70",
-  "groupKey": null,
-  "columnKey": "C",
-  "isCurated": false,
-  "sourcePageNumber": 1,
-  "sourceImageUrl": "/api/tables/critical/slash/cells/412/source-image",
-  "rawCellText": "Original imported full cell text",
-  "descriptionText": "Current curated prose",
-  "rawAffixText": "+8H - 2S",
-  "parseStatus": "verified",
-  "parsedJson": "{\"version\":1,\"isDescriptionOverridden\":false,\"isRawAffixTextOverridden\":false,\"areEffectsOverridden\":false,\"areBranchesOverridden\":false,\"effects\":[],\"branches\":[]}",
-  "isDescriptionOverridden": false,
-  "isRawAffixTextOverridden": false,
-  "areEffectsOverridden": false,
-  "areBranchesOverridden": false,
-  "validationMessages": [],
-  "effects": [],
-  "branches": []
-}
-

Use this to retrieve the full editable result graph for one critical-table cell, including nested branches, normalized effects, and review notes for unresolved quick-parse tokens.

-
- -
-

Cell source image

-

GET /api/tables/critical/{slug}/cells/{resultId}/source-image

-

Streams the importer-generated PNG crop for the current critical cell. Returns 404 when the row has no stored crop or the artifact is missing.

-
- -
-

Cell re-parse

-

POST /api/tables/critical/{slug}/cells/{resultId}/reparse

-
{
-  "currentState": {
-    "rawCellText": "Strike to thigh. +8H\nWith greaves: blow glances aside.",
-    "descriptionText": "Curated prose",
-    "rawAffixText": "+8H",
-    "parseStatus": "partial",
-    "parsedJson": "{}",
-    "isCurated": false,
-    "isDescriptionOverridden": true,
-    "isRawAffixTextOverridden": false,
-    "areEffectsOverridden": false,
-    "areBranchesOverridden": false,
-    "effects": [],
-    "branches": []
-  }
-}
-

Re-runs the shared single-cell parser, merges the generated result with the current override state, and returns the refreshed editor payload without saving changes. Unknown or partially parsed tokens are surfaced explicitly in the returned review data.

-
- -
-

Cell editor save

-

PUT /api/tables/critical/{slug}/cells/{resultId}

-
{
-  "rawCellText": "Corrected imported text",
-    "descriptionText": "Rewritten prose after manual review",
-  "rawAffixText": "+10H - must parry 2 rnds",
-  "parseStatus": "manually_curated",
-  "parsedJson": "{\"reviewed\":true}",
-  "isCurated": true,
-  "isDescriptionOverridden": true,
-  "isRawAffixTextOverridden": false,
-  "areEffectsOverridden": false,
-  "areBranchesOverridden": false,
-  "effects": [
-    {
-      "effectCode": "direct_hits",
-      "target": null,
-      "valueInteger": 10,
-      "valueDecimal": null,
-      "valueExpression": null,
-      "durationRounds": null,
-      "perRound": null,
-      "modifier": null,
-      "bodyPart": null,
-      "isPermanent": false,
-      "sourceType": "symbol",
-      "sourceText": "+10H",
-      "originKey": "base:direct_hits:1",
-      "isOverridden": true
-    }
-  ],
-  "branches": []
-}
-

The save endpoint replaces the stored base result, branch rows, and effect rows for that cell with the submitted curated payload.

-
-
+ diff --git a/src/RolemasterDb.App/Components/Pages/Diagnostics.razor b/src/RolemasterDb.App/Components/Pages/Diagnostics.razor index ec7852b..0dfe1df 100644 --- a/src/RolemasterDb.App/Components/Pages/Diagnostics.razor +++ b/src/RolemasterDb.App/Components/Pages/Diagnostics.razor @@ -1,332 +1,5 @@ @page "/diagnostics" -@rendermode InteractiveServer -@using System -@using System.Collections.Generic -@using System.Linq -@inject LookupService LookupService Diagnostics -
-
-
-

Critical Cell Diagnostics

-

Engineering-only parser metadata, provenance, and payload inspection live here instead of inside the normal curation modal.

-
-
- - @if (referenceData is null) - { -

Loading table list...

- } - else if (!referenceData.CriticalTables.Any()) - { -

No critical tables are available yet.

- } - else - { -
-
- - -
- -
- - -
- - @if (tableDetail is { Groups.Count: > 0 }) - { -
- - -
- } - -
- - -
-
- - @if (!string.IsNullOrWhiteSpace(detailError)) - { -

@detailError

- } - else if (tableDetail is null) - { -

The selected table could not be loaded.

- } - else if (!tableDetail.Cells.Any()) - { -

The selected table has no filled cells to inspect.

- } - else if (selectedCell is null) - { -
-
-
- No Filled Cell At This Position -

Pick another roll band, variant, or severity to inspect a stored result.

-
-
-
- } - else - { -
- Inspecting - @tableDetail.DisplayName - · Roll band @selectedCell.RollBand - · Severity @selectedCell.ColumnLabel - @if (!string.IsNullOrWhiteSpace(selectedCell.GroupLabel)) - { - · Variant @selectedCell.GroupLabel - } - · Result ID @selectedCell.ResultId -
- - @if (isDiagnosticsLoading) - { -

Loading diagnostics...

- } - else if (!string.IsNullOrWhiteSpace(diagnosticsError)) - { -

@diagnosticsError

- } - else if (diagnosticsModel is not null) - { - - } - } - } -
- -@code { - private LookupReferenceData? referenceData; - private CriticalTableDetail? tableDetail; - private CriticalTableCellDetail? selectedCell; - private CriticalCellEditorModel? diagnosticsModel; - private string selectedTableSlug = string.Empty; - private string selectedRollBand = string.Empty; - private string selectedColumnKey = string.Empty; - private string? selectedGroupKey; - private bool isDetailLoading; - private bool isDiagnosticsLoading; - private string? detailError; - private string? diagnosticsError; - - private bool isBusy => isDetailLoading || isDiagnosticsLoading; - - protected override async Task OnInitializedAsync() - { - referenceData = await LookupService.GetReferenceDataAsync(); - selectedTableSlug = referenceData.CriticalTables.FirstOrDefault()?.Key ?? string.Empty; - await LoadTableDetailAsync(); - } - - private async Task HandleTableChanged(ChangeEventArgs args) - { - selectedTableSlug = args.Value?.ToString() ?? string.Empty; - await LoadTableDetailAsync(); - } - - private async Task HandleRollBandChanged(ChangeEventArgs args) - { - selectedRollBand = args.Value?.ToString() ?? string.Empty; - ResolveSelectedCell(); - await LoadSelectedCellDiagnosticsAsync(); - } - - private async Task HandleGroupChanged(ChangeEventArgs args) - { - selectedGroupKey = NormalizeOptionalText(args.Value?.ToString()); - ResolveSelectedCell(); - await LoadSelectedCellDiagnosticsAsync(); - } - - private async Task HandleColumnChanged(ChangeEventArgs args) - { - selectedColumnKey = args.Value?.ToString() ?? string.Empty; - ResolveSelectedCell(); - await LoadSelectedCellDiagnosticsAsync(); - } - - private async Task LoadTableDetailAsync() - { - if (string.IsNullOrWhiteSpace(selectedTableSlug)) - { - tableDetail = null; - selectedCell = null; - diagnosticsModel = null; - return; - } - - isDetailLoading = true; - detailError = null; - diagnosticsError = null; - diagnosticsModel = null; - selectedCell = null; - - try - { - tableDetail = await LookupService.GetCriticalTableAsync(selectedTableSlug); - if (tableDetail is null) - { - detailError = "The selected table could not be loaded."; - return; - } - - SetDefaultSelection(tableDetail); - ResolveSelectedCell(); - await LoadSelectedCellDiagnosticsAsync(); - } - catch (Exception exception) - { - detailError = exception.Message; - tableDetail = null; - selectedCell = null; - diagnosticsModel = null; - } - finally - { - isDetailLoading = false; - } - } - - private void SetDefaultSelection(CriticalTableDetail detail) - { - if (!detail.Cells.Any()) - { - selectedRollBand = detail.RollBands.FirstOrDefault()?.Label ?? string.Empty; - selectedColumnKey = detail.Columns.FirstOrDefault()?.Key ?? string.Empty; - selectedGroupKey = detail.Groups.FirstOrDefault()?.Key; - return; - } - - var rollOrder = detail.RollBands - .Select((rollBand, index) => new { rollBand.Label, index }) - .ToDictionary(item => item.Label, item => item.index, StringComparer.Ordinal); - var columnOrder = detail.Columns - .Select((column, index) => new { column.Key, index }) - .ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal); - var groupOrder = detail.Groups - .Select((group, index) => new { group.Key, index }) - .ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal); - - var firstCell = detail.Cells - .OrderBy(cell => rollOrder.GetValueOrDefault(cell.RollBand, int.MaxValue)) - .ThenBy(cell => - { - if (cell.GroupKey is null) - { - return -1; - } - - return groupOrder.GetValueOrDefault(cell.GroupKey, int.MaxValue); - }) - .ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)) - .First(); - - selectedRollBand = firstCell.RollBand; - selectedColumnKey = firstCell.ColumnKey; - selectedGroupKey = firstCell.GroupKey; - } - - private void ResolveSelectedCell() - { - if (tableDetail is null) - { - selectedCell = null; - return; - } - - selectedCell = tableDetail.Cells.FirstOrDefault(cell => - string.Equals(cell.RollBand, selectedRollBand, StringComparison.Ordinal) && - string.Equals(cell.ColumnKey, selectedColumnKey, StringComparison.Ordinal) && - string.Equals(cell.GroupKey ?? string.Empty, selectedGroupKey ?? string.Empty, StringComparison.Ordinal)); - } - - private async Task LoadSelectedCellDiagnosticsAsync() - { - diagnosticsError = null; - diagnosticsModel = null; - - if (selectedCell is null || string.IsNullOrWhiteSpace(selectedTableSlug)) - { - return; - } - - isDiagnosticsLoading = true; - - try - { - var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, selectedCell.ResultId); - if (response is null) - { - diagnosticsError = "The selected cell could not be loaded."; - return; - } - - diagnosticsModel = CriticalCellEditorModel.FromResponse(response); - } - catch (Exception exception) - { - diagnosticsError = exception.Message; - } - finally - { - isDiagnosticsLoading = false; - } - } - - private static string? NormalizeOptionalText(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value.Trim(); -} + diff --git a/src/RolemasterDb.App/Components/Shared/CompatibilityRouteRedirect.razor b/src/RolemasterDb.App/Components/Shared/CompatibilityRouteRedirect.razor new file mode 100644 index 0000000..c6dc77c --- /dev/null +++ b/src/RolemasterDb.App/Components/Shared/CompatibilityRouteRedirect.razor @@ -0,0 +1,30 @@ +@inject NavigationManager NavigationManager + +
+

Redirecting…

+

This route moved to @TargetPath. If the redirect does not complete automatically, use the link below.

+
+ Continue +
+
+ +@code { + [Parameter, EditorRequired] + public string TargetPath { get; set; } = string.Empty; + + protected override void OnAfterRender(bool firstRender) + { + if (!firstRender) + { + return; + } + + NavigationManager.NavigateTo(BuildTargetUri(), replace: true); + } + + private string BuildTargetUri() + { + var currentUri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + return string.Concat(TargetPath, currentUri.Query, currentUri.Fragment); + } +}