225 lines
15 KiB
Plaintext
225 lines
15 KiB
Plaintext
@model StoryIntelligenceProgressViewModel
|
|
@{
|
|
ViewData["Title"] = "Review characters";
|
|
const int previewLimit = 25;
|
|
var characterCandidates = Model.CharacterReview.Candidates.Take(previewLimit).ToList();
|
|
var hiddenCandidateCount = Math.Max(0, Model.CharacterReview.PendingCandidateCount - characterCandidates.Count);
|
|
}
|
|
|
|
<section class="onboarding-shell" aria-labelledby="story-character-title">
|
|
<div class="onboarding-panel onboarding-review-panel story-review-page">
|
|
<div class="onboarding-copy story-review-heading">
|
|
<p class="eyebrow">Story Intelligence</p>
|
|
<h1 id="story-character-title">Review Characters <help-icon key="storyIntelligence.characters" /></h1>
|
|
<p>Resolve character identity questions before reviewing the rest of the story intelligence.</p>
|
|
</div>
|
|
|
|
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
|
{
|
|
<div class="alert alert-danger">@error</div>
|
|
}
|
|
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
|
|
{
|
|
<div class="alert alert-success">@message</div>
|
|
}
|
|
|
|
<p class="onboarding-compact-status">
|
|
@Model.CharacterReview.Candidates.Count.ToString("N0") candidate@(Model.CharacterReview.Candidates.Count == 1 ? string.Empty : "s")
|
|
· @Model.CharacterReview.ExistingMatchCount.ToString("N0") existing match@(Model.CharacterReview.ExistingMatchCount == 1 ? string.Empty : "es")
|
|
· @Model.CharacterReview.NewCandidateCount.ToString("N0") new or alias
|
|
· @Model.CharacterReview.AmbiguousCount.ToString("N0") ambiguous
|
|
· @Model.CharacterReview.GenericReferenceCount.ToString("N0") generic
|
|
</p>
|
|
|
|
<section class="story-review-chapter-list" aria-label="Character review">
|
|
@if (Model.CharacterReview.Candidates.Count == 0)
|
|
{
|
|
<div class="story-review-note">
|
|
<strong>Character Review is complete.</strong>
|
|
<p class="mb-0">@Model.CharacterReview.AliasTargetOptions.Count.ToString("N0") resolved character@(Model.CharacterReview.AliasTargetOptions.Count == 1 ? string.Empty : "s") are available for this book.</p>
|
|
</div>
|
|
@if (Model.CharacterReview.AliasTargetOptions.Count > 0)
|
|
{
|
|
<div class="story-character-card-grid">
|
|
@foreach (var character in Model.CharacterReview.AliasTargetOptions)
|
|
{
|
|
<article class="story-character-card">
|
|
<div class="story-character-card__body">
|
|
<strong>@character.CharacterName</strong>
|
|
<p class="mb-0 text-muted">Resolved character</p>
|
|
</div>
|
|
</article>
|
|
}
|
|
</div>
|
|
}
|
|
<div class="onboarding-actions">
|
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="story-review-card-actions">
|
|
<button class="btn btn-outline-primary btn-sm" type="button" data-character-bulk="create-all">Create all</button>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="link-existing">Use same-character matches</button>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="ignore-all">Ignore all</button>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="expand">Expand all</button>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
|
|
@if (hiddenCandidateCount > 0)
|
|
{
|
|
<span class="story-review-page-status">Showing @characterCandidates.Count.ToString("N0") of @Model.CharacterReview.PendingCandidateCount.ToString("N0") pending</span>
|
|
}
|
|
</div>
|
|
|
|
<form asp-action="ImportStoryIntelligenceCharacters" method="post" data-character-review-form data-story-review-form>
|
|
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
|
<div class="story-character-card-grid">
|
|
@for (var i = 0; i < characterCandidates.Count; i++)
|
|
{
|
|
var candidate = characterCandidates[i];
|
|
<details class="story-character-card" open data-character-card>
|
|
<summary class="story-review-card-header">
|
|
<span>
|
|
<strong>@candidate.CharacterName</strong>
|
|
<small>@candidate.Classification · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
|
|
</span>
|
|
<em>@candidate.Confidence</em>
|
|
</summary>
|
|
|
|
<input type="hidden" name="Characters[@i].Key" value="@candidate.Key" />
|
|
|
|
<div class="story-character-card__body" data-character-key="@candidate.Key" data-character-name="@candidate.CharacterName">
|
|
<dl>
|
|
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
|
|
<div><dt>Possible aliases</dt><dd>@(candidate.PossibleAliases.Count == 0 ? "None detected" : string.Join(", ", candidate.PossibleAliases))</dd></div>
|
|
@if (!string.IsNullOrWhiteSpace(candidate.NameIntelligenceWarning))
|
|
{
|
|
<div><dt>Name warning</dt><dd class="text-warning">@candidate.NameIntelligenceWarning</dd></div>
|
|
}
|
|
<div><dt>Existing match</dt><dd>@(candidate.ExistingCharacterName ?? "None")</dd></div>
|
|
</dl>
|
|
|
|
@if (candidate.IsExistingMatch)
|
|
{
|
|
<div class="story-review-note">
|
|
<strong>Possible existing character</strong>
|
|
<p>@candidate.CharacterName may already be @candidate.ExistingCharacterName. Choose whether this is the same character or a separate character.</p>
|
|
</div>
|
|
}
|
|
|
|
<fieldset class="story-character-actions">
|
|
<legend>Decision <help-icon key="storyIntelligence.characters.decisions" mode="inline" /></legend>
|
|
<label>
|
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateNew" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.CreateNew) ? "checked" : string.Empty) data-character-action />
|
|
Create new character
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.LinkExisting" data-character-action @(candidate.CanLinkExisting ? string.Empty : "disabled") @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.LinkExisting) ? "checked" : string.Empty) />
|
|
Same character as existing
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.Ignore) ? "checked" : string.Empty) data-character-action />
|
|
Ignore
|
|
</label>
|
|
</fieldset>
|
|
|
|
<div data-character-existing-panel hidden>
|
|
<label class="form-label" for="character-existing-@i">Same character as <help-icon key="storyIntelligence.characters.sameCharacter" mode="inline" /></label>
|
|
<select id="character-existing-@i" class="form-select" name="Characters[@i].ExistingCharacterID" data-character-existing-target>
|
|
<option value="">Choose character...</option>
|
|
@foreach (var existing in candidate.ExistingCharacterOptions)
|
|
{
|
|
if (candidate.SelectedExistingCharacterID == existing.CharacterID)
|
|
{
|
|
<option value="@existing.CharacterID" selected>@existing.CharacterName</option>
|
|
}
|
|
else
|
|
{
|
|
<option value="@existing.CharacterID">@existing.CharacterName</option>
|
|
}
|
|
}
|
|
</select>
|
|
</div>
|
|
|
|
<div data-character-import-name-panel>
|
|
<label class="form-label" for="character-import-name-@i">Import name <help-icon key="storyIntelligence.review.importName" mode="inline" /></label>
|
|
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" />
|
|
</div>
|
|
</div>
|
|
</details>
|
|
}
|
|
</div>
|
|
|
|
<div class="onboarding-actions">
|
|
@if (hiddenCandidateCount > 0)
|
|
{
|
|
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more character decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
|
|
}
|
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
|
@if (Model.CharacterReview.HasPreviousPage)
|
|
{
|
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.CharacterReview.PageNumber - 1)">Previous</a>
|
|
}
|
|
@if (Model.CharacterReview.HasNextPage)
|
|
{
|
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.CharacterReview.PageNumber + 1)">Next</a>
|
|
}
|
|
<button class="btn btn-primary" type="submit" data-busy-label="Saving characters...">Save character decisions</button>
|
|
</div>
|
|
</form>
|
|
}
|
|
</section>
|
|
</div>
|
|
</section>
|
|
|
|
@section Scripts {
|
|
<script>
|
|
(() => {
|
|
const form = document.querySelector("[data-character-review-form]");
|
|
if (!form) return;
|
|
|
|
const cards = () => Array.from(form.querySelectorAll("[data-character-card]"));
|
|
const setAction = (card, action) => {
|
|
const input = card.querySelector(`[data-character-action][value="${action}"]`);
|
|
if (input) {
|
|
input.checked = true;
|
|
updateCard(card);
|
|
}
|
|
};
|
|
const actionFor = (card) => card.querySelector("[data-character-action]:checked")?.value || "@StoryIntelligenceCharacterImportActions.CreateNew";
|
|
const updateCard = (card) => {
|
|
const action = actionFor(card);
|
|
const namePanel = card.querySelector("[data-character-import-name-panel]");
|
|
const existingPanel = card.querySelector("[data-character-existing-panel]");
|
|
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.LinkExisting";
|
|
if (existingPanel) existingPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.LinkExisting";
|
|
};
|
|
document.querySelectorAll("[data-character-bulk]").forEach(button => {
|
|
button.addEventListener("click", () => {
|
|
const action = button.getAttribute("data-character-bulk");
|
|
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.CreateNew"));
|
|
if (action === "link-existing") cards().forEach(card => {
|
|
if (card.querySelector(`[data-character-action][value="@StoryIntelligenceCharacterImportActions.LinkExisting"]:not(:disabled)`)) {
|
|
setAction(card, "@StoryIntelligenceCharacterImportActions.LinkExisting");
|
|
}
|
|
});
|
|
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Ignore"));
|
|
if (action === "expand") cards().forEach(card => card.open = true);
|
|
if (action === "collapse") cards().forEach(card => card.open = false);
|
|
});
|
|
});
|
|
form.querySelectorAll("[data-character-action]").forEach(input => {
|
|
input.addEventListener("change", () => updateCard(input.closest("[data-character-card]")));
|
|
});
|
|
cards().forEach(updateCard);
|
|
})();
|
|
</script>
|
|
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
|
|
}
|
|
|
|
@functions {
|
|
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
|
|
|
|
private static bool IsDefault(StoryIntelligenceCharacterReviewCandidateViewModel candidate, string action)
|
|
=> string.Equals(candidate.DefaultAction, action, StringComparison.OrdinalIgnoreCase);
|
|
}
|