Make onboarding scan review a linear wizard
This commit is contained in:
parent
d835bafb5c
commit
bfe729e8c0
@ -49,7 +49,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("scan-characters")]
|
[HttpGet("scan-characters")]
|
||||||
public async Task<IActionResult> ScanCharacters(Guid? previewId)
|
public async Task<IActionResult> ScanCharacters(Guid? previewId, int page = 1)
|
||||||
{
|
{
|
||||||
var model = await onboarding.GetScanReviewAsync(previewId)
|
var model = await onboarding.GetScanReviewAsync(previewId)
|
||||||
?? (previewId.HasValue ? await onboarding.GetScanReviewAsync() : null);
|
?? (previewId.HasValue ? await onboarding.GetScanReviewAsync() : null);
|
||||||
@ -61,6 +61,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
}
|
}
|
||||||
|
|
||||||
ViewData["ScanReviewStep"] = "characters";
|
ViewData["ScanReviewStep"] = "characters";
|
||||||
|
ViewData["CharacterReviewPage"] = Math.Max(1, page);
|
||||||
return View("ScanReview", model);
|
return View("ScanReview", model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -513,10 +514,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||||
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
|
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
|
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save", int characterPage = 1)
|
||||||
{
|
{
|
||||||
var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase);
|
var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var continueCharacterReview = string.Equals(intent, "character-next", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var backCharacterReview = string.Equals(intent, "character-back", StringComparison.OrdinalIgnoreCase);
|
||||||
var readyToImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
|
var readyToImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var nextCharacterPage = Math.Max(1, characterPage + (backCharacterReview ? -1 : 1));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var model = await onboarding.SaveScanReviewAsync(form, readyToImport);
|
var model = await onboarding.SaveScanReviewAsync(form, readyToImport);
|
||||||
@ -528,9 +532,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
TempData["OnboardingReviewMessage"] = readyToImport
|
TempData["OnboardingReviewMessage"] = readyToImport
|
||||||
? "Review saved. Next: prepare chapters and analyse manuscript."
|
? "Review saved. Next: prepare chapters and analyse manuscript."
|
||||||
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
|
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
|
||||||
|
: continueCharacterReview || backCharacterReview ? "Character review choices saved."
|
||||||
: "Review choices saved.";
|
: "Review choices saved.";
|
||||||
return readyToImport
|
return readyToImport
|
||||||
? RedirectToAction(nameof(StoryIntelligence))
|
? RedirectToAction(nameof(StoryIntelligence))
|
||||||
|
: continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage })
|
||||||
: continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID })
|
: continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID })
|
||||||
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
|
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
|
||||||
}
|
}
|
||||||
@ -543,9 +549,10 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (readyToImport)
|
if (readyToImport || continueCharacterReview || backCharacterReview)
|
||||||
{
|
{
|
||||||
ViewData["ScanReviewStep"] = "characters";
|
ViewData["ScanReviewStep"] = "characters";
|
||||||
|
ViewData["CharacterReviewPage"] = Math.Max(1, characterPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return View("ScanReview", model);
|
return View("ScanReview", model);
|
||||||
|
|||||||
@ -188,13 +188,30 @@ public sealed class OnboardingService(
|
|||||||
var review = new ManuscriptScanReviewDecision
|
var review = new ManuscriptScanReviewDecision
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
Status = readyToImport ? ManuscriptScanReviewStatuses.ReadyToImport : ManuscriptScanReviewStatuses.ReviewInProgress,
|
Status = ManuscriptScanReviewStatuses.ReviewInProgress,
|
||||||
Chapters = chapterDecisions,
|
Chapters = chapterDecisions,
|
||||||
Scenes = sceneDecisions,
|
Scenes = sceneDecisions,
|
||||||
Characters = characterDecisions
|
Characters = characterDecisions
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (readyToImport)
|
||||||
|
{
|
||||||
|
await scanPreviews.SaveReviewAsync(userId, review);
|
||||||
ValidateScanReview(review, preview);
|
ValidateScanReview(review, preview);
|
||||||
|
review = new ManuscriptScanReviewDecision
|
||||||
|
{
|
||||||
|
PreviewID = review.PreviewID,
|
||||||
|
Status = ManuscriptScanReviewStatuses.ReadyToImport,
|
||||||
|
Chapters = review.Chapters,
|
||||||
|
Scenes = review.Scenes,
|
||||||
|
Characters = review.Characters
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ValidateScanStructure(review, preview);
|
||||||
|
}
|
||||||
|
|
||||||
var saved = await scanPreviews.SaveReviewAsync(userId, review);
|
var saved = await scanPreviews.SaveReviewAsync(userId, review);
|
||||||
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters));
|
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters));
|
||||||
}
|
}
|
||||||
@ -439,6 +456,42 @@ public sealed class OnboardingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateScanReview(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
|
private static void ValidateScanReview(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
|
||||||
|
{
|
||||||
|
ValidateScanStructure(review, preview);
|
||||||
|
|
||||||
|
var duplicateName = review.Characters
|
||||||
|
.Where(IsIncludedCanonicalCharacter)
|
||||||
|
.Select(character => Clean(character.Name))
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault(group => group.Count() > 1);
|
||||||
|
if (duplicateName is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"The character name \"{duplicateName.Key}\" appears more than once. Exclude or rename one before continuing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (review.Characters.Any(character => character.Include && string.IsNullOrWhiteSpace(character.Name)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Every included character needs a display name.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var characterLookup = review.Characters.ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||||
|
foreach (var character in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)))
|
||||||
|
{
|
||||||
|
if (string.Equals(character.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("A character candidate cannot be an alias of itself.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!characterLookup.TryGetValue(character.AliasTargetTemporaryCharacterKey!, out var target)
|
||||||
|
|| !IsIncludedCanonicalCharacter(target))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Choose an included primary character for the alias \"{character.Name}\".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateScanStructure(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
|
||||||
{
|
{
|
||||||
var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||||
var previewScenes = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
|
var previewScenes = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
|
||||||
@ -488,37 +541,6 @@ public sealed class OnboardingService(
|
|||||||
throw new InvalidOperationException("One of the selected chapters no longer exists in the scan preview.");
|
throw new InvalidOperationException("One of the selected chapters no longer exists in the scan preview.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var duplicateName = review.Characters
|
|
||||||
.Where(IsIncludedCanonicalCharacter)
|
|
||||||
.Select(character => Clean(character.Name))
|
|
||||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
||||||
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.FirstOrDefault(group => group.Count() > 1);
|
|
||||||
if (duplicateName is not null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"The character name \"{duplicateName.Key}\" appears more than once. Exclude or rename one before continuing.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (review.Characters.Any(character => character.Include && string.IsNullOrWhiteSpace(character.Name)))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Every included character needs a display name.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var characterLookup = review.Characters.ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal);
|
|
||||||
foreach (var character in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)))
|
|
||||||
{
|
|
||||||
if (string.Equals(character.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("A character candidate cannot be an alias of itself.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!characterLookup.TryGetValue(character.AliasTargetTemporaryCharacterKey!, out var target)
|
|
||||||
|| !IsIncludedCanonicalCharacter(target))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Choose an included primary character for the alias \"{character.Name}\".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddScanCharacterAliasesAsync(ManuscriptScanReviewDecision review, OnboardingManuscriptBuildResult result)
|
private async Task AddScanCharacterAliasesAsync(ManuscriptScanReviewDecision review, OnboardingManuscriptBuildResult result)
|
||||||
|
|||||||
@ -66,6 +66,11 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
var included = IncludedChapters(preview, review);
|
var included = IncludedChapters(preview, review);
|
||||||
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
||||||
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
|
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
|
||||||
|
var primaryCharacterCount = preview.CharacterCandidates.Count(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var excludedCharacterCount = preview.CharacterCandidates.Count(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var characterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacterCount / 10d))
|
||||||
|
+ (excludedCharacterCount > 0 ? (int)Math.Ceiling(excludedCharacterCount / 10d) : 0);
|
||||||
|
var totalWizardSteps = 7 + characterPageCount + 2;
|
||||||
return new StoryIntelligenceOverviewViewModel
|
return new StoryIntelligenceOverviewViewModel
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
@ -76,6 +81,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
ApprovedChapterCount = included.Count,
|
ApprovedChapterCount = included.Count,
|
||||||
ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount),
|
ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount),
|
||||||
MissingChapterTextCount = missingTextCount,
|
MissingChapterTextCount = missingTextCount,
|
||||||
|
WizardCurrentStep = totalWizardSteps - 1,
|
||||||
|
WizardTotalSteps = totalWizardSteps,
|
||||||
ExistingBatchID = existingBatch?.BatchID,
|
ExistingBatchID = existingBatch?.BatchID,
|
||||||
CanStart = included.Count > 0 && missingTextCount == 0,
|
CanStart = included.Count > 0 && missingTextCount == 0,
|
||||||
Message = build is null
|
Message = build is null
|
||||||
|
|||||||
@ -36,7 +36,7 @@ public sealed class OnboardingOptionViewModel
|
|||||||
public sealed class OnboardingJourneyHeaderViewModel
|
public sealed class OnboardingJourneyHeaderViewModel
|
||||||
{
|
{
|
||||||
public int CurrentStep { get; init; }
|
public int CurrentStep { get; init; }
|
||||||
public int TotalSteps { get; init; } = 9;
|
public int TotalSteps { get; init; } = 10;
|
||||||
public string AriaLabel { get; init; } = "Manuscript import progress";
|
public string AriaLabel { get; init; } = "Manuscript import progress";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,6 +127,8 @@ public sealed class StoryIntelligenceOverviewViewModel
|
|||||||
public int ApprovedChapterCount { get; init; }
|
public int ApprovedChapterCount { get; init; }
|
||||||
public int ApprovedWordCount { get; init; }
|
public int ApprovedWordCount { get; init; }
|
||||||
public int MissingChapterTextCount { get; init; }
|
public int MissingChapterTextCount { get; init; }
|
||||||
|
public int WizardCurrentStep { get; init; } = 9;
|
||||||
|
public int WizardTotalSteps { get; init; } = 10;
|
||||||
public string? Message { get; init; }
|
public string? Message { get; init; }
|
||||||
public Guid? ExistingBatchID { get; init; }
|
public Guid? ExistingBatchID { get; init; }
|
||||||
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
|
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
|
||||||
|
|||||||
@ -233,7 +233,7 @@
|
|||||||
@if (Model.IsMicrosoftWordPath)
|
@if (Model.IsMicrosoftWordPath)
|
||||||
{
|
{
|
||||||
<h1 id="onboarding-title">@(currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1>
|
<h1 id="onboarding-title">@(currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1>
|
||||||
<p>Open Microsoft Word, open your manuscript, start the PlotDirector Word Companion, and sign in using your PlotDirector account. PlotDirector will stay on this page with you while the scan runs.</p>
|
<p>Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -243,7 +243,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (Model.IsMicrosoftWordPath)
|
@if (Model.IsMicrosoftWordPath)
|
||||||
{
|
{
|
||||||
<section class="onboarding-companion-card @(Model.CompanionPresence.IsConnected ? "is-connected" : "is-offline")"
|
<section class="onboarding-companion-card onboarding-companion-card--compact @(Model.CompanionPresence.IsConnected ? "is-connected" : "is-offline")"
|
||||||
data-word-companion-presence
|
data-word-companion-presence
|
||||||
data-word-companion-status="@Model.CompanionPresence.Status"
|
data-word-companion-status="@Model.CompanionPresence.Status"
|
||||||
aria-label="Word Companion connection">
|
aria-label="Word Companion connection">
|
||||||
@ -296,20 +296,6 @@
|
|||||||
<strong data-onboarding-scan-words>@Model.ScanState.TotalWordCount.ToString("N0")</strong>
|
<strong data-onboarding-scan-words>@Model.ScanState.TotalWordCount.ToString("N0")</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-scan-actions">
|
|
||||||
<button class="btn btn-primary"
|
|
||||||
type="button"
|
|
||||||
data-onboarding-scan-start
|
|
||||||
disabled="@(!CanStartScan(Model.CompanionPresence, Model.ScanState))">
|
|
||||||
Scan manuscript
|
|
||||||
</button>
|
|
||||||
<a class="btn btn-outline-primary @(Model.ScanState.IsComplete ? string.Empty : "disabled")"
|
|
||||||
data-onboarding-scan-review
|
|
||||||
href="@(Model.ScanState.PreviewID.HasValue ? Url.Action("ScanReview", "Onboarding", new { previewId = Model.ScanState.PreviewID }) : "#")"
|
|
||||||
aria-disabled="@(!Model.ScanState.IsComplete)">
|
|
||||||
@(string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) ? "Edit review" : "Review chapters")
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
@if (string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
@if (string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
<div class="onboarding-build-panel" data-onboarding-build-panel>
|
<div class="onboarding-build-panel" data-onboarding-build-panel>
|
||||||
@ -322,51 +308,40 @@
|
|||||||
<span data-onboarding-build-progress></span>
|
<span data-onboarding-build-progress></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-success" asp-controller="Onboarding" asp-action="StoryIntelligence">
|
|
||||||
Analyse manuscript
|
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
<dl class="onboarding-summary">
|
|
||||||
<div>
|
|
||||||
<dt>Writing journey</dt>
|
|
||||||
<dd>@DisplayJourney(Model.WritingJourney)</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Writing software</dt>
|
|
||||||
<dd>@DisplaySoftware(Model.WritingSoftware)</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Project</dt>
|
|
||||||
<dd>@(Model.SelectedProjectName ?? "Not set")</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Book</dt>
|
|
||||||
<dd>@(Model.SelectedBookTitle ?? "Not set")</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
<form asp-controller="Onboarding" asp-action="Back" method="post">
|
<form asp-controller="Onboarding" asp-action="Back" method="post">
|
||||||
<button class="btn btn-outline-secondary" type="submit" name="currentStep" value="@OnboardingSteps.NextPathPreview">Back</button>
|
<button class="btn btn-outline-secondary" type="submit" name="currentStep" value="@OnboardingSteps.NextPathPreview">Back</button>
|
||||||
</form>
|
</form>
|
||||||
@if (Model.ProjectID.HasValue)
|
|
||||||
{
|
|
||||||
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Go to Project Overview</a>
|
|
||||||
}
|
|
||||||
@if (Model.IsMicrosoftWordPath && string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
@if (Model.IsMicrosoftWordPath && string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse manuscript</a>
|
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse manuscript</a>
|
||||||
}
|
}
|
||||||
else if (Model.IsMicrosoftWordPath && Model.ScanState.IsComplete)
|
else if (Model.IsMicrosoftWordPath && Model.ScanState.IsComplete)
|
||||||
{
|
{
|
||||||
<span class="text-muted">Review the scan above to continue.</span>
|
<a class="btn btn-primary"
|
||||||
|
data-onboarding-scan-review
|
||||||
|
href="@(Model.ScanState.PreviewID.HasValue ? Url.Action("ScanReview", "Onboarding", new { previewId = Model.ScanState.PreviewID }) : "#")">
|
||||||
|
Review chapters
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
else if (Model.IsMicrosoftWordPath)
|
else if (Model.IsMicrosoftWordPath)
|
||||||
{
|
{
|
||||||
<span class="text-muted">Scan your manuscript to continue.</span>
|
<button class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
data-onboarding-scan-start
|
||||||
|
disabled="@(!CanStartScan(Model.CompanionPresence, Model.ScanState))">
|
||||||
|
Scan manuscript
|
||||||
|
</button>
|
||||||
|
<a class="visually-hidden"
|
||||||
|
data-onboarding-scan-review
|
||||||
|
href="@(Model.ScanState.PreviewID.HasValue ? Url.Action("ScanReview", "Onboarding", new { previewId = Model.ScanState.PreviewID }) : "#")">
|
||||||
|
Review chapters
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@ -13,32 +13,60 @@
|
|||||||
var excludedCharacters = Model.CharacterCandidates
|
var excludedCharacters = Model.CharacterCandidates
|
||||||
.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
var primaryCharacters = probableCharacters
|
||||||
|
.Concat(possibleCharacters)
|
||||||
|
.Concat(relationshipTitles)
|
||||||
|
.ToList();
|
||||||
var detectedAliasTargets = Model.CharacterCandidates
|
var detectedAliasTargets = Model.CharacterCandidates
|
||||||
.Where(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
.Where(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
||||||
.OrderBy(candidate => candidate.ReviewName)
|
.OrderBy(candidate => candidate.ReviewName)
|
||||||
.ToList();
|
.ToList();
|
||||||
var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count;
|
var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count;
|
||||||
var isCharacterStep = string.Equals(ViewData["ScanReviewStep"] as string, "characters", StringComparison.OrdinalIgnoreCase);
|
var isCharacterStep = string.Equals(ViewData["ScanReviewStep"] as string, "characters", StringComparison.OrdinalIgnoreCase);
|
||||||
var currentStep = isCharacterStep ? 8 : 7;
|
const int characterPageSize = 10;
|
||||||
|
var primaryCharacterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacters.Count / (double)characterPageSize));
|
||||||
|
var excludedCharacterPageCount = excludedCharacters.Any()
|
||||||
|
? (int)Math.Ceiling(excludedCharacters.Count / (double)characterPageSize)
|
||||||
|
: 0;
|
||||||
|
var characterReviewPageCount = primaryCharacterPageCount + excludedCharacterPageCount;
|
||||||
|
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
|
||||||
|
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
|
||||||
|
var isExcludedCharacterPage = excludedCharacterPageCount > 0 && characterPage > primaryCharacterPageCount;
|
||||||
|
var excludedPageIndex = isExcludedCharacterPage ? characterPage - primaryCharacterPageCount : 0;
|
||||||
|
var currentCharacters = isExcludedCharacterPage
|
||||||
|
? excludedCharacters.Skip((excludedPageIndex - 1) * characterPageSize).Take(characterPageSize).ToList()
|
||||||
|
: primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
|
||||||
|
var currentStep = isCharacterStep ? 7 + characterPage : 7;
|
||||||
|
var totalSteps = 7 + characterReviewPageCount + 2;
|
||||||
|
var characterPageTitle = isExcludedCharacterPage
|
||||||
|
? "Review Excluded Characters"
|
||||||
|
: $"Review Characters {characterPage} of {primaryCharacterPageCount}";
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="onboarding-shell" aria-labelledby="scan-review-title">
|
<section class="onboarding-shell" aria-labelledby="scan-review-title">
|
||||||
<form class="onboarding-panel onboarding-review-panel" asp-controller="Onboarding" asp-action="SaveScanReview" method="post">
|
<form class="onboarding-panel onboarding-review-panel" asp-controller="Onboarding" asp-action="SaveScanReview" method="post">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" asp-for="PreviewID" name="PreviewID" />
|
<input type="hidden" asp-for="PreviewID" name="PreviewID" />
|
||||||
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = currentStep, TotalSteps = 10 })" />
|
<input type="hidden" name="characterPage" value="@characterPage" />
|
||||||
|
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = currentStep, TotalSteps = totalSteps })" />
|
||||||
|
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">Step @currentStep of 10</p>
|
<p class="eyebrow">Step @currentStep of @totalSteps</p>
|
||||||
<h1 id="scan-review-title">@(isCharacterStep ? "Review Characters" : "Review Chapters")</h1>
|
<h1 id="scan-review-title">@(isCharacterStep ? characterPageTitle : "Review Chapters")</h1>
|
||||||
<p>@(isCharacterStep ? "Review detected character names, aliases and existing character matches before Story Intelligence begins." : "Confirm the chapter and scene structure before reviewing detected characters.")</p>
|
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the wizard." : "Confirm the chapter and scene structure before reviewing detected characters.")</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
||||||
{
|
{
|
||||||
<div class="alert alert-success">@reviewMessage</div>
|
<div class="alert alert-success">@reviewMessage</div>
|
||||||
}
|
}
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
@if (!ViewData.ModelState.IsValid)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<strong>Review this before continuing.</strong>
|
||||||
|
<div asp-validation-summary="ModelOnly"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||||
<div>
|
<div>
|
||||||
@ -157,34 +185,45 @@
|
|||||||
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden="@(!isCharacterStep)">
|
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden="@(!isCharacterStep)">
|
||||||
<div class="onboarding-review-section-heading">
|
<div class="onboarding-review-section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>Character candidates</h2>
|
<h2>@characterPageTitle</h2>
|
||||||
<p>@visibleCharacterCount.ToString("N0") detected candidate@(visibleCharacterCount == 1 ? string.Empty : "s") before excluded names.</p>
|
<p>
|
||||||
|
@(isExcludedCharacterPage
|
||||||
|
? $"Excluded candidates page {excludedPageIndex} of {excludedCharacterPageCount}."
|
||||||
|
: $"Character candidates page {characterPage} of {primaryCharacterPageCount}.")
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-outline-secondary btn-sm" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to chapters</a>
|
<strong>@currentCharacters.Count character@(currentCharacters.Count == 1 ? string.Empty : "s") on this step</strong>
|
||||||
</div>
|
</div>
|
||||||
@if (!probableCharacters.Any() && !possibleCharacters.Any() && !relationshipTitles.Any())
|
|
||||||
{
|
<div class="onboarding-character-step-summary">
|
||||||
<p>No repeated character names were found yet. You can still prepare chapters and add characters later.</p>
|
<div>
|
||||||
|
<span>Primary candidates</span>
|
||||||
|
<strong>@visibleCharacterCount.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Excluded</span>
|
||||||
|
<strong>@excludedCharacters.Count.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Wizard pages</span>
|
||||||
|
<strong>@characterReviewPageCount.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@{
|
||||||
|
var allCharacters = Model.CharacterCandidates.ToList();
|
||||||
}
|
}
|
||||||
else
|
@if (!currentCharacters.Any())
|
||||||
{
|
{
|
||||||
var characterIndex = 0;
|
<p class="onboarding-empty-state">No character candidates were found for this step.</p>
|
||||||
<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">
|
<div class="onboarding-character-groups">
|
||||||
@foreach (var group in new[]
|
@foreach (var group in new[]
|
||||||
{
|
{
|
||||||
new { Title = "Probable characters", Candidates = probableCharacters },
|
new { Title = "Probable characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||||
new { Title = "Possible characters", Candidates = possibleCharacters },
|
new { Title = "Possible characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||||
new { Title = "Relationship titles", Candidates = relationshipTitles }
|
new { Title = "Relationship titles", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||||
|
new { Title = "Excluded candidates", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase)).ToList() }
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
if (!group.Candidates.Any())
|
if (!group.Candidates.Any())
|
||||||
@ -197,21 +236,22 @@
|
|||||||
<ul class="onboarding-character-candidates">
|
<ul class="onboarding-character-candidates">
|
||||||
@foreach (var candidate in group.Candidates)
|
@foreach (var candidate in group.Candidates)
|
||||||
{
|
{
|
||||||
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason" data-character-page-item>
|
var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
|
||||||
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
<li>
|
||||||
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
|
<input type="hidden" name="Characters[@currentCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
||||||
|
<input type="hidden" name="Characters[@currentCharacterIndex].Category" value="@candidate.Category" />
|
||||||
<label class="onboarding-review-toggle">
|
<label class="onboarding-review-toggle">
|
||||||
<input type="checkbox" name="Characters[@characterIndex].Include" value="true" checked="@candidate.Include" />
|
<input type="checkbox" name="Characters[@currentCharacterIndex].Include" value="true" checked="@candidate.Include" />
|
||||||
<input type="hidden" name="Characters[@characterIndex].Include" value="false" />
|
<input type="hidden" name="Characters[@currentCharacterIndex].Include" value="false" />
|
||||||
Include
|
Include
|
||||||
</label>
|
</label>
|
||||||
<label class="onboarding-review-field">
|
<label class="onboarding-review-field">
|
||||||
Name
|
Name
|
||||||
<input class="form-control" name="Characters[@characterIndex].Name" value="@candidate.ReviewName" />
|
<input class="form-control" name="Characters[@currentCharacterIndex].Name" value="@candidate.ReviewName" />
|
||||||
</label>
|
</label>
|
||||||
<label class="onboarding-review-field">
|
<label class="onboarding-review-field">
|
||||||
Existing character
|
Existing character
|
||||||
<select class="form-select" name="Characters[@characterIndex].ExistingCharacterID">
|
<select class="form-select" name="Characters[@currentCharacterIndex].ExistingCharacterID">
|
||||||
<option value="">Create later if imported</option>
|
<option value="">Create later if imported</option>
|
||||||
@foreach (var option in Model.ExistingCharacterOptions)
|
@foreach (var option in Model.ExistingCharacterOptions)
|
||||||
{
|
{
|
||||||
@ -221,7 +261,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="onboarding-review-field">
|
<label class="onboarding-review-field">
|
||||||
Alias of detected character
|
Alias of detected character
|
||||||
<select class="form-select" name="Characters[@characterIndex].AliasTargetTemporaryCharacterKey">
|
<select class="form-select" name="Characters[@currentCharacterIndex].AliasTargetTemporaryCharacterKey">
|
||||||
<option value="">Not an alias</option>
|
<option value="">Not an alias</option>
|
||||||
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
|
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
|
||||||
{
|
{
|
||||||
@ -243,67 +283,26 @@
|
|||||||
<small>@candidate.Reason</small>
|
<small>@candidate.Reason</small>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
characterIndex++;
|
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@if (excludedCharacters.Any())
|
</div>
|
||||||
|
@foreach (var candidate in allCharacters.Where(candidate => !currentCharacters.Any(current => string.Equals(current.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal))))
|
||||||
{
|
{
|
||||||
<details class="onboarding-excluded-candidates">
|
var hiddenCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
|
||||||
<summary>Show excluded candidates</summary>
|
<div hidden>
|
||||||
<section>
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
||||||
<h3>Excluded candidates</h3>
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].Category" value="@candidate.Category" />
|
||||||
<ul class="onboarding-character-candidates">
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].Include" value="@candidate.Include.ToString().ToLowerInvariant()" />
|
||||||
@foreach (var candidate in excludedCharacters)
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].Name" value="@candidate.ReviewName" />
|
||||||
|
@if (candidate.ExistingCharacterID.HasValue)
|
||||||
{
|
{
|
||||||
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason" data-character-excluded-item>
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].ExistingCharacterID" value="@candidate.ExistingCharacterID" />
|
||||||
<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>
|
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
|
||||||
</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>
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].AliasTargetTemporaryCharacterKey" value="@candidate.AliasTargetTemporaryCharacterKey" />
|
||||||
}
|
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
@ -311,13 +310,29 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
|
|
||||||
@if (isCharacterStep)
|
@if (isCharacterStep)
|
||||||
{
|
{
|
||||||
<button class="btn btn-primary" type="submit" name="intent" value="continue">Save and continue</button>
|
@if (characterPage <= 1)
|
||||||
|
{
|
||||||
|
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to chapters</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<button class="btn btn-outline-secondary" type="submit" name="intent" value="character-back">Back</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (characterPage < characterReviewPageCount)
|
||||||
|
{
|
||||||
|
<button class="btn btn-primary" type="submit" name="intent" value="character-next">Save and continue</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-primary" type="submit" name="intent" value="continue">Save and continue</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<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="characters">Save and review characters</button>
|
<button class="btn btn-primary" type="submit" name="intent" value="characters">Save and review characters</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@ -338,90 +353,4 @@
|
|||||||
checkbox.checked = checked;
|
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 excludedCharacterItems = () => [...document.querySelectorAll("[data-character-excluded-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);
|
|
||||||
if (!section.closest(".onboarding-excluded-candidates")) {
|
|
||||||
section.hidden = !hasVisibleItems;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCharacterPage = () => {
|
|
||||||
const query = characterFilter?.value.trim().toLowerCase() || "";
|
|
||||||
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();
|
|
||||||
excludedCharacterItems().forEach((item) => {
|
|
||||||
const text = `${item.dataset.characterName || ""} ${item.dataset.characterReason || ""}`.toLowerCase();
|
|
||||||
item.hidden = query.length > 0 && !text.includes(query);
|
|
||||||
});
|
|
||||||
|
|
||||||
const emptyState = document.querySelector("[data-character-empty]");
|
|
||||||
if (emptyState) {
|
|
||||||
emptyState.hidden = matches.length > 0 || excludedCharacterItems().some((item) => !item.hidden);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
characterFilter?.addEventListener("input", () => {
|
|
||||||
characterPage = 1;
|
|
||||||
renderCharacterPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
characterPagePrev?.addEventListener("click", () => {
|
|
||||||
characterPage -= 1;
|
|
||||||
renderCharacterPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
characterPageNext?.addEventListener("click", () => {
|
|
||||||
characterPage += 1;
|
|
||||||
renderCharacterPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
renderCharacterPage();
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -5,9 +5,9 @@
|
|||||||
|
|
||||||
<section class="onboarding-shell" aria-labelledby="story-intelligence-title">
|
<section class="onboarding-shell" aria-labelledby="story-intelligence-title">
|
||||||
<div class="onboarding-panel onboarding-analysis-panel">
|
<div class="onboarding-panel onboarding-analysis-panel">
|
||||||
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 9, TotalSteps = 10 })" />
|
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = Model.WizardCurrentStep, TotalSteps = Model.WizardTotalSteps })" />
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">Step 9 of 10</p>
|
<p class="eyebrow">Step @Model.WizardCurrentStep of @Model.WizardTotalSteps</p>
|
||||||
<h1 id="story-intelligence-title">Analyse Manuscript</h1>
|
<h1 id="story-intelligence-title">Analyse Manuscript</h1>
|
||||||
<p>Set the analysis context, then PlotDirector will read the approved chapters and prepare scenes, characters, locations, assets, relationships and knowledge for review.</p>
|
<p>Set the analysis context, then PlotDirector will read the approved chapters and prepare scenes, characters, locations, assets, relationships and knowledge for review.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,27 +1,21 @@
|
|||||||
@model OnboardingJourneyHeaderViewModel
|
@model OnboardingJourneyHeaderViewModel
|
||||||
@{
|
@{
|
||||||
var steps = new[]
|
var characterReviewStepCount = Math.Max(1, Model.TotalSteps - 9);
|
||||||
{
|
var steps = Model.TotalSteps <= 10
|
||||||
new { Label = "Welcome", Order = 1 },
|
? BuildDefaultSteps()
|
||||||
new { Label = "Writing Stage", Order = 2 },
|
: BuildDynamicSteps(characterReviewStepCount, Model.TotalSteps);
|
||||||
new { Label = "Writing Software", Order = 3 },
|
var progressWidth = Model.TotalSteps <= 1
|
||||||
new { Label = "Project", Order = 4 },
|
? 100
|
||||||
new { Label = "Book", Order = 5 },
|
: ((Math.Clamp(Model.CurrentStep, 1, Model.TotalSteps) - 1) * 100) / (Model.TotalSteps - 1);
|
||||||
new { Label = "Connect Word", Order = 6 },
|
|
||||||
new { Label = "Review Chapters", Order = 7 },
|
|
||||||
new { Label = "Review Characters", Order = 8 },
|
|
||||||
new { Label = "Analyse Manuscript", Order = 9 },
|
|
||||||
new { Label = "Complete", Order = 10 }
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="onboarding-progress" aria-label="@Model.AriaLabel">
|
<div class="onboarding-progress" aria-label="@Model.AriaLabel">
|
||||||
<span>Step @Model.CurrentStep of @Model.TotalSteps</span>
|
<span>Step @Model.CurrentStep of @Model.TotalSteps</span>
|
||||||
<div class="onboarding-progress-track">
|
<div class="onboarding-progress-track">
|
||||||
<span style="width:@((Model.CurrentStep * 100) / Model.TotalSteps)%"></span>
|
<span style="width:@progressWidth%"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ol class="onboarding-stepper" aria-label="Manuscript import journey">
|
<ol class="onboarding-stepper" style="--onboarding-step-count:@steps.Count" aria-label="Manuscript import journey">
|
||||||
@foreach (var step in steps)
|
@foreach (var step in steps)
|
||||||
{
|
{
|
||||||
<li class="@StepClass(step.Order, Model.CurrentStep)">
|
<li class="@StepClass(step.Order, Model.CurrentStep)">
|
||||||
@ -32,6 +26,45 @@
|
|||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
@functions {
|
@functions {
|
||||||
|
private sealed record HeaderStep(string Label, int Order);
|
||||||
|
|
||||||
|
private static IReadOnlyList<HeaderStep> BuildDefaultSteps() =>
|
||||||
|
[
|
||||||
|
new("Welcome", 1),
|
||||||
|
new("Writing Stage", 2),
|
||||||
|
new("Writing Software", 3),
|
||||||
|
new("Project", 4),
|
||||||
|
new("Book", 5),
|
||||||
|
new("Connect Word", 6),
|
||||||
|
new("Review Chapters", 7),
|
||||||
|
new("Review Characters", 8),
|
||||||
|
new("Analyse Manuscript", 9),
|
||||||
|
new("Complete", 10)
|
||||||
|
];
|
||||||
|
|
||||||
|
private static IReadOnlyList<HeaderStep> BuildDynamicSteps(int characterReviewStepCount, int totalSteps)
|
||||||
|
{
|
||||||
|
var steps = new List<HeaderStep>
|
||||||
|
{
|
||||||
|
new("Welcome", 1),
|
||||||
|
new("Writing Stage", 2),
|
||||||
|
new("Writing Software", 3),
|
||||||
|
new("Project", 4),
|
||||||
|
new("Book", 5),
|
||||||
|
new("Connect Word", 6),
|
||||||
|
new("Review Chapters", 7)
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var index = 1; index <= characterReviewStepCount; index++)
|
||||||
|
{
|
||||||
|
steps.Add(new HeaderStep($"Characters {index}", 7 + index));
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.Add(new HeaderStep("Analyse Manuscript", totalSteps - 1));
|
||||||
|
steps.Add(new HeaderStep("Complete", totalSteps));
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
private static string StepClass(int order, int currentStep)
|
private static string StepClass(int order, int currentStep)
|
||||||
{
|
{
|
||||||
if (order < currentStep)
|
if (order < currentStep)
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
.onboarding-stepper {
|
.onboarding-stepper {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(10, minmax(0, 1fr));
|
grid-template-columns: repeat(var(--onboarding-step-count, 10), minmax(0, 1fr));
|
||||||
gap: .45rem;
|
gap: .45rem;
|
||||||
margin: 0 0 2rem;
|
margin: 0 0 2rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@ -268,6 +268,11 @@
|
|||||||
background: linear-gradient(135deg, rgba(47, 111, 99, .1), rgba(182, 138, 70, .12));
|
background: linear-gradient(135deg, rgba(47, 111, 99, .1), rgba(182, 138, 70, .12));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-companion-card--compact {
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-companion-mark {
|
.onboarding-companion-mark {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 4.25rem;
|
width: 4.25rem;
|
||||||
@ -1319,36 +1324,31 @@ summary.story-review-chapter-heading {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-character-pager {
|
.onboarding-character-step-summary {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
justify-content: space-between;
|
gap: .65rem;
|
||||||
gap: .75rem;
|
}
|
||||||
|
|
||||||
|
.onboarding-character-step-summary div {
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid rgba(31, 42, 68, .1);
|
border: 1px solid rgba(31, 42, 68, .1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: .6rem;
|
padding: .7rem;
|
||||||
background: rgba(255, 255, 255, .62);
|
background: rgba(255, 255, 255, .62);
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-character-pager span {
|
.onboarding-character-step-summary span {
|
||||||
|
display: block;
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: .76rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: center;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-excluded-candidates {
|
.onboarding-character-step-summary strong {
|
||||||
border-top: 1px solid rgba(31, 42, 68, .12);
|
display: block;
|
||||||
padding-top: .65rem;
|
margin-top: .15rem;
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-excluded-candidates summary {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--bs-secondary-color);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-excluded-candidates .onboarding-character-candidates {
|
|
||||||
margin-top: .65rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-summary div {
|
.onboarding-summary div {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user