Add manual critical table cell editor
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
Starter `.NET 10` Blazor Web App with:
|
Starter `.NET 10` Blazor Web App with:
|
||||||
|
|
||||||
- minimal API endpoints for attack and critical table lookup
|
- minimal API endpoints for attack and critical table lookup
|
||||||
|
- manual cell-edit tooling for repairing imported critical-table entries
|
||||||
- `EF Core 10` with SQLite
|
- `EF Core 10` with SQLite
|
||||||
- seeded starter data for `Broadsword`, `Short Bow`, `Slash`, and `Puncture`
|
- seeded starter data for `Broadsword`, `Short Bow`, `Slash`, and `Puncture`
|
||||||
- an interactive Blazor lookup UI
|
- an interactive Blazor lookup UI
|
||||||
@@ -20,7 +21,11 @@ The app creates `rolemaster.db` on first run.
|
|||||||
- `GET /api/reference-data`
|
- `GET /api/reference-data`
|
||||||
- `POST /api/lookup/attack`
|
- `POST /api/lookup/attack`
|
||||||
- `POST /api/lookup/critical`
|
- `POST /api/lookup/critical`
|
||||||
|
- `GET /api/tables/critical/{slug}/cells/{resultId}`
|
||||||
|
- `PUT /api/tables/critical/{slug}/cells/{resultId}`
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
The tables page now supports manual curation of imported critical-table cells. Hovering a populated cell shows that it is editable, and clicking opens a popup editor for the full `CriticalResult` record plus its nested `CriticalBranch` and `CriticalEffect` rows.
|
||||||
|
|
||||||
The current database is an initial seeded subset designed to prove the full lookup workflow. The existing schema notes in `critical_tables_db_model.md` and `critical_tables_schema.sql` are still useful as the source of truth for expanding the import pipeline and normalizing more of the official tables.
|
The current database is an initial seeded subset designed to prove the full lookup workflow. The existing schema notes in `critical_tables_db_model.md` and `critical_tables_schema.sql` are still useful as the source of truth for expanding the import pipeline and normalizing more of the official tables.
|
||||||
|
|||||||
@@ -270,3 +270,29 @@ Current import flow:
|
|||||||
7. Route image PDFs like `Void.pdf` through OCR before the same parser.
|
7. Route image PDFs like `Void.pdf` through OCR before the same parser.
|
||||||
|
|
||||||
The important design decision is: never throw away the original text. The prose is too irregular to rely on normalized fields alone.
|
The important design decision is: never throw away the original text. The prose is too irregular to rely on normalized fields alone.
|
||||||
|
|
||||||
|
## Manual curation workflow
|
||||||
|
|
||||||
|
Because the import path depends on OCR, PDF XML extraction, and heuristics, the web app now treats manual repair as a first-class capability instead of an out-of-band database operation.
|
||||||
|
|
||||||
|
Current curation flow:
|
||||||
|
|
||||||
|
1. Browse a table on the `/tables` page.
|
||||||
|
2. Hover a populated cell to identify editable entries.
|
||||||
|
3. Open the popup editor for that cell.
|
||||||
|
4. Edit the entire `critical_result` graph:
|
||||||
|
- base raw cell text
|
||||||
|
- curated prose / description
|
||||||
|
- raw affix text
|
||||||
|
- parse status
|
||||||
|
- parsed JSON
|
||||||
|
- nested `critical_branch` rows
|
||||||
|
- nested `critical_effect` rows for both the base result and branches
|
||||||
|
5. Save the result back through the API.
|
||||||
|
|
||||||
|
The corresponding API endpoints are:
|
||||||
|
|
||||||
|
- `GET /api/tables/critical/{slug}/cells/{resultId}`
|
||||||
|
- `PUT /api/tables/critical/{slug}/cells/{resultId}`
|
||||||
|
|
||||||
|
The save operation replaces the stored branches and effects for that cell with the submitted payload. That keeps manual edits deterministic and avoids trying to reconcile partial child-row diffs against importer-generated data.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
<section class="hero-panel">
|
<section class="hero-panel">
|
||||||
<span class="eyebrow">Minimal API</span>
|
<span class="eyebrow">Minimal API</span>
|
||||||
<h1 class="page-title">Endpoints for attack and critical lookups.</h1>
|
<h1 class="page-title">Endpoints for attack, critical lookup, and manual critical-table curation.</h1>
|
||||||
<p class="lede">The Blazor UI uses the same lookup service that the API exposes, so this page doubles as the first integration contract.</p>
|
<p class="lede">The Blazor UI uses the same lookup service that the API exposes, so this page doubles as the first integration contract for both read and write workflows.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="api-grid">
|
<div class="api-grid">
|
||||||
@@ -50,4 +50,55 @@
|
|||||||
}</pre>
|
}</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>
|
<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>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<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">{
|
||||||
|
"resultId": 412,
|
||||||
|
"tableSlug": "slash",
|
||||||
|
"tableName": "Slash Critical Strike Table",
|
||||||
|
"rollBand": "66-70",
|
||||||
|
"groupKey": null,
|
||||||
|
"columnKey": "C",
|
||||||
|
"rawCellText": "Original imported full cell text",
|
||||||
|
"descriptionText": "Current curated prose",
|
||||||
|
"rawAffixText": "+8H - 2S",
|
||||||
|
"parseStatus": "verified",
|
||||||
|
"parsedJson": "{}",
|
||||||
|
"effects": [],
|
||||||
|
"branches": []
|
||||||
|
}</pre>
|
||||||
|
<p class="panel-copy">Use this to retrieve the full editable result graph for one critical-table cell, including nested branches and normalized effects.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<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">{
|
||||||
|
"rawCellText": "Corrected imported text",
|
||||||
|
"descriptionText": "Rewritten prose after manual review",
|
||||||
|
"rawAffixText": "+10H - must parry 2 rnds",
|
||||||
|
"parseStatus": "manually_curated",
|
||||||
|
"parsedJson": "{\"reviewed\":true}",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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>
|
</div>
|
||||||
|
|||||||
@@ -117,9 +117,26 @@
|
|||||||
{
|
{
|
||||||
foreach (var column in detail.Columns)
|
foreach (var column in detail.Columns)
|
||||||
{
|
{
|
||||||
<td>
|
@if (TryGetCell(rollBand.Label, group.Key, column.Key, out var groupedCell))
|
||||||
@RenderCell(rollBand.Label, group.Key, column.Key)
|
{
|
||||||
</td>
|
<td
|
||||||
|
class="critical-table-cell is-editable"
|
||||||
|
tabindex="0"
|
||||||
|
title="Click to edit this cell"
|
||||||
|
@onclick="() => OpenCellEditorAsync(groupedCell.ResultId)"
|
||||||
|
@onkeydown="args => HandleCellKeyDownAsync(args, groupedCell.ResultId)">
|
||||||
|
<CompactCriticalCell
|
||||||
|
Description="@(groupedCell.Description ?? string.Empty)"
|
||||||
|
Effects="@(groupedCell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
|
||||||
|
Branches="@(groupedCell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<td class="critical-table-cell">
|
||||||
|
<span class="empty-cell">—</span>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,9 +144,26 @@
|
|||||||
{
|
{
|
||||||
foreach (var column in detail.Columns)
|
foreach (var column in detail.Columns)
|
||||||
{
|
{
|
||||||
<td>
|
@if (TryGetCell(rollBand.Label, null, column.Key, out var cell))
|
||||||
@RenderCell(rollBand.Label, null, column.Key)
|
{
|
||||||
</td>
|
<td
|
||||||
|
class="critical-table-cell is-editable"
|
||||||
|
tabindex="0"
|
||||||
|
title="Click to edit this cell"
|
||||||
|
@onclick="() => OpenCellEditorAsync(cell.ResultId)"
|
||||||
|
@onkeydown="args => HandleCellKeyDownAsync(args, cell.ResultId)">
|
||||||
|
<CompactCriticalCell
|
||||||
|
Description="@(cell.Description ?? string.Empty)"
|
||||||
|
Effects="@(cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
|
||||||
|
Branches="@(cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<td class="critical-table-cell">
|
||||||
|
<span class="empty-cell">—</span>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -164,6 +198,17 @@
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (isEditorOpen)
|
||||||
|
{
|
||||||
|
<CriticalCellEditorDialog
|
||||||
|
Model="editorModel"
|
||||||
|
IsLoading="isEditorLoading"
|
||||||
|
IsSaving="isEditorSaving"
|
||||||
|
ErrorMessage="editorError"
|
||||||
|
OnClose="CloseCellEditorAsync"
|
||||||
|
OnSave="SaveCellEditorAsync" />
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private LookupReferenceData? referenceData;
|
private LookupReferenceData? referenceData;
|
||||||
private CriticalTableDetail? tableDetail;
|
private CriticalTableDetail? tableDetail;
|
||||||
@@ -175,6 +220,12 @@
|
|||||||
private int tableLayoutVersion;
|
private int tableLayoutVersion;
|
||||||
private int appliedLayoutVersion = -1;
|
private int appliedLayoutVersion = -1;
|
||||||
private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0;
|
private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0;
|
||||||
|
private bool isEditorOpen;
|
||||||
|
private bool isEditorLoading;
|
||||||
|
private bool isEditorSaving;
|
||||||
|
private string? editorError;
|
||||||
|
private int? editingResultId;
|
||||||
|
private CriticalCellEditorModel? editorModel;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -252,25 +303,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private RenderFragment RenderCell(string rollBand, string? groupKey, string columnKey) => builder =>
|
|
||||||
{
|
|
||||||
if (TryGetCell(rollBand, groupKey, columnKey, out var cell))
|
|
||||||
{
|
|
||||||
builder.OpenComponent(0, typeof(CompactCriticalCell));
|
|
||||||
builder.AddAttribute(1, "Description", cell.Description ?? string.Empty);
|
|
||||||
builder.AddAttribute(2, "Effects", cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>());
|
|
||||||
builder.AddAttribute(3, "Branches", cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>());
|
|
||||||
builder.CloseComponent();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.OpenElement(4, "span");
|
|
||||||
builder.AddAttribute(5, "class", "empty-cell");
|
|
||||||
builder.AddContent(6, "—");
|
|
||||||
builder.CloseElement();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private bool TryGetCell(string rollBand, string? groupKey, string columnKey, [NotNullWhen(true)] out CriticalTableCellDetail? cell)
|
private bool TryGetCell(string rollBand, string? groupKey, string columnKey, [NotNullWhen(true)] out CriticalTableCellDetail? cell)
|
||||||
{
|
{
|
||||||
if (cellIndex is null)
|
if (cellIndex is null)
|
||||||
@@ -281,4 +313,91 @@
|
|||||||
|
|
||||||
return cellIndex.TryGetValue((rollBand, groupKey, columnKey), out cell);
|
return cellIndex.TryGetValue((rollBand, groupKey, columnKey), out cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task OpenCellEditorAsync(int resultId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(selectedTableSlug))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEditorOpen = true;
|
||||||
|
isEditorLoading = true;
|
||||||
|
isEditorSaving = false;
|
||||||
|
editorError = null;
|
||||||
|
editingResultId = resultId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, resultId);
|
||||||
|
if (response is null)
|
||||||
|
{
|
||||||
|
editorError = "The selected cell could not be loaded for editing.";
|
||||||
|
editorModel = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorModel = CriticalCellEditorModel.FromResponse(response);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
editorError = exception.Message;
|
||||||
|
editorModel = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isEditorLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseCellEditorAsync()
|
||||||
|
{
|
||||||
|
isEditorOpen = false;
|
||||||
|
isEditorLoading = false;
|
||||||
|
isEditorSaving = false;
|
||||||
|
editorError = null;
|
||||||
|
editingResultId = null;
|
||||||
|
editorModel = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveCellEditorAsync()
|
||||||
|
{
|
||||||
|
if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEditorSaving = true;
|
||||||
|
editorError = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await LookupService.UpdateCriticalCellAsync(selectedTableSlug, editingResultId.Value, editorModel.ToRequest());
|
||||||
|
if (response is null)
|
||||||
|
{
|
||||||
|
editorError = "The selected cell could not be saved.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorModel = CriticalCellEditorModel.FromResponse(response);
|
||||||
|
await LoadTableDetailAsync();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
editorError = exception.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isEditorSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCellKeyDownAsync(KeyboardEventArgs args, int resultId)
|
||||||
|
{
|
||||||
|
if (args.Key is "Enter" or " ")
|
||||||
|
{
|
||||||
|
await OpenCellEditorAsync(resultId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using RolemasterDb.App.Features;
|
||||||
|
|
||||||
|
namespace RolemasterDb.App.Components.Shared;
|
||||||
|
|
||||||
|
public sealed class CriticalBranchEditorModel
|
||||||
|
{
|
||||||
|
public string BranchKind { get; set; } = "conditional";
|
||||||
|
public string? ConditionKey { get; set; }
|
||||||
|
public string ConditionText { get; set; } = string.Empty;
|
||||||
|
public string ConditionJson { get; set; } = "{}";
|
||||||
|
public string RawText { get; set; } = string.Empty;
|
||||||
|
public string DescriptionText { get; set; } = string.Empty;
|
||||||
|
public string? RawAffixText { get; set; }
|
||||||
|
public string ParsedJson { get; set; } = "{}";
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public List<CriticalEffectEditorModel> Effects { get; set; } = [];
|
||||||
|
|
||||||
|
public static CriticalBranchEditorModel FromItem(CriticalBranchEditorItem item) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
BranchKind = item.BranchKind,
|
||||||
|
ConditionKey = item.ConditionKey,
|
||||||
|
ConditionText = item.ConditionText,
|
||||||
|
ConditionJson = item.ConditionJson,
|
||||||
|
RawText = item.RawText,
|
||||||
|
DescriptionText = item.DescriptionText,
|
||||||
|
RawAffixText = item.RawAffixText,
|
||||||
|
ParsedJson = item.ParsedJson,
|
||||||
|
SortOrder = item.SortOrder,
|
||||||
|
Effects = item.Effects.Select(CriticalEffectEditorModel.FromItem).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
public CriticalBranchEditorItem ToItem() =>
|
||||||
|
new(
|
||||||
|
BranchKind,
|
||||||
|
ConditionKey,
|
||||||
|
ConditionText,
|
||||||
|
ConditionJson,
|
||||||
|
RawText,
|
||||||
|
DescriptionText,
|
||||||
|
RawAffixText,
|
||||||
|
ParsedJson,
|
||||||
|
SortOrder,
|
||||||
|
Effects.Select(effect => effect.ToItem()).ToList());
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
@if (Model is not null)
|
||||||
|
{
|
||||||
|
<div class="critical-editor-backdrop" @onclick="HandleBackdropClicked">
|
||||||
|
<div class="critical-editor-dialog" @onclick:stopPropagation="true">
|
||||||
|
<header class="critical-editor-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">@Model.SourceDocument</span>
|
||||||
|
<h3 class="panel-title">Edit @Model.TableName</h3>
|
||||||
|
<p class="muted critical-editor-meta">
|
||||||
|
Roll band <strong>@Model.RollBand</strong>, column <strong>@Model.ColumnLabel</strong>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.GroupLabel))
|
||||||
|
{
|
||||||
|
<span>, group <strong>@Model.GroupLabel</strong></span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-link critical-editor-close" @onclick="OnClose">Close</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||||
|
{
|
||||||
|
<p class="error-text">@ErrorMessage</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="muted">Loading editor...</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="critical-editor-body">
|
||||||
|
<section class="critical-editor-section">
|
||||||
|
<h4>Base Cell</h4>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Raw Cell Text</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea tall" @bind-Value="Model.RawCellText" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Description / Prose</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea" @bind-Value="Model.DescriptionText" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Affix Text</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.RawAffixText" />
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Parse Status</label>
|
||||||
|
<InputText class="input-shell" @bind-Value="Model.ParseStatus" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Parsed Json</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.ParsedJson" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="critical-editor-section">
|
||||||
|
<div class="critical-editor-section-header">
|
||||||
|
<h4>Base Effects</h4>
|
||||||
|
<button type="button" class="btn-ritual" @onclick="AddBaseEffect">Add Effect</button>
|
||||||
|
</div>
|
||||||
|
@if (Model.Effects.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">No normalized base effects for this cell.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@for (var index = 0; index < Model.Effects.Count; index++)
|
||||||
|
{
|
||||||
|
var effect = Model.Effects[index];
|
||||||
|
<div class="critical-editor-card">
|
||||||
|
<div class="critical-editor-card-header">
|
||||||
|
<strong>Effect @(index + 1)</strong>
|
||||||
|
<button type="button" class="btn btn-link" @onclick="() => RemoveBaseEffect(index)">Remove</button>
|
||||||
|
</div>
|
||||||
|
@EffectFields(effect)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="critical-editor-section">
|
||||||
|
<div class="critical-editor-section-header">
|
||||||
|
<h4>Branches</h4>
|
||||||
|
<button type="button" class="btn-ritual" @onclick="AddBranch">Add Branch</button>
|
||||||
|
</div>
|
||||||
|
@if (Model.Branches.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">No branch records on this cell.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@for (var index = 0; index < Model.Branches.Count; index++)
|
||||||
|
{
|
||||||
|
var branch = Model.Branches[index];
|
||||||
|
<div class="critical-editor-card branch-card-editor">
|
||||||
|
<div class="critical-editor-card-header">
|
||||||
|
<strong>Branch @(index + 1)</strong>
|
||||||
|
<button type="button" class="btn btn-link" @onclick="() => RemoveBranch(index)">Remove</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Branch Kind</label>
|
||||||
|
<InputText class="input-shell" @bind-Value="branch.BranchKind" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Condition Key</label>
|
||||||
|
<InputText class="input-shell" @bind-Value="branch.ConditionKey" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Sort Order</label>
|
||||||
|
<InputNumber TValue="int" class="input-shell" @bind-Value="branch.SortOrder" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Condition Text</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="branch.ConditionText" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Raw Text</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea" @bind-Value="branch.RawText" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Description / Prose</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea" @bind-Value="branch.DescriptionText" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Affix Text</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="branch.RawAffixText" />
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Condition Json</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="branch.ConditionJson" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Parsed Json</label>
|
||||||
|
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="branch.ParsedJson" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="critical-editor-subsection">
|
||||||
|
<div class="critical-editor-section-header">
|
||||||
|
<h5>Branch Effects</h5>
|
||||||
|
<button type="button" class="btn-ritual" @onclick="() => AddBranchEffect(branch)">Add Effect</button>
|
||||||
|
</div>
|
||||||
|
@if (branch.Effects.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">No normalized branch effects.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@for (var effectIndex = 0; effectIndex < branch.Effects.Count; effectIndex++)
|
||||||
|
{
|
||||||
|
var effect = branch.Effects[effectIndex];
|
||||||
|
<div class="critical-editor-card nested">
|
||||||
|
<div class="critical-editor-card-header">
|
||||||
|
<strong>Branch Effect @(effectIndex + 1)</strong>
|
||||||
|
<button type="button" class="btn btn-link" @onclick="() => RemoveBranchEffect(branch, effectIndex)">Remove</button>
|
||||||
|
</div>
|
||||||
|
@EffectFields(effect)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<footer class="critical-editor-footer">
|
||||||
|
<button type="button" class="btn btn-link" @onclick="OnClose" disabled="@IsSaving">Cancel</button>
|
||||||
|
<button type="button" class="btn-ritual" @onclick="OnSave" disabled="@IsLoading || IsSaving">
|
||||||
|
@(IsSaving ? "Saving..." : "Save Cell")
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public CriticalCellEditorModel? Model { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool IsLoading { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool IsSaving { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public EventCallback OnClose { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public EventCallback OnSave { get; set; }
|
||||||
|
|
||||||
|
private async Task HandleBackdropClicked()
|
||||||
|
{
|
||||||
|
await OnClose.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddBaseEffect()
|
||||||
|
{
|
||||||
|
Model?.Effects.Add(new CriticalEffectEditorModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveBaseEffect(int index)
|
||||||
|
{
|
||||||
|
if (Model is null || index < 0 || index >= Model.Effects.Count)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Model.Effects.RemoveAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddBranch()
|
||||||
|
{
|
||||||
|
if (Model is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Model.Branches.Add(new CriticalBranchEditorModel
|
||||||
|
{
|
||||||
|
SortOrder = Model.Branches.Count + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveBranch(int index)
|
||||||
|
{
|
||||||
|
if (Model is null || index < 0 || index >= Model.Branches.Count)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Model.Branches.RemoveAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddBranchEffect(CriticalBranchEditorModel branch)
|
||||||
|
{
|
||||||
|
branch.Effects.Add(new CriticalEffectEditorModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveBranchEffect(CriticalBranchEditorModel branch, int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= branch.Effects.Count)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
branch.Effects.RemoveAt(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@functions {
|
||||||
|
private RenderFragment EffectFields(CriticalEffectEditorModel effect) => @<div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Effect Code</label>
|
||||||
|
<InputText class="input-shell" @bind-Value="effect.EffectCode" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Target</label>
|
||||||
|
<InputText class="input-shell" @bind-Value="effect.Target" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Source Type</label>
|
||||||
|
<InputText class="input-shell" @bind-Value="effect.SourceType" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Source Text</label>
|
||||||
|
<InputText class="input-shell" @bind-Value="effect.SourceText" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Value Integer</label>
|
||||||
|
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.ValueInteger" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Value Decimal</label>
|
||||||
|
<InputNumber TValue="decimal?" class="input-shell" @bind-Value="effect.ValueDecimal" step="0.01" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Value Expression</label>
|
||||||
|
<InputText class="input-shell" @bind-Value="effect.ValueExpression" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Duration Rounds</label>
|
||||||
|
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.DurationRounds" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Per Round</label>
|
||||||
|
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.PerRound" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Modifier</label>
|
||||||
|
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.Modifier" />
|
||||||
|
</div>
|
||||||
|
<div class="field-shell">
|
||||||
|
<label>Body Part</label>
|
||||||
|
<InputText class="input-shell" @bind-Value="effect.BodyPart" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="critical-editor-checkbox">
|
||||||
|
<InputCheckbox class="form-check-input" @bind-Value="effect.IsPermanent" />
|
||||||
|
<span>Permanent</span>
|
||||||
|
</label>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using RolemasterDb.App.Features;
|
||||||
|
|
||||||
|
namespace RolemasterDb.App.Components.Shared;
|
||||||
|
|
||||||
|
public sealed class CriticalCellEditorModel
|
||||||
|
{
|
||||||
|
public int ResultId { get; set; }
|
||||||
|
public string TableSlug { get; set; } = string.Empty;
|
||||||
|
public string TableName { get; set; } = string.Empty;
|
||||||
|
public string SourceDocument { get; set; } = string.Empty;
|
||||||
|
public string RollBand { get; set; } = string.Empty;
|
||||||
|
public string? GroupKey { get; set; }
|
||||||
|
public string? GroupLabel { get; set; }
|
||||||
|
public string ColumnKey { get; set; } = string.Empty;
|
||||||
|
public string ColumnLabel { get; set; } = string.Empty;
|
||||||
|
public string ColumnRole { get; set; } = string.Empty;
|
||||||
|
public string RawCellText { get; set; } = string.Empty;
|
||||||
|
public string DescriptionText { get; set; } = string.Empty;
|
||||||
|
public string? RawAffixText { get; set; }
|
||||||
|
public string ParseStatus { get; set; } = string.Empty;
|
||||||
|
public string ParsedJson { get; set; } = "{}";
|
||||||
|
public List<CriticalEffectEditorModel> Effects { get; set; } = [];
|
||||||
|
public List<CriticalBranchEditorModel> Branches { get; set; } = [];
|
||||||
|
|
||||||
|
public static CriticalCellEditorModel FromResponse(CriticalCellEditorResponse response) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ResultId = response.ResultId,
|
||||||
|
TableSlug = response.TableSlug,
|
||||||
|
TableName = response.TableName,
|
||||||
|
SourceDocument = response.SourceDocument,
|
||||||
|
RollBand = response.RollBand,
|
||||||
|
GroupKey = response.GroupKey,
|
||||||
|
GroupLabel = response.GroupLabel,
|
||||||
|
ColumnKey = response.ColumnKey,
|
||||||
|
ColumnLabel = response.ColumnLabel,
|
||||||
|
ColumnRole = response.ColumnRole,
|
||||||
|
RawCellText = response.RawCellText,
|
||||||
|
DescriptionText = response.DescriptionText,
|
||||||
|
RawAffixText = response.RawAffixText,
|
||||||
|
ParseStatus = response.ParseStatus,
|
||||||
|
ParsedJson = response.ParsedJson,
|
||||||
|
Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(),
|
||||||
|
Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
public CriticalCellUpdateRequest ToRequest() =>
|
||||||
|
new(
|
||||||
|
RawCellText,
|
||||||
|
DescriptionText,
|
||||||
|
RawAffixText,
|
||||||
|
ParseStatus,
|
||||||
|
ParsedJson,
|
||||||
|
Effects.Select(effect => effect.ToItem()).ToList(),
|
||||||
|
Branches
|
||||||
|
.OrderBy(branch => branch.SortOrder)
|
||||||
|
.Select((branch, index) =>
|
||||||
|
{
|
||||||
|
branch.SortOrder = index + 1;
|
||||||
|
return branch.ToItem();
|
||||||
|
})
|
||||||
|
.ToList());
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using RolemasterDb.App.Features;
|
||||||
|
|
||||||
|
namespace RolemasterDb.App.Components.Shared;
|
||||||
|
|
||||||
|
public sealed class CriticalEffectEditorModel
|
||||||
|
{
|
||||||
|
public string EffectCode { get; set; } = string.Empty;
|
||||||
|
public string? Target { get; set; }
|
||||||
|
public int? ValueInteger { get; set; }
|
||||||
|
public decimal? ValueDecimal { get; set; }
|
||||||
|
public string? ValueExpression { get; set; }
|
||||||
|
public int? DurationRounds { get; set; }
|
||||||
|
public int? PerRound { get; set; }
|
||||||
|
public int? Modifier { get; set; }
|
||||||
|
public string? BodyPart { get; set; }
|
||||||
|
public bool IsPermanent { get; set; }
|
||||||
|
public string SourceType { get; set; } = "symbol";
|
||||||
|
public string? SourceText { get; set; }
|
||||||
|
|
||||||
|
public static CriticalEffectEditorModel FromItem(CriticalEffectEditorItem item) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
EffectCode = item.EffectCode,
|
||||||
|
Target = item.Target,
|
||||||
|
ValueInteger = item.ValueInteger,
|
||||||
|
ValueDecimal = item.ValueDecimal,
|
||||||
|
ValueExpression = item.ValueExpression,
|
||||||
|
DurationRounds = item.DurationRounds,
|
||||||
|
PerRound = item.PerRound,
|
||||||
|
Modifier = item.Modifier,
|
||||||
|
BodyPart = item.BodyPart,
|
||||||
|
IsPermanent = item.IsPermanent,
|
||||||
|
SourceType = item.SourceType,
|
||||||
|
SourceText = item.SourceText
|
||||||
|
};
|
||||||
|
|
||||||
|
public CriticalEffectEditorItem ToItem() =>
|
||||||
|
new(
|
||||||
|
EffectCode,
|
||||||
|
Target,
|
||||||
|
ValueInteger,
|
||||||
|
ValueDecimal,
|
||||||
|
ValueExpression,
|
||||||
|
DurationRounds,
|
||||||
|
PerRound,
|
||||||
|
Modifier,
|
||||||
|
BodyPart,
|
||||||
|
IsPermanent,
|
||||||
|
SourceType,
|
||||||
|
SourceText);
|
||||||
|
}
|
||||||
15
src/RolemasterDb.App/Features/CriticalBranchEditorItem.cs
Normal file
15
src/RolemasterDb.App/Features/CriticalBranchEditorItem.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
|
public sealed record CriticalBranchEditorItem(
|
||||||
|
string BranchKind,
|
||||||
|
string? ConditionKey,
|
||||||
|
string ConditionText,
|
||||||
|
string ConditionJson,
|
||||||
|
string RawText,
|
||||||
|
string DescriptionText,
|
||||||
|
string? RawAffixText,
|
||||||
|
string ParsedJson,
|
||||||
|
int SortOrder,
|
||||||
|
IReadOnlyList<CriticalEffectEditorItem> Effects);
|
||||||
22
src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs
Normal file
22
src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
|
public sealed record CriticalCellEditorResponse(
|
||||||
|
int ResultId,
|
||||||
|
string TableSlug,
|
||||||
|
string TableName,
|
||||||
|
string SourceDocument,
|
||||||
|
string RollBand,
|
||||||
|
string? GroupKey,
|
||||||
|
string? GroupLabel,
|
||||||
|
string ColumnKey,
|
||||||
|
string ColumnLabel,
|
||||||
|
string ColumnRole,
|
||||||
|
string RawCellText,
|
||||||
|
string DescriptionText,
|
||||||
|
string? RawAffixText,
|
||||||
|
string ParseStatus,
|
||||||
|
string ParsedJson,
|
||||||
|
IReadOnlyList<CriticalEffectEditorItem> Effects,
|
||||||
|
IReadOnlyList<CriticalBranchEditorItem> Branches);
|
||||||
12
src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs
Normal file
12
src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
|
public sealed record CriticalCellUpdateRequest(
|
||||||
|
string RawCellText,
|
||||||
|
string DescriptionText,
|
||||||
|
string? RawAffixText,
|
||||||
|
string ParseStatus,
|
||||||
|
string ParsedJson,
|
||||||
|
IReadOnlyList<CriticalEffectEditorItem> Effects,
|
||||||
|
IReadOnlyList<CriticalBranchEditorItem> Branches);
|
||||||
15
src/RolemasterDb.App/Features/CriticalEffectEditorItem.cs
Normal file
15
src/RolemasterDb.App/Features/CriticalEffectEditorItem.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
|
public sealed record CriticalEffectEditorItem(
|
||||||
|
string EffectCode,
|
||||||
|
string? Target,
|
||||||
|
int? ValueInteger,
|
||||||
|
decimal? ValueDecimal,
|
||||||
|
string? ValueExpression,
|
||||||
|
int? DurationRounds,
|
||||||
|
int? PerRound,
|
||||||
|
int? Modifier,
|
||||||
|
string? BodyPart,
|
||||||
|
bool IsPermanent,
|
||||||
|
string SourceType,
|
||||||
|
string? SourceText);
|
||||||
@@ -96,6 +96,7 @@ public sealed record AttackLookupResponse(
|
|||||||
CriticalLookupResponse? AutoCritical);
|
CriticalLookupResponse? AutoCritical);
|
||||||
|
|
||||||
public sealed record CriticalTableCellDetail(
|
public sealed record CriticalTableCellDetail(
|
||||||
|
int ResultId,
|
||||||
string RollBand,
|
string RollBand,
|
||||||
string ColumnKey,
|
string ColumnKey,
|
||||||
string ColumnLabel,
|
string ColumnLabel,
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
.ThenBy(result => result.CriticalGroup?.SortOrder ?? 0)
|
.ThenBy(result => result.CriticalGroup?.SortOrder ?? 0)
|
||||||
.ThenBy(result => result.CriticalColumn.SortOrder)
|
.ThenBy(result => result.CriticalColumn.SortOrder)
|
||||||
.Select(result => new CriticalTableCellDetail(
|
.Select(result => new CriticalTableCellDetail(
|
||||||
|
result.Id,
|
||||||
result.CriticalRollBand.Label,
|
result.CriticalRollBand.Label,
|
||||||
result.CriticalColumn.ColumnKey,
|
result.CriticalColumn.ColumnKey,
|
||||||
result.CriticalColumn.Label,
|
result.CriticalColumn.Label,
|
||||||
@@ -262,6 +263,70 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
cells,
|
cells,
|
||||||
legend);
|
legend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<CriticalCellEditorResponse?> GetCriticalCellEditorAsync(string slug, int resultId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var normalizedSlug = NormalizeSlug(slug);
|
||||||
|
var result = await dbContext.CriticalResults
|
||||||
|
.AsNoTracking()
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(item => item.CriticalTable)
|
||||||
|
.Include(item => item.CriticalColumn)
|
||||||
|
.Include(item => item.CriticalGroup)
|
||||||
|
.Include(item => item.CriticalRollBand)
|
||||||
|
.Include(item => item.Effects)
|
||||||
|
.Include(item => item.Branches)
|
||||||
|
.ThenInclude(branch => branch.Effects)
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return result is null ? null : CreateCellEditorResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync(
|
||||||
|
string slug,
|
||||||
|
int resultId,
|
||||||
|
CriticalCellUpdateRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var normalizedSlug = NormalizeSlug(slug);
|
||||||
|
var result = await dbContext.CriticalResults
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(item => item.CriticalTable)
|
||||||
|
.Include(item => item.CriticalColumn)
|
||||||
|
.Include(item => item.CriticalGroup)
|
||||||
|
.Include(item => item.CriticalRollBand)
|
||||||
|
.Include(item => item.Effects)
|
||||||
|
.Include(item => item.Branches)
|
||||||
|
.ThenInclude(branch => branch.Effects)
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.RawCellText = request.RawCellText.Trim();
|
||||||
|
result.DescriptionText = request.DescriptionText.Trim();
|
||||||
|
result.RawAffixText = NormalizeOptionalText(request.RawAffixText);
|
||||||
|
result.ParseStatus = request.ParseStatus.Trim();
|
||||||
|
result.ParsedJson = string.IsNullOrWhiteSpace(request.ParsedJson) ? "{}" : request.ParsedJson.Trim();
|
||||||
|
|
||||||
|
ReplaceBaseEffects(dbContext, result, request.Effects);
|
||||||
|
ReplaceBranches(dbContext, result, request.Branches);
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return CreateCellEditorResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
|
private static IReadOnlyList<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
|
||||||
{
|
{
|
||||||
var seenCodes = new HashSet<string>(StringComparer.Ordinal);
|
var seenCodes = new HashSet<string>(StringComparer.Ordinal);
|
||||||
@@ -320,6 +385,32 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
effect.SourceType,
|
effect.SourceType,
|
||||||
effect.SourceText);
|
effect.SourceText);
|
||||||
|
|
||||||
|
private static CriticalCellEditorResponse CreateCellEditorResponse(CriticalResult result) =>
|
||||||
|
new(
|
||||||
|
result.Id,
|
||||||
|
result.CriticalTable.Slug,
|
||||||
|
result.CriticalTable.DisplayName,
|
||||||
|
result.CriticalTable.SourceDocument,
|
||||||
|
result.CriticalRollBand.Label,
|
||||||
|
result.CriticalGroup?.GroupKey,
|
||||||
|
result.CriticalGroup?.Label,
|
||||||
|
result.CriticalColumn.ColumnKey,
|
||||||
|
result.CriticalColumn.Label,
|
||||||
|
result.CriticalColumn.Role,
|
||||||
|
result.RawCellText,
|
||||||
|
result.DescriptionText,
|
||||||
|
result.RawAffixText,
|
||||||
|
result.ParseStatus,
|
||||||
|
result.ParsedJson,
|
||||||
|
result.Effects
|
||||||
|
.OrderBy(effect => effect.Id)
|
||||||
|
.Select(CreateEffectEditorItem)
|
||||||
|
.ToList(),
|
||||||
|
result.Branches
|
||||||
|
.OrderBy(branch => branch.SortOrder)
|
||||||
|
.Select(CreateBranchEditorItem)
|
||||||
|
.ToList());
|
||||||
|
|
||||||
private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
|
private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
|
||||||
new(
|
new(
|
||||||
branch.BranchKind,
|
branch.BranchKind,
|
||||||
@@ -334,6 +425,108 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
branch.RawText,
|
branch.RawText,
|
||||||
branch.SortOrder);
|
branch.SortOrder);
|
||||||
|
|
||||||
|
private static CriticalBranchEditorItem CreateBranchEditorItem(CriticalBranch branch) =>
|
||||||
|
new(
|
||||||
|
branch.BranchKind,
|
||||||
|
branch.ConditionKey,
|
||||||
|
branch.ConditionText,
|
||||||
|
branch.ConditionJson,
|
||||||
|
branch.RawText,
|
||||||
|
branch.DescriptionText,
|
||||||
|
branch.RawAffixText,
|
||||||
|
branch.ParsedJson,
|
||||||
|
branch.SortOrder,
|
||||||
|
(branch.Effects ?? Enumerable.Empty<CriticalEffect>())
|
||||||
|
.OrderBy(effect => effect.Id)
|
||||||
|
.Select(CreateEffectEditorItem)
|
||||||
|
.ToList());
|
||||||
|
|
||||||
|
private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect) =>
|
||||||
|
new(
|
||||||
|
effect.EffectCode,
|
||||||
|
effect.Target,
|
||||||
|
effect.ValueInteger,
|
||||||
|
effect.ValueDecimal,
|
||||||
|
effect.ValueExpression,
|
||||||
|
effect.DurationRounds,
|
||||||
|
effect.PerRound,
|
||||||
|
effect.Modifier,
|
||||||
|
effect.BodyPart,
|
||||||
|
effect.IsPermanent,
|
||||||
|
effect.SourceType,
|
||||||
|
effect.SourceText);
|
||||||
|
|
||||||
|
private static void ReplaceBaseEffects(
|
||||||
|
RolemasterDbContext dbContext,
|
||||||
|
CriticalResult result,
|
||||||
|
IReadOnlyList<CriticalEffectEditorItem>? effects)
|
||||||
|
{
|
||||||
|
dbContext.CriticalEffects.RemoveRange(result.Effects);
|
||||||
|
result.Effects.Clear();
|
||||||
|
|
||||||
|
foreach (var effect in effects ?? Array.Empty<CriticalEffectEditorItem>())
|
||||||
|
{
|
||||||
|
result.Effects.Add(CreateEffectEntity(effect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReplaceBranches(
|
||||||
|
RolemasterDbContext dbContext,
|
||||||
|
CriticalResult result,
|
||||||
|
IReadOnlyList<CriticalBranchEditorItem>? branches)
|
||||||
|
{
|
||||||
|
foreach (var branch in result.Branches)
|
||||||
|
{
|
||||||
|
dbContext.CriticalEffects.RemoveRange(branch.Effects);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.CriticalBranches.RemoveRange(result.Branches);
|
||||||
|
result.Branches.Clear();
|
||||||
|
|
||||||
|
foreach (var branch in branches ?? Array.Empty<CriticalBranchEditorItem>())
|
||||||
|
{
|
||||||
|
var branchEntity = new CriticalBranch
|
||||||
|
{
|
||||||
|
BranchKind = branch.BranchKind.Trim(),
|
||||||
|
ConditionKey = NormalizeOptionalText(branch.ConditionKey),
|
||||||
|
ConditionText = branch.ConditionText.Trim(),
|
||||||
|
ConditionJson = string.IsNullOrWhiteSpace(branch.ConditionJson) ? "{}" : branch.ConditionJson.Trim(),
|
||||||
|
RawText = branch.RawText.Trim(),
|
||||||
|
DescriptionText = branch.DescriptionText.Trim(),
|
||||||
|
RawAffixText = NormalizeOptionalText(branch.RawAffixText),
|
||||||
|
ParsedJson = string.IsNullOrWhiteSpace(branch.ParsedJson) ? "{}" : branch.ParsedJson.Trim(),
|
||||||
|
SortOrder = branch.SortOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var effect in branch.Effects ?? Array.Empty<CriticalEffectEditorItem>())
|
||||||
|
{
|
||||||
|
branchEntity.Effects.Add(CreateEffectEntity(effect));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Branches.Add(branchEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CriticalEffect CreateEffectEntity(CriticalEffectEditorItem effect) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
EffectCode = effect.EffectCode.Trim(),
|
||||||
|
Target = NormalizeOptionalText(effect.Target),
|
||||||
|
ValueInteger = effect.ValueInteger,
|
||||||
|
ValueDecimal = effect.ValueDecimal,
|
||||||
|
ValueExpression = NormalizeOptionalText(effect.ValueExpression),
|
||||||
|
DurationRounds = effect.DurationRounds,
|
||||||
|
PerRound = effect.PerRound,
|
||||||
|
Modifier = effect.Modifier,
|
||||||
|
BodyPart = NormalizeOptionalText(effect.BodyPart),
|
||||||
|
IsPermanent = effect.IsPermanent,
|
||||||
|
SourceType = effect.SourceType.Trim(),
|
||||||
|
SourceText = NormalizeOptionalText(effect.SourceText)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? NormalizeOptionalText(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
private static string NormalizeSlug(string value) =>
|
private static string NormalizeSlug(string value) =>
|
||||||
value.Trim().Replace(' ', '_').ToLowerInvariant();
|
value.Trim().Replace(' ', '_').ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ api.MapPost("/lookup/critical", async (CriticalLookupRequest request, LookupServ
|
|||||||
var result = await lookupService.LookupCriticalAsync(request, cancellationToken);
|
var result = await lookupService.LookupCriticalAsync(request, cancellationToken);
|
||||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||||
});
|
});
|
||||||
|
api.MapGet("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, int resultId, LookupService lookupService, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken);
|
||||||
|
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||||
|
});
|
||||||
|
api.MapPut("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, int resultId, CriticalCellUpdateRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var result = await lookupService.UpdateCriticalCellAsync(slug, resultId, request, cancellationToken);
|
||||||
|
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||||
|
});
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
|||||||
@@ -540,6 +540,22 @@ textarea {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.critical-table-cell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-table-cell.is-editable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.16s ease, box-shadow 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-table-cell.is-editable:hover,
|
||||||
|
.critical-table-cell.is-editable:focus-visible {
|
||||||
|
background: rgba(255, 248, 232, 0.98);
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(184, 121, 59, 0.4);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.critical-table td .critical-cell {
|
.critical-table td .critical-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -600,6 +616,111 @@ textarea {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.critical-editor-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1050;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: rgba(38, 26, 20, 0.44);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-dialog {
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
max-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 250, 242, 0.98);
|
||||||
|
border: 1px solid rgba(127, 96, 55, 0.18);
|
||||||
|
box-shadow: 0 28px 60px rgba(41, 22, 11, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-header,
|
||||||
|
.critical-editor-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid rgba(127, 96, 55, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-footer {
|
||||||
|
border-top: 1px solid rgba(127, 96, 55, 0.12);
|
||||||
|
border-bottom: none;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-close {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-meta {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-body {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-section,
|
||||||
|
.critical-editor-subsection {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-section h4,
|
||||||
|
.critical-editor-subsection h5 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-section-header,
|
||||||
|
.critical-editor-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
border: 1px solid rgba(127, 96, 55, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-card.nested {
|
||||||
|
background: rgba(252, 248, 238, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-textarea {
|
||||||
|
min-height: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-textarea.compact {
|
||||||
|
min-height: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-textarea.tall {
|
||||||
|
min-height: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #5d4429;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640.98px) {
|
@media (max-width: 640.98px) {
|
||||||
.content-shell {
|
.content-shell {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -609,4 +730,18 @@ textarea {
|
|||||||
.panel {
|
.panel {
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.critical-editor-backdrop {
|
||||||
|
padding: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-dialog {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-header,
|
||||||
|
.critical-editor-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user