439 lines
27 KiB
Plaintext
439 lines
27 KiB
Plaintext
@model ManuscriptScanReviewViewModel
|
|
@{
|
|
ViewData["Title"] = "Review manuscript scan";
|
|
var probableCharacters = Model.CharacterCandidates
|
|
.Where(candidate => string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
var possibleCharacters = Model.CharacterCandidates
|
|
.Where(candidate => string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
var relationshipTitles = Model.CharacterCandidates
|
|
.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
var excludedCharacters = Model.CharacterCandidates
|
|
.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
var detectedAliasTargets = Model.CharacterCandidates
|
|
.Where(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
|
.OrderBy(candidate => candidate.ReviewName)
|
|
.ToList();
|
|
var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count;
|
|
}
|
|
|
|
<section class="onboarding-shell" aria-labelledby="scan-review-title">
|
|
<form class="onboarding-panel onboarding-review-panel" asp-controller="Onboarding" asp-action="SaveScanReview" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<input type="hidden" asp-for="PreviewID" name="PreviewID" />
|
|
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 7 })" />
|
|
|
|
<div class="onboarding-copy">
|
|
<p class="eyebrow">Step 7 of 9</p>
|
|
<h1 id="scan-review-title">Review Manuscript Scan</h1>
|
|
<p>Confirm the chapter structure first, then review the detected character names and aliases before Story Intelligence begins.</p>
|
|
</div>
|
|
|
|
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
|
{
|
|
<div class="alert alert-success">@reviewMessage</div>
|
|
}
|
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
|
|
|
<div class="onboarding-scan-counts onboarding-review-counts">
|
|
<div>
|
|
<span>Chapters</span>
|
|
<strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong>
|
|
</div>
|
|
<div>
|
|
<span>Scenes</span>
|
|
<strong>@Model.SelectedSceneCount / @Model.SceneCount</strong>
|
|
</div>
|
|
<div>
|
|
<span>Characters</span>
|
|
<strong>@Model.SelectedCharacterCount / @Model.CharacterCandidateCount</strong>
|
|
</div>
|
|
<div>
|
|
<span>Words</span>
|
|
<strong>@Model.SelectedWordCount.ToString("N0")</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="onboarding-review-tools" data-review-tools>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="true">Include all chapters</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="false">Exclude all chapters</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="scene" data-bulk-state="true">Include all scenes</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="scene" data-bulk-state="false">Exclude all scenes</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
|
|
</div>
|
|
|
|
<section class="onboarding-review-workflow" aria-label="Scan preview" data-scan-review-tabs>
|
|
<div class="onboarding-review-tabs" role="tablist" aria-label="Scan review sections">
|
|
<button class="is-active" type="button" role="tab" aria-selected="true" data-scan-review-tab="structure">1. Chapters and scenes</button>
|
|
<button type="button" role="tab" aria-selected="false" data-scan-review-tab="characters">2. Characters</button>
|
|
</div>
|
|
|
|
<div class="onboarding-review-section" data-scan-review-panel="structure">
|
|
<h2>Chapters and scenes</h2>
|
|
@if (!Model.Chapters.Any())
|
|
{
|
|
<p>No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="onboarding-review-chapters">
|
|
@{
|
|
var chapterIndex = 0;
|
|
var sceneIndex = 0;
|
|
}
|
|
@foreach (var chapter in Model.Chapters)
|
|
{
|
|
<details class="onboarding-review-chapter" open>
|
|
<summary>
|
|
<span>Chapter @chapter.ChapterNumber</span>
|
|
<strong>@chapter.ReviewTitle</strong>
|
|
<small>@chapter.WordCount.ToString("N0") words / @chapter.Scenes.Count scene@(chapter.Scenes.Count == 1 ? string.Empty : "s")</small>
|
|
</summary>
|
|
<input type="hidden" name="Chapters[@chapterIndex].TemporaryChapterKey" value="@chapter.TemporaryChapterKey" />
|
|
<input type="hidden" name="Chapters[@chapterIndex].ChapterNumber" value="@chapter.ChapterNumber" />
|
|
<label class="onboarding-review-toggle">
|
|
<input type="checkbox" name="Chapters[@chapterIndex].Include" value="true" checked="@chapter.Include" data-review-kind="chapter" />
|
|
<input type="hidden" name="Chapters[@chapterIndex].Include" value="false" />
|
|
Include chapter
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Chapter title
|
|
<input class="form-control" name="Chapters[@chapterIndex].Title" value="@chapter.ReviewTitle" />
|
|
</label>
|
|
|
|
@if (chapter.Scenes.Any())
|
|
{
|
|
<ol>
|
|
@foreach (var scene in chapter.Scenes)
|
|
{
|
|
<li>
|
|
<input type="hidden" name="Scenes[@sceneIndex].TemporarySceneKey" value="@scene.TemporarySceneKey" />
|
|
<input type="hidden" name="Scenes[@sceneIndex].TemporaryChapterKey" value="@scene.TemporaryChapterKey" />
|
|
<input type="hidden" name="Scenes[@sceneIndex].SceneNumberWithinChapter" value="@scene.SceneNumberWithinChapter" />
|
|
<label class="onboarding-review-toggle">
|
|
<input type="checkbox" name="Scenes[@sceneIndex].Include" value="true" checked="@scene.Include" data-review-kind="scene" />
|
|
<input type="hidden" name="Scenes[@sceneIndex].Include" value="false" />
|
|
Include scene
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Scene title
|
|
<input class="form-control" name="Scenes[@sceneIndex].Title" value="@scene.ReviewTitle" placeholder="Scene @scene.SceneNumberWithinChapter" />
|
|
</label>
|
|
<span>@scene.WordCount.ToString("N0") words</span>
|
|
@if (scene.WordCount == 0)
|
|
{
|
|
<label class="onboarding-review-toggle">
|
|
<input type="checkbox" name="Scenes[@sceneIndex].AllowZeroWords" value="true" />
|
|
<input type="hidden" name="Scenes[@sceneIndex].AllowZeroWords" value="false" />
|
|
Allow empty scene
|
|
</label>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(scene.OpeningTextPreview))
|
|
{
|
|
<p>@scene.OpeningTextPreview</p>
|
|
}
|
|
</li>
|
|
sceneIndex++;
|
|
}
|
|
</ol>
|
|
}
|
|
else
|
|
{
|
|
<p>No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.</p>
|
|
}
|
|
</details>
|
|
chapterIndex++;
|
|
}
|
|
</div>
|
|
}
|
|
<div class="onboarding-actions onboarding-actions--inline">
|
|
<button class="btn btn-primary" type="button" data-scan-review-next="characters">Continue to characters</button>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden>
|
|
<div class="onboarding-review-section-heading">
|
|
<div>
|
|
<h2>Character candidates</h2>
|
|
<p>@visibleCharacterCount.ToString("N0") detected candidate@(visibleCharacterCount == 1 ? string.Empty : "s") before excluded names.</p>
|
|
</div>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-scan-review-next="structure">Back to chapters</button>
|
|
</div>
|
|
@if (!probableCharacters.Any() && !possibleCharacters.Any() && !relationshipTitles.Any())
|
|
{
|
|
<p>No repeated character names were found yet. You can still prepare chapters and add characters later.</p>
|
|
}
|
|
else
|
|
{
|
|
var characterIndex = 0;
|
|
<label class="onboarding-character-filter">
|
|
Find a candidate
|
|
<input class="form-control" type="search" placeholder="Search names or notes" data-character-filter />
|
|
</label>
|
|
<div class="onboarding-character-pager" data-character-pager hidden>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-page-prev>Previous</button>
|
|
<span data-character-page-label></span>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-page-next>Next</button>
|
|
</div>
|
|
<p class="onboarding-empty-state" data-character-empty hidden>No matching candidates.</p>
|
|
<div class="onboarding-character-groups">
|
|
@foreach (var group in new[]
|
|
{
|
|
new { Title = "Probable characters", Candidates = probableCharacters },
|
|
new { Title = "Possible characters", Candidates = possibleCharacters },
|
|
new { Title = "Relationship titles", Candidates = relationshipTitles }
|
|
})
|
|
{
|
|
if (!group.Candidates.Any())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
<section>
|
|
<h3>@group.Title</h3>
|
|
<ul class="onboarding-character-candidates">
|
|
@foreach (var candidate in group.Candidates)
|
|
{
|
|
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason" data-character-page-item>
|
|
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
|
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
|
|
<label class="onboarding-review-toggle">
|
|
<input type="checkbox" name="Characters[@characterIndex].Include" value="true" checked="@candidate.Include" />
|
|
<input type="hidden" name="Characters[@characterIndex].Include" value="false" />
|
|
Include
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Name
|
|
<input class="form-control" name="Characters[@characterIndex].Name" value="@candidate.ReviewName" />
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Existing character
|
|
<select class="form-select" name="Characters[@characterIndex].ExistingCharacterID">
|
|
<option value="">Create later if imported</option>
|
|
@foreach (var option in Model.ExistingCharacterOptions)
|
|
{
|
|
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
|
|
}
|
|
</select>
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Alias of detected character
|
|
<select class="form-select" name="Characters[@characterIndex].AliasTargetTemporaryCharacterKey">
|
|
<option value="">Not an alias</option>
|
|
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
|
|
{
|
|
<option value="@target.TemporaryCharacterKey" selected="@(string.Equals(candidate.AliasTargetTemporaryCharacterKey, target.TemporaryCharacterKey, StringComparison.Ordinal))">@target.ReviewName</option>
|
|
}
|
|
</select>
|
|
</label>
|
|
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
|
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
|
|
{
|
|
<small>Alias of detected character: @candidate.AliasTargetName</small>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName))
|
|
{
|
|
<small>Matches existing character: @candidate.ExistingCharacterName</small>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
|
|
{
|
|
<small>@candidate.Reason</small>
|
|
}
|
|
</li>
|
|
characterIndex++;
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
@if (excludedCharacters.Any())
|
|
{
|
|
<details class="onboarding-excluded-candidates">
|
|
<summary>Show excluded candidates</summary>
|
|
<section>
|
|
<h3>Excluded candidates</h3>
|
|
<ul class="onboarding-character-candidates">
|
|
@foreach (var candidate in excludedCharacters)
|
|
{
|
|
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason" data-character-page-item>
|
|
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
|
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
|
|
<label class="onboarding-review-toggle">
|
|
<input type="checkbox" name="Characters[@characterIndex].Include" value="true" checked="@candidate.Include" />
|
|
<input type="hidden" name="Characters[@characterIndex].Include" value="false" />
|
|
Include
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Name
|
|
<input class="form-control" name="Characters[@characterIndex].Name" value="@candidate.ReviewName" />
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Existing character
|
|
<select class="form-select" name="Characters[@characterIndex].ExistingCharacterID">
|
|
<option value="">Create later if imported</option>
|
|
@foreach (var option in Model.ExistingCharacterOptions)
|
|
{
|
|
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
|
|
}
|
|
</select>
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Alias of detected character
|
|
<select class="form-select" name="Characters[@characterIndex].AliasTargetTemporaryCharacterKey">
|
|
<option value="">Not an alias</option>
|
|
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
|
|
{
|
|
<option value="@target.TemporaryCharacterKey" selected="@(string.Equals(candidate.AliasTargetTemporaryCharacterKey, target.TemporaryCharacterKey, StringComparison.Ordinal))">@target.ReviewName</option>
|
|
}
|
|
</select>
|
|
</label>
|
|
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
|
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
|
|
{
|
|
<small>Alias of detected character: @candidate.AliasTargetName</small>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
|
|
{
|
|
<small>@candidate.Reason</small>
|
|
}
|
|
</li>
|
|
characterIndex++;
|
|
}
|
|
</ul>
|
|
</section>
|
|
</details>
|
|
}
|
|
</div>
|
|
}
|
|
</aside>
|
|
</section>
|
|
|
|
<div class="onboarding-actions">
|
|
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
|
|
<button class="btn btn-primary" type="submit" name="intent" value="continue">Save and continue</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<script>
|
|
const scanReviewRoot = document.querySelector("[data-scan-review-tabs]");
|
|
const showScanPanel = (panelName) => {
|
|
if (!scanReviewRoot) {
|
|
return;
|
|
}
|
|
|
|
scanReviewRoot.querySelectorAll("[data-scan-review-tab]").forEach((tab) => {
|
|
const isActive = tab.dataset.scanReviewTab === panelName;
|
|
tab.classList.toggle("is-active", isActive);
|
|
tab.setAttribute("aria-selected", String(isActive));
|
|
});
|
|
scanReviewRoot.querySelectorAll("[data-scan-review-panel]").forEach((panel) => {
|
|
panel.hidden = panel.dataset.scanReviewPanel !== panelName;
|
|
});
|
|
};
|
|
scanReviewRoot?.addEventListener("click", (event) => {
|
|
const tab = event.target.closest("[data-scan-review-tab]");
|
|
if (tab) {
|
|
showScanPanel(tab.dataset.scanReviewTab);
|
|
}
|
|
|
|
const next = event.target.closest("[data-scan-review-next]");
|
|
if (next) {
|
|
showScanPanel(next.dataset.scanReviewNext);
|
|
}
|
|
});
|
|
|
|
const reviewTools = document.querySelector("[data-review-tools]");
|
|
reviewTools?.addEventListener("click", (event) => {
|
|
const button = event.target.closest("[data-bulk-target]");
|
|
if (!button) {
|
|
return;
|
|
}
|
|
|
|
const target = button.dataset.bulkTarget;
|
|
const checked = button.dataset.bulkState === "true";
|
|
document.querySelectorAll(`[data-review-kind="${target}"]`).forEach((checkbox) => {
|
|
checkbox.checked = checked;
|
|
});
|
|
});
|
|
|
|
const characterFilter = document.querySelector("[data-character-filter]");
|
|
const characterPager = document.querySelector("[data-character-pager]");
|
|
const characterPageLabel = document.querySelector("[data-character-page-label]");
|
|
const characterPagePrev = document.querySelector("[data-character-page-prev]");
|
|
const characterPageNext = document.querySelector("[data-character-page-next]");
|
|
const characterPageSize = 20;
|
|
let characterPage = 1;
|
|
|
|
const characterItems = () => [...document.querySelectorAll("[data-character-page-item]")];
|
|
const matchingCharacterItems = () => {
|
|
const query = characterFilter?.value.trim().toLowerCase() || "";
|
|
return characterItems().filter((item) => {
|
|
const text = `${item.dataset.characterName || ""} ${item.dataset.characterReason || ""}`.toLowerCase();
|
|
return query.length === 0 || text.includes(query);
|
|
});
|
|
};
|
|
|
|
const refreshCharacterSections = () => {
|
|
document.querySelectorAll(".onboarding-character-groups section").forEach((section) => {
|
|
const hasVisibleItems = [...section.querySelectorAll("[data-character-page-item]")].some((item) => !item.hidden);
|
|
section.hidden = !hasVisibleItems;
|
|
});
|
|
};
|
|
|
|
const renderCharacterPage = () => {
|
|
const matches = matchingCharacterItems();
|
|
const pageCount = Math.max(1, Math.ceil(matches.length / characterPageSize));
|
|
characterPage = Math.min(Math.max(characterPage, 1), pageCount);
|
|
const start = (characterPage - 1) * characterPageSize;
|
|
const visible = new Set(matches.slice(start, start + characterPageSize));
|
|
|
|
characterItems().forEach((item) => {
|
|
item.hidden = !visible.has(item);
|
|
});
|
|
|
|
if (characterPager) {
|
|
characterPager.hidden = matches.length <= characterPageSize;
|
|
}
|
|
|
|
if (characterPageLabel) {
|
|
characterPageLabel.textContent = matches.length === 0
|
|
? "No candidates"
|
|
: `Page ${characterPage} of ${pageCount} / ${matches.length.toLocaleString()} candidate${matches.length === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
if (characterPagePrev) {
|
|
characterPagePrev.disabled = characterPage <= 1;
|
|
}
|
|
|
|
if (characterPageNext) {
|
|
characterPageNext.disabled = characterPage >= pageCount;
|
|
}
|
|
|
|
refreshCharacterSections();
|
|
const emptyState = document.querySelector("[data-character-empty]");
|
|
if (emptyState) {
|
|
emptyState.hidden = matches.length > 0;
|
|
}
|
|
};
|
|
|
|
characterFilter?.addEventListener("input", () => {
|
|
characterPage = 1;
|
|
renderCharacterPage();
|
|
});
|
|
|
|
characterPagePrev?.addEventListener("click", () => {
|
|
characterPage -= 1;
|
|
renderCharacterPage();
|
|
});
|
|
|
|
characterPageNext?.addEventListener("click", () => {
|
|
characterPage += 1;
|
|
renderCharacterPage();
|
|
});
|
|
|
|
renderCharacterPage();
|
|
</script>
|