Consolidate tools and fix static assets

This commit is contained in:
2026-04-12 01:18:26 +02:00
parent 7843073d13
commit f625cdae0d
14 changed files with 639 additions and 221 deletions

View File

@@ -30,7 +30,7 @@ It is intentionally implementation-focused:
- Branch: `frontend/tables-overhaul`
- Last updated: `2026-04-12`
- Current focus: `Phase 5`
- Current focus: `Phase 6`
- Document mode: living plan and progress log
### Progress Log
@@ -77,6 +77,7 @@ It is intentionally implementation-focused:
| 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. |
| 2026-04-12 | Phase 5 | Completed | Consolidated the existing tooling routes into a coherent `Tools` workspace with a real hub, shared tooling page frame, preserved-context exits from diagnostics back into `Tables` and `Curation`, and a grouped API reference surface. |
### Lessons Learned
@@ -688,6 +689,22 @@ Create a dedicated queue-first curation workflow so repair work is fast and does
## Phase 5: `Tools` Consolidation
### Status
`Completed`
### Task Progress
| Task | Status | Notes |
| --- | --- | --- |
| `P5.1` | Completed | The thin `Tools` panel was replaced with a real hub page presenting diagnostics and API docs as destination cards. |
| `P5.2` | Completed | Diagnostics remained on the canonical `/tools/diagnostics` route established earlier. |
| `P5.3` | Completed | API docs remained on the canonical `/tools/api` route established earlier. |
| `P5.4` | Completed | Tooling pages now reuse a shared tooling frame and the same table-context URI patterns already used by `Tables`, `Curation`, and diagnostics. |
| `P5.5` | Completed | Diagnostics now exposes preserved-context links back into `Tables` and `Curation`, plus a stable return path to the `Tools` hub. |
| `P5.6` | Completed | Deep inspection remains isolated to tooling surfaces; browse and curation flows still link outward instead of embedding engineering detail inline. |
| `P5.7` | Completed | The tooling pages now share a stronger cool-slate documentation/workbench treatment instead of ad hoc panel stacks. |
### Goal
Separate diagnostic and developer tooling from player-facing flows without losing deep-link usefulness.

View File

@@ -2,28 +2,27 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["RolemasterDb.App.styles.css"]" />
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<ResourcePreloader/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"/>
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]"/>
<link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["RolemasterDb.App.styles.css"]"/>
<script src="@Assets["theme.js"]"></script>
<script>window.rolemasterTheme?.init("rolemaster.theme.mode");</script>
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
<ImportMap/>
<link rel="icon" type="image/png" href="favicon.png"/>
<HeadOutlet/>
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["tables.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
<Routes @rendermode="InteractiveServer"/>
<ReconnectModal/>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<script type="module" src="@Assets["components/layout/reconnect-modal.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">

View File

@@ -2,12 +2,39 @@
<PageTitle>Tools</PageTitle>
<section class="panel tooling-surface">
<h1 class="panel-title">Tools</h1>
<p class="panel-copy">Diagnostics and API documentation now live under the `Tools` destination so engineering workflows stay reachable without polluting player-facing navigation.</p>
<ToolPageFrame
Eyebrow="Developer workflows"
Title="Tools"
Summary="Inspection, parser review, and API surface reference stay isolated here from play and table-reading flows.">
<ChildContent>
<div class="tools-hub-grid">
<ToolLinkCard
Title="Diagnostics"
Badge="Context-aware"
Summary="Inspect one stored critical-table cell with parser provenance, payload state, and engineering diagnostics."
Href="/tools/diagnostics"
ActionLabel="Open diagnostics">
<Details>
<div class="tool-link-card-meta">
<span>Table, roll band, variant, severity</span>
<span>Jump back to Tables or Curation</span>
</div>
</Details>
</ToolLinkCard>
<div class="action-row">
<NavLink class="btn-link" href="/tools/diagnostics">Open diagnostics</NavLink>
<NavLink class="btn-link" href="/tools/api">Open API docs</NavLink>
</div>
</section>
<ToolLinkCard
Title="API Surface"
Badge="Reference"
Summary="Browse the core lookup and critical-cell editing endpoints without leaving the app shell."
Href="/tools/api"
ActionLabel="Open API docs">
<Details>
<div class="tool-link-card-meta">
<span>Reference data and lookup contracts</span>
<span>Load, re-parse, source-image, and save endpoints</span>
</div>
</Details>
</ToolLinkCard>
</div>
</ChildContent>
</ToolPageFrame>

View File

@@ -103,7 +103,7 @@
{
if (firstRender)
{
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./Components/Shared/CriticalCellEditorDialog.razor.js");
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./components/shared/critical-cell-editor-dialog.js");
await jsModule.InvokeVoidAsync("lockBackgroundScroll");
}

View File

@@ -382,7 +382,7 @@
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import",
"./Components/Shared/CriticalCellEditorDialog.razor.js");
"./components/shared/critical-cell-editor-dialog.js");
await jsModule.InvokeVoidAsync("lockBackgroundScroll");
}

