diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index c9dae62..d4fd2f8 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -3,6 +3,7 @@ @using System @using System.Collections.Generic @using System.Diagnostics.CodeAnalysis +@using System.Linq @inject IJSRuntime JSRuntime @inject LookupService LookupService @@ -11,25 +12,51 @@
- - +

Choose a table, then read from the roll band on the left across to the result you need.

@@ -67,7 +94,7 @@

@detail.DisplayName

@readingHint

-

Click any filled result to correct it.

+

Use the curation action or edit action on any filled result.

@@ -120,18 +147,38 @@ { @if (TryGetCell(rollBand.Label, group.Key, column.Key, out var groupedCell)) { - - + +
+
+ @if (groupedCell.IsCurated) + { + Curated + } + else + { + + } + + +
+ + +
} else @@ -149,18 +196,38 @@ { @if (TryGetCell(rollBand.Label, null, column.Key, out var cell)) { - - + +
+
+ @if (cell.IsCurated) + { + Curated + } + else + { + + } + + +
+ + +
} else @@ -206,6 +273,20 @@ }
+@if (isCurationOpen) +{ + +} + @if (isEditorOpen) { + referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase)); protected override async Task OnInitializedAsync() { @@ -254,9 +344,25 @@ await LoadTableDetailAsync(); } - private async Task HandleTableChanged(ChangeEventArgs args) + private void ToggleTableMenu() { - selectedTableSlug = args.Value?.ToString() ?? string.Empty; + if (IsTableSelectionDisabled) + { + return; + } + + isTableMenuOpen = !isTableMenuOpen; + } + + private void CloseTableMenu() + { + isTableMenuOpen = false; + } + + private async Task SelectTableAsync(string tableSlug) + { + selectedTableSlug = tableSlug; + isTableMenuOpen = false; await LoadTableDetailAsync(); } @@ -373,6 +479,173 @@ } } + private async Task OpenCellCurationAsync(int resultId) + { + if (string.IsNullOrWhiteSpace(selectedTableSlug)) + { + return; + } + + isCurationSaving = false; + isCurationOpen = true; + await LoadCurationCellAsync(resultId, "The selected cell could not be loaded for curation."); + } + + private async Task CloseCellCurationAsync() + { + isCurationOpen = false; + isCurationLoading = false; + isCurationSaving = false; + curationError = null; + curatingResultId = null; + curationModel = null; + await InvokeAsync(StateHasChanged); + } + + private async Task MarkCellCuratedAsync() + { + if (curationModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || curatingResultId is null) + { + return; + } + + isCurationSaving = true; + curationError = null; + + try + { + curationModel.IsCurated = true; + var response = await LookupService.UpdateCriticalCellAsync(selectedTableSlug, curatingResultId.Value, curationModel.ToRequest()); + if (response is null) + { + curationError = "The selected cell could not be marked curated."; + return; + } + + await LoadTableDetailAsync(); + + var nextResultId = FindNextUncuratedResultId(curatingResultId.Value); + if (nextResultId is null) + { + await CloseCellCurationAsync(); + return; + } + + await LoadCurationCellAsync(nextResultId.Value, "The next cell could not be loaded for curation."); + } + catch (Exception exception) + { + curationError = exception.Message; + } + finally + { + isCurationSaving = false; + } + } + + private async Task OpenEditorFromCurationAsync() + { + if (curatingResultId is null) + { + return; + } + + var resultId = curatingResultId.Value; + await CloseCellCurationAsync(); + await OpenCellEditorAsync(resultId); + } + + private async Task LoadCurationCellAsync(int resultId, string loadFailureMessage) + { + curationError = null; + curationModel = null; + curatingResultId = resultId; + isCurationLoading = true; + + try + { + var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, resultId); + if (response is null) + { + curationError = loadFailureMessage; + curationModel = null; + return; + } + + curationModel = CriticalCellEditorModel.FromResponse(response); + } + catch (Exception exception) + { + curationError = exception.Message; + curationModel = null; + } + finally + { + isCurationLoading = false; + } + } + + private int? FindNextUncuratedResultId(int currentResultId) + { + if (tableDetail?.Cells is null || tableDetail.Cells.Count == 0) + { + return null; + } + + var orderedCells = tableDetail.Cells + .OrderBy(cell => GetGroupSortOrder(cell.GroupKey)) + .ThenBy(cell => GetColumnSortOrder(cell.ColumnKey)) + .ThenBy(cell => GetRollBandSortOrder(cell.RollBand)) + .ToList(); + + var currentIndex = orderedCells.FindIndex(cell => cell.ResultId == currentResultId); + for (var index = currentIndex + 1; index < orderedCells.Count; index++) + { + if (!orderedCells[index].IsCurated) + { + return orderedCells[index].ResultId; + } + } + + return null; + } + + private int GetGroupSortOrder(string? groupKey) + { + if (tableDetail is null || string.IsNullOrWhiteSpace(groupKey)) + { + return 0; + } + + return tableDetail.Groups + .FirstOrDefault(group => string.Equals(group.Key, groupKey, StringComparison.OrdinalIgnoreCase)) + ?.SortOrder ?? int.MaxValue; + } + + private int GetColumnSortOrder(string columnKey) + { + if (tableDetail is null) + { + return int.MaxValue; + } + + return tableDetail.Columns + .FirstOrDefault(column => string.Equals(column.Key, columnKey, StringComparison.OrdinalIgnoreCase)) + ?.SortOrder ?? int.MaxValue; + } + + private int GetRollBandSortOrder(string rollBandLabel) + { + if (tableDetail is null) + { + return int.MaxValue; + } + + return tableDetail.RollBands + .FirstOrDefault(rollBand => string.Equals(rollBand.Label, rollBandLabel, StringComparison.OrdinalIgnoreCase)) + ?.SortOrder ?? int.MaxValue; + } + private async Task CloseCellEditorAsync() { isEditorOpen = false; @@ -454,21 +727,24 @@ } } - private async Task HandleCellKeyDownAsync(KeyboardEventArgs args, int resultId) - { - if (args.Key is "Enter" or " ") - { - await OpenCellEditorAsync(resultId); - } - } - private static string GetCellCssClass(CriticalTableCellDetail cell) => cell.IsCurated - ? "critical-table-cell is-editable is-curated" - : "critical-table-cell is-editable needs-curation"; + ? "critical-table-cell is-curated" + : "critical-table-cell needs-curation"; - private static string GetCellTitle(CriticalTableCellDetail cell) => - cell.IsCurated - ? "Curated cell. Click to edit this cell." - : "Needs curation. Click to edit this cell."; + private string GetSelectedTableLabel() => + SelectedTableReference?.Label ?? "Select a table"; + + private string GetTableOptionCssClass(CriticalTableReference table) + { + var classes = new List(); + + if (string.Equals(table.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-selected"); + } + + classes.Add(table.CurationPercentage >= 100 ? "is-curated" : "needs-curation"); + return string.Join(' ', classes); + } } diff --git a/src/RolemasterDb.App/Components/Shared/CompactCriticalCell.razor b/src/RolemasterDb.App/Components/Shared/CompactCriticalCell.razor index 1cdc973..65a53c6 100644 --- a/src/RolemasterDb.App/Components/Shared/CompactCriticalCell.razor +++ b/src/RolemasterDb.App/Components/Shared/CompactCriticalCell.razor @@ -2,12 +2,6 @@ @using RolemasterDb.App.Features
-
- - @(IsCurated ? "Curated" : "Needs Curation") - -
- @if (!string.IsNullOrWhiteSpace(Description)) {

@Description

@@ -46,9 +40,6 @@ [Parameter, EditorRequired] public string Description { get; set; } = string.Empty; - [Parameter] - public bool IsCurated { get; set; } - [Parameter] public IReadOnlyList? Effects { get; set; } diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor new file mode 100644 index 0000000..fc39284 --- /dev/null +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor @@ -0,0 +1,281 @@ +@using System.Collections.Generic +@using System.Linq +@using Microsoft.JSInterop +@using RolemasterDb.App.Features +@implements IAsyncDisposable +@inject IJSRuntime JSRuntime + +
+
+
+
+

Curate Result Card

+ @if (Model is not null) + { +

+ @Model.TableName + · Column @BuildColumnDisplayText(Model) + · Roll band @Model.RollBand +

+ } +
+ +
+ + @if (IsLoading) + { +
+

Loading curation preview...

+
+ } + else if (!string.IsNullOrWhiteSpace(ErrorMessage)) + { +
+

@ErrorMessage

+
+ } + else if (Model is not null) + { +
+
+
+ +
+ +
+ @if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl)) + { + @BuildSourceImageAltText(Model) + } + else + { +
+

No source image is available for this cell yet.

+
+ } +
+
+ + @{ + var usedLegendEntries = GetUsedLegendEntries(Model, LegendEntries); + } + + @if (usedLegendEntries.Count > 0) + { +
+ @foreach (var entry in usedLegendEntries) + { +
+ @entry.Symbol + @entry.Label +
+ } +
+ } +
+ +
+ + +
+ } +
+
+ +@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] + public IReadOnlyList? LegendEntries { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnClose { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnMarkCurated { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnEdit { get; set; } + + private IJSObjectReference? jsModule; + private bool isBackdropPointerDown; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + jsModule = await JSRuntime.InvokeAsync( + "import", + "./Components/Shared/CriticalCellEditorDialog.razor.js"); + + await jsModule.InvokeVoidAsync("lockBackgroundScroll"); + } + + public async ValueTask DisposeAsync() + { + if (jsModule is null) + { + return; + } + + try + { + await jsModule.InvokeVoidAsync("unlockBackgroundScroll"); + await jsModule.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + + private void HandleBackdropPointerDown() + { + isBackdropPointerDown = true; + } + + private async Task HandleBackdropPointerUp() + { + if (!isBackdropPointerDown) + { + return; + } + + isBackdropPointerDown = false; + await OnClose.InvokeAsync(); + } + + private void HandleBackdropPointerCancel() + { + isBackdropPointerDown = false; + } + + private void HandleDialogPointerDown() + { + isBackdropPointerDown = false; + } + + private void HandleDialogPointerUp() + { + isBackdropPointerDown = false; + } + + private void HandleDialogPointerCancel() + { + isBackdropPointerDown = false; + } + + private static string BuildSourceImageAltText(CriticalCellEditorModel model) + { + var segments = new List + { + model.TableName, + $"roll band {model.RollBand}", + $"column {model.ColumnLabel}" + }; + + if (!string.IsNullOrWhiteSpace(model.GroupLabel)) + { + segments.Add($"variant {model.GroupLabel}"); + } + + return string.Join(", ", segments); + } + + private static string BuildColumnDisplayText(CriticalCellEditorModel model) => + string.IsNullOrWhiteSpace(model.GroupLabel) + ? model.ColumnLabel + : $"{model.GroupLabel} / {model.ColumnLabel}"; + + private static IReadOnlyList BuildPreviewEffects(CriticalCellEditorModel model) => + model.Effects + .Select(effect => new CriticalEffectLookupResponse( + effect.EffectCode, + effect.Target, + effect.ValueInteger, + effect.ValueExpression, + effect.DurationRounds, + effect.PerRound, + effect.Modifier, + effect.BodyPart, + effect.IsPermanent, + effect.SourceType, + effect.SourceText)) + .ToList(); + + private static IReadOnlyList BuildPreviewBranches(CriticalCellEditorModel model) => + model.Branches + .OrderBy(branch => branch.SortOrder) + .Select(branch => new CriticalBranchLookupResponse( + branch.BranchKind, + branch.ConditionKey, + branch.ConditionText, + branch.DescriptionText, + branch.RawAffixText, + branch.Effects + .Select(effect => new CriticalEffectLookupResponse( + effect.EffectCode, + effect.Target, + effect.ValueInteger, + effect.ValueExpression, + effect.DurationRounds, + effect.PerRound, + effect.Modifier, + effect.BodyPart, + effect.IsPermanent, + effect.SourceType, + effect.SourceText)) + .ToList(), + branch.RawText, + branch.SortOrder)) + .ToList(); + + private static IReadOnlyList GetUsedLegendEntries( + CriticalCellEditorModel model, + IReadOnlyList? legendEntries) + { + if (legendEntries is null || legendEntries.Count == 0) + { + return []; + } + + var usedEffectCodes = model.Effects + .Select(effect => effect.EffectCode) + .Concat(model.Branches.SelectMany(branch => branch.Effects.Select(effect => effect.EffectCode))) + .Where(effectCode => !string.IsNullOrWhiteSpace(effectCode)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return legendEntries + .Where(entry => usedEffectCodes.Contains(entry.EffectCode)) + .ToList(); + } +} diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor index a97f7e1..ce93bbf 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor @@ -32,15 +32,6 @@ · Variant @Model.GroupLabel }

-
- - @(Model.IsCurated ? "Curated" : "Needs Curation") - - @if (Model.SourcePageNumber is not null) - { - Source page @Model.SourcePageNumber - } -
} else { @@ -76,47 +67,6 @@

@SaveErrorMessage

} -
-
-
-
-

Curation State

-

Curated cells are protected from importer content overwrites until you unmark them.

-
-
- -

- @(Model.IsCurated - ? "This result will keep its current text, branches, and effects on later imports." - : "This result will be refreshed from the importer on later imports.") -

-
- -
-
-
-

Source Cell

-

Use the importer crop as a visual reference while curating the result.

-
-
- - @if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl)) - { - @BuildSourceImageAltText(Model) - } - else - { -

No source image is available for this cell yet.

- } -
-
-
@@ -133,12 +83,38 @@ @(IsReparsing ? "Generating..." : "Generate From Quick Input")
-
- - +
+
+ + +
+ +
+
+ + @if (Model.SourcePageNumber is not null) + { + Page @Model.SourcePageNumber + } +
+ +
+ @if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl)) + { + @BuildSourceImageAltText(Model) + } + else + { +

No source image is available for this cell yet.

+ } +
+

Example: Foe brings his guard up, frightened by your display. then +5, 1mp or w/o shield: glancing blow, +15, 3s, 3np.