View File

@@ -1,8 +1,28 @@
<div class="api-grid">
<section class="panel tooling-surface">
<h2 class="panel-title">Reference data</h2>
<p class="panel-copy"><code>GET /api/reference-data</code></p>
<pre class="code-block">{
<ToolPageFrame
Eyebrow="API reference"
Title="API Surface"
Summary="The stable lookup and critical-cell editing contracts are grouped here by workflow so diagnostics and curation work can move quickly without leaving the app shell.">
<Actions>
<a class="btn btn-link" href="/tools">All tools</a>
<a class="btn btn-secondary" href="/tools/diagnostics">Open diagnostics</a>
</Actions>
<ChildContent>
<div class="api-reference-groups">
<section class="api-reference-group">
<div class="api-reference-group-header">
<span class="tool-page-eyebrow">Read models</span>
<h2>Reference and lookup payloads</h2>
</div>
<div class="api-grid">
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Reference data</h3>
<code>GET /api/reference-data</code>
</div>
<div class="tool-link-card-summary">Bootstraps attack tables, critical tables, labels, and high-level metadata for the main client flows.</div>
<pre class="code-block">{
"attackTables": [
{
"key": "broadsword",
@@ -22,35 +42,52 @@
}
]
}</pre>
</section>
</section>
<section class="panel tooling-surface">
<h2 class="panel-title">Attack lookup</h2>
<p class="panel-copy"><code>POST /api/lookup/attack</code></p>
<pre class="code-block">{
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Attack lookup</h3>
<code>POST /api/lookup/attack</code>
</div>
<div class="tool-link-card-summary">Resolves attack-table outcomes for the player-facing `Play` surface.</div>
<pre class="code-block">{
"attackTable": "broadsword",
"armorType": "AT10",
"roll": 111,
"criticalRoll": 72
}</pre>
</section>
</section>
<section class="panel tooling-surface">
<h2 class="panel-title">Critical lookup</h2>
<p class="panel-copy"><code>POST /api/lookup/critical</code></p>
<pre class="code-block">{
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Critical lookup</h3>
<code>POST /api/lookup/critical</code>
</div>
<div class="tool-link-card-summary">Returns resolved critical outcomes plus the imported table metadata needed to inspect the underlying source row.</div>
<pre class="code-block">{
"criticalType": "mana",
"column": "E",
"roll": 100,
"group": null
}</pre>
<p class="panel-copy">Response now includes table metadata, roll-band bounds, raw imported cell text, parse status, and parsed JSON alongside the gameplay description.</p>
</section>
</section>
</div>
</section>
<section class="panel tooling-surface">
<h2 class="panel-title">Cell editor load</h2>
<p class="panel-copy"><code>GET /api/tables/critical/{slug}/cells/{resultId}</code></p>
<pre class="code-block">{
<section class="api-reference-group">
<div class="api-reference-group-header">
<span class="tool-page-eyebrow">Curation contracts</span>
<h2>Critical-cell editing workflow</h2>
</div>
<div class="api-grid">
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Cell editor load</h3>
<code>GET /api/tables/critical/{slug}/cells/{resultId}</code>
</div>
<div class="tool-link-card-summary">Loads the full editable graph for one stored critical-table cell, including review notes and nested branches.</div>
<pre class="code-block">{
"resultId": 412,
"tableSlug": "slash",
"tableName": "Slash Critical Strike Table",
@@ -73,19 +110,23 @@
"effects": [],
"branches": []
}</pre>
<p class="panel-copy">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.</p>
</section>
</section>
<section class="panel tooling-surface">
<h2 class="panel-title">Cell source image</h2>
<p class="panel-copy"><code>GET /api/tables/critical/{slug}/cells/{resultId}/source-image</code></p>
<p class="panel-copy">Streams the importer-generated PNG crop for the current critical cell. Returns <code>404</code> when the row has no stored crop or the artifact is missing.</p>
</section>
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Cell source image</h3>
<code>GET /api/tables/critical/{slug}/cells/{resultId}/source-image</code>
</div>
<div class="tool-link-card-summary">Streams the importer-generated PNG crop for the current critical cell and returns `404` when no stored artifact is available.</div>
</section>
<section class="panel tooling-surface">
<h2 class="panel-title">Cell re-parse</h2>
<p class="panel-copy"><code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code></p>
<pre class="code-block">{
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Cell re-parse</h3>
<code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code>
</div>
<div class="tool-link-card-summary">Re-runs the single-cell parser and merges generated output with the current override state without persisting changes.</div>
<pre class="code-block">{
"currentState": {
"rawCellText": "Strike to thigh. +8H\nWith greaves: blow glances aside.",
"descriptionText": "Curated prose",
@@ -101,15 +142,17 @@
"branches": []
}
}</pre>
<p class="panel-copy">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.</p>
</section>
</section>
<section class="panel tooling-surface">
<h2 class="panel-title">Cell editor save</h2>
<p class="panel-copy"><code>PUT /api/tables/critical/{slug}/cells/{resultId}</code></p>
<pre class="code-block">{
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Cell editor save</h3>
<code>PUT /api/tables/critical/{slug}/cells/{resultId}</code>
</div>
<div class="tool-link-card-summary">Replaces the stored base result, branch rows, and effect rows for the targeted cell with the submitted curated payload.</div>
<pre class="code-block">{
"rawCellText": "Corrected imported text",
"descriptionText": "Rewritten prose after manual review",
"descriptionText": "Rewritten prose after manual review",
"rawAffixText": "+10H - must parry 2 rnds",
"parseStatus": "manually_curated",
"parsedJson": "{\"reviewed\":true}",
@@ -138,6 +181,9 @@
],
"branches": []
}</pre>
<p class="panel-copy">The save endpoint replaces the stored base result, branch rows, and effect rows for that cell with the submitted curated payload.</p>
</section>
</div>
</section>
</div>
</section>
</div>
</ChildContent>
</ToolPageFrame>

View File

@@ -5,151 +5,163 @@
@inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState
<section class="panel diagnostics-page tooling-surface">
<header class="diagnostics-page-header">
<div>
<h2 class="panel-title">Critical Cell Diagnostics</h2>
<p class="panel-copy">Engineering-only parser metadata, provenance, and payload inspection live here instead of inside the normal curation modal.</p>
</div>
</header>
<ToolPageFrame
Eyebrow="Inspection workspace"
Title="Critical Cell Diagnostics"
Summary="Parser provenance, payload state, and engineering-only review stay here instead of leaking into browse or curation workflows.">
<Actions>
<a class="btn btn-link" href="/tools">All tools</a>
@if (BuildTablesUri() is { } tablesUri)
{
<a class="btn btn-secondary" href="@tablesUri">Open tables</a>
}
@if (BuildCurationUri() is { } curationUri)
{
<a class="btn btn-secondary" href="@curationUri">Open curation</a>
}
</Actions>
@if (referenceData is null)
{
<p class="muted">Loading table list...</p>
}
else if (!referenceData.CriticalTables.Any())
{
<p class="muted">No critical tables are available yet.</p>
}
else if (!hasInitializedContext)
{
<p class="muted">Restoring diagnostic context...</p>
}
else
{
<div class="diagnostics-selector-grid">
<div class="field-shell">
<label for="diagnostics-table-select">Table</label>
<select
id="diagnostics-table-select"
class="input-shell"
value="@selectedTableSlug"
@onchange="HandleTableChanged"
disabled="@isBusy">
@foreach (var table in referenceData.CriticalTables)
{
<option value="@table.Key">@table.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="diagnostics-roll-band-select">Roll Band</label>
<select
id="diagnostics-roll-band-select"
class="input-shell"
value="@selectedRollBand"
@onchange="HandleRollBandChanged"
disabled="@(isBusy || tableDetail is null || !tableDetail.RollBands.Any())">
@if (tableDetail is not null)
{
@foreach (var rollBand in tableDetail.RollBands)
{
<option value="@rollBand.Label">@rollBand.Label</option>
}
}
</select>
</div>
@if (tableDetail is { Groups.Count: > 0 })
<ChildContent>
<div class="diagnostics-page">
@if (referenceData is null)
{
<div class="field-shell">
<label for="diagnostics-group-select">Variant</label>
<select
id="diagnostics-group-select"
class="input-shell"
value="@selectedGroupKey"
@onchange="HandleGroupChanged"
disabled="@isBusy">
@foreach (var group in tableDetail.Groups)
{
<option value="@group.Key">@group.Label</option>
}
</select>
</div>
<p class="muted">Loading table list...</p>
}
else if (!referenceData.CriticalTables.Any())
{
<p class="muted">No critical tables are available yet.</p>
}
else if (!hasInitializedContext)
{
<p class="muted">Restoring diagnostic context...</p>
}
else
{
<div class="diagnostics-selector-grid">
<div class="field-shell">
<label for="diagnostics-table-select">Table</label>
<select
id="diagnostics-table-select"
class="input-shell"
value="@selectedTableSlug"
@onchange="HandleTableChanged"
disabled="@isBusy">
@foreach (var table in referenceData.CriticalTables)
{
<option value="@table.Key">@table.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="diagnostics-column-select">Severity</label>
<select
id="diagnostics-column-select"
class="input-shell"
value="@selectedColumnKey"
@onchange="HandleColumnChanged"
disabled="@(isBusy || tableDetail is null || !tableDetail.Columns.Any())">
@if (tableDetail is not null)
<div class="field-shell">
<label for="diagnostics-roll-band-select">Roll Band</label>
<select
id="diagnostics-roll-band-select"
class="input-shell"
value="@selectedRollBand"
@onchange="HandleRollBandChanged"
disabled="@(isBusy || tableDetail is null || !tableDetail.RollBands.Any())">
@if (tableDetail is not null)
{
@foreach (var rollBand in tableDetail.RollBands)
{
<option value="@rollBand.Label">@rollBand.Label</option>
}
}
</select>
</div>
@if (tableDetail is { Groups.Count: > 0 })
{
@foreach (var column in tableDetail.Columns)
{
<option value="@column.Key">@column.Label</option>
}
<div class="field-shell">
<label for="diagnostics-group-select">Variant</label>
<select
id="diagnostics-group-select"
class="input-shell"
value="@selectedGroupKey"
@onchange="HandleGroupChanged"
disabled="@isBusy">
@foreach (var group in tableDetail.Groups)
{
<option value="@group.Key">@group.Label</option>
}
</select>
</div>
}
</select>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(detailError))
{
<p class="error-text">@detailError</p>
}
else if (tableDetail is null)
{
<p class="muted">The selected table could not be loaded.</p>
}
else if (!tableDetail.Cells.Any())
{
<p class="muted">The selected table has no filled cells to inspect.</p>
}
else if (selectedCell is null)
{
<div class="critical-editor-card nested">
<div class="critical-editor-card-header">
<div>
<strong>No Filled Cell At This Position</strong>
<p class="muted critical-editor-inline-copy">Pick another roll band, variant, or severity to inspect a stored result.</p>
<div class="field-shell">
<label for="diagnostics-column-select">Severity</label>
<select
id="diagnostics-column-select"
class="input-shell"
value="@selectedColumnKey"
@onchange="HandleColumnChanged"
disabled="@(isBusy || tableDetail is null || !tableDetail.Columns.Any())">
@if (tableDetail is not null)
{
@foreach (var column in tableDetail.Columns)
{
<option value="@column.Key">@column.Label</option>
}
}
</select>
</div>
</div>
</div>
}
else
{
<div class="diagnostics-selection-summary">
<strong>Inspecting</strong>
<span>@tableDetail.DisplayName</span>
<span>· Roll band <strong>@selectedCell.RollBand</strong></span>
<span>· Severity <strong>@selectedCell.ColumnLabel</strong></span>
@if (!string.IsNullOrWhiteSpace(selectedCell.GroupLabel))
{
<span>· Variant <strong>@selectedCell.GroupLabel</strong></span>
}
<span>· Result ID <strong>@selectedCell.ResultId</strong></span>
</div>
@if (isDiagnosticsLoading)
{
<p class="muted">Loading diagnostics...</p>
@if (!string.IsNullOrWhiteSpace(detailError))
{
<p class="error-text">@detailError</p>
}
else if (tableDetail is null)
{
<p class="muted">The selected table could not be loaded.</p>
}
else if (!tableDetail.Cells.Any())
{
<p class="muted">The selected table has no filled cells to inspect.</p>
}
else if (selectedCell is null)
{
<div class="critical-editor-card nested">
<div class="critical-editor-card-header">
<div>
<strong>No Filled Cell At This Position</strong>
<div class="muted critical-editor-inline-copy">Pick another roll band, variant, or severity to inspect a stored result.</div>
</div>
</div>
</div>
}
else
{
<div class="diagnostics-selection-summary">
<strong>Inspecting</strong>
<span>@tableDetail.DisplayName</span>
<span>· Roll band <strong>@selectedCell.RollBand</strong></span>
<span>· Severity <strong>@selectedCell.ColumnLabel</strong></span>
@if (!string.IsNullOrWhiteSpace(selectedCell.GroupLabel))
{
<span>· Variant <strong>@selectedCell.GroupLabel</strong></span>
}
<span>· Result ID <strong>@selectedCell.ResultId</strong></span>
</div>
@if (isDiagnosticsLoading)
{
<p class="muted">Loading diagnostics...</p>
}
else if (!string.IsNullOrWhiteSpace(diagnosticsError))
{
<p class="error-text">@diagnosticsError</p>
}
else if (diagnosticsModel is not null)
{
<CriticalCellEngineeringDiagnostics Model="diagnosticsModel" />
}
}
}
else if (!string.IsNullOrWhiteSpace(diagnosticsError))
{
<p class="error-text">@diagnosticsError</p>
}
else if (diagnosticsModel is not null)
{
<CriticalCellEngineeringDiagnostics Model="diagnosticsModel" />
}
}
}
</section>
</div>
</ChildContent>
</ToolPageFrame>
@code {
private LookupReferenceData? referenceData;
@@ -438,6 +450,43 @@
?? detail.RollBands.FirstOrDefault()?.Label
?? string.Empty;
private string? BuildTablesUri()
{
if (string.IsNullOrWhiteSpace(selectedTableSlug))
{
return null;
}
var snapshot = new RolemasterDb.App.Frontend.AppState.TableContextSnapshot(
TableSlug: selectedTableSlug,
GroupKey: selectedGroupKey,
ColumnKey: selectedColumnKey,
RollBand: selectedRollBand,
ResultId: selectedCell?.ResultId,
Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference);
return TableContextState.BuildUri("/tables", snapshot);
}
private string? BuildCurationUri()
{
if (selectedCell is null || string.IsNullOrWhiteSpace(selectedTableSlug))
{
return null;
}
var snapshot = new RolemasterDb.App.Frontend.AppState.TableContextSnapshot(
TableSlug: selectedTableSlug,
GroupKey: selectedGroupKey,
ColumnKey: selectedColumnKey,
RollBand: selectedRollBand,
ResultId: selectedCell.ResultId,
Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Curation,
QueueScope: RolemasterDb.App.Frontend.Curation.CurationQueueScopes.SelectedTable);
return TableContextState.BuildUri("/curation", snapshot);
}
private static string ResolveColumnKey(CriticalTableDetail detail, string? columnKey) =>
detail.Columns.FirstOrDefault(item => string.Equals(item.Key, columnKey, StringComparison.Ordinal))?.Key
?? detail.Columns.FirstOrDefault()?.Key

View File

@@ -0,0 +1,49 @@
<article class="tool-link-card">
<div class="tool-link-card-body">
<div class="tool-link-card-header">
<div class="tool-link-card-title-row">
<h2 class="tool-link-card-title">@Title</h2>
@if (!string.IsNullOrWhiteSpace(Badge))
{
<span class="chip">@Badge</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(Summary))
{
<div class="tool-link-card-summary">@Summary</div>
}
</div>
@if (Details is not null)
{
<div class="tool-link-card-details">
@Details
</div>
}
</div>
<div class="tool-link-card-actions">
<NavLink class="btn btn-secondary" href="@Href">@ActionLabel</NavLink>
</div>
</article>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
[Parameter]
public string? Summary { get; set; }
[Parameter]
public string? Badge { get; set; }
[Parameter, EditorRequired]
public string Href { get; set; } = string.Empty;
[Parameter]
public string ActionLabel { get; set; } = "Open";
[Parameter]
public RenderFragment? Details { get; set; }
}

View File

@@ -0,0 +1,45 @@
<section class="panel tooling-surface tool-page-frame">
<header class="tool-page-header">
<div class="tool-page-heading">
@if (!string.IsNullOrWhiteSpace(Eyebrow))
{
<span class="tool-page-eyebrow">@Eyebrow</span>
}
<h1 class="panel-title">@Title</h1>
@if (!string.IsNullOrWhiteSpace(Summary))
{
<div class="tool-page-summary">@Summary</div>
}
</div>
@if (Actions is not null)
{
<div class="tool-page-actions">
@Actions
</div>
}
</header>
<div class="tool-page-body">
@ChildContent
</div>
</section>
@code {
[Parameter]
public string? Eyebrow { get; set; }
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
[Parameter]
public string? Summary { get; set; }
[Parameter]
public RenderFragment? Actions { get; set; }
[Parameter, EditorRequired]
public RenderFragment ChildContent { get; set; } = default!;
}

View File

@@ -5,10 +5,10 @@ using RolemasterDb.App.Frontend.AppState;
using RolemasterDb.App.Features;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseStaticWebAssets();
var connectionString = builder.Configuration.GetConnectionString("RolemasterDb") ?? "Data Source=rolemaster.db";
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddDbContextFactory<RolemasterDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddSingleton<CriticalImportArtifactLocator>();
builder.Services.AddScoped<LookupService>();
@@ -27,13 +27,13 @@ if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseAntiforgery();
app.MapStaticAssets();
var api = app.MapGroup("/api");
api.MapGet("/reference-data", async (LookupService lookupService, CancellationToken cancellationToken) =>
Results.Ok(await lookupService.GetReferenceDataAsync(cancellationToken)));
api.MapGet("/reference-data", async (LookupService lookupService, CancellationToken cancellationToken) => Results.Ok(await lookupService.GetReferenceDataAsync(cancellationToken)));
api.MapPost("/lookup/attack", async (AttackLookupRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
{
var result = await lookupService.LookupAttackAsync(request, cancellationToken);
@@ -64,7 +64,6 @@ api.MapPut("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, i
var result = await lookupService.UpdateCriticalCellAsync(slug, resultId, request, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
});
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();

View File

@@ -297,6 +297,105 @@ pre,
color: color-mix(in srgb, var(--info-3) 46%, var(--text-primary));
}
.tool-page-frame,
.tool-page-body,
.tool-page-heading,
.tool-page-actions,
.tools-hub-grid,
.tool-link-card,
.tool-link-card-body,
.tool-link-card-header,
.tool-link-card-title-row,
.tool-link-card-meta,
.api-reference-groups,
.api-reference-group,
.api-reference-group-header,
.api-endpoint-card,
.api-endpoint-card-header {
display: grid;
gap: 1rem;
}
.tool-page-header {
display: flex;
gap: 1rem;
align-items: start;
justify-content: space-between;
flex-wrap: wrap;
}
.tool-page-eyebrow {
color: color-mix(in srgb, var(--info-3) 55%, var(--text-secondary));
font-family: var(--font-ui);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.tool-page-summary,
.tool-link-card-summary {
color: var(--text-secondary);
max-width: 62ch;
}
.tool-page-actions {
grid-auto-flow: column;
grid-auto-columns: max-content;
justify-content: end;
align-items: center;
}
.tools-hub-grid {
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
.tool-link-card {
padding: 1.1rem 1.15rem;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--info-2) 24%, var(--border-default));
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-2) 86%, var(--surface-tooling)), color-mix(in srgb, var(--surface-1) 92%, var(--surface-tooling)));
box-shadow: var(--shadow-1);
}
.tool-link-card-title {
margin: 0;
font-size: 1.15rem;
}
.tool-link-card-meta {
gap: 0.4rem;
color: var(--text-secondary);
}
.tool-link-card-actions {
display: flex;
justify-content: flex-start;
align-items: center;
}
.api-reference-group-header h2 {
margin: 0;
font-size: 1.2rem;
}
.api-endpoint-card {
align-content: start;
}
.api-endpoint-card-header {
gap: 0.45rem;
}
.api-endpoint-card-header .panel-title {
margin-bottom: 0;
}
.api-endpoint-card-header code {
color: color-mix(in srgb, var(--info-3) 55%, var(--text-primary));
font-size: 0.92rem;
}
.panel-title {
margin: 0 0 0.35rem;
font-size: 1.4rem;
@@ -1125,6 +1224,7 @@ select.input-shell {
.api-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
}
.blazor-error-boundary {
@@ -2366,13 +2466,6 @@ select.input-shell {
justify-items: start;
}
.diagnostics-page-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
}
.diagnostics-selector-grid {
display: grid;
gap: 0.85rem;
@@ -2602,6 +2695,11 @@ select.input-shell {
text-align: left;
}
.tool-page-actions {
grid-auto-flow: row;
justify-content: start;
}
.diagnostics-page-header,
.diagnostics-selection-summary,
.curation-queue-bar-header,

View File

@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View File

@@ -0,0 +1,26 @@
const scrollLockClassName = "critical-editor-scroll-locked";
let scrollLockCount = 0;
export function lockBackgroundScroll() {
scrollLockCount++;
if (scrollLockCount !== 1) {
return;
}
document.documentElement.classList.add(scrollLockClassName);
document.body.classList.add(scrollLockClassName);
}
export function unlockBackgroundScroll() {
if (scrollLockCount === 0) {
return;
}
scrollLockCount--;
if (scrollLockCount !== 0) {
return;
}
document.documentElement.classList.remove(scrollLockClassName);
document.body.classList.remove(scrollLockClassName);
}