Phase 20E Build the review and edit stage for the scan preview.
This commit is contained in:
parent
5ee9ffbdd1
commit
f78e1ac8fc
@ -24,6 +24,39 @@ public sealed class OnboardingController(IOnboardingService onboarding) : Contro
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost("scan-review")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
|
||||
{
|
||||
var readyToImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
var model = await onboarding.SaveScanReviewAsync(form, readyToImport);
|
||||
if (model is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
TempData["OnboardingReviewMessage"] = readyToImport
|
||||
? "Review saved. Next: import into PlotDirector."
|
||||
: "Review choices saved.";
|
||||
return readyToImport
|
||||
? RedirectToAction(nameof(Index))
|
||||
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, ex.Message);
|
||||
var model = await onboarding.GetScanReviewAsync(form.PreviewID);
|
||||
if (model is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return View("ScanReview", model);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("welcome")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Welcome()
|
||||
|
||||
@ -13,6 +13,14 @@ public static class ManuscriptScanStatuses
|
||||
public const string Failed = "Failed";
|
||||
}
|
||||
|
||||
public static class ManuscriptScanReviewStatuses
|
||||
{
|
||||
public const string ScanComplete = "ScanComplete";
|
||||
public const string ReviewInProgress = "ReviewInProgress";
|
||||
public const string ReviewComplete = "ReviewComplete";
|
||||
public const string ReadyToImport = "ReadyToImport";
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanCommand
|
||||
{
|
||||
public int UserID { get; init; }
|
||||
@ -98,6 +106,7 @@ public sealed class ManuscriptScanCharacterCandidatePreview
|
||||
public sealed class ManuscriptScanState
|
||||
{
|
||||
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
|
||||
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public int? PercentComplete { get; init; }
|
||||
public int ChapterCount { get; init; }
|
||||
@ -107,3 +116,40 @@ public sealed class ManuscriptScanState
|
||||
public Guid? PreviewID { get; init; }
|
||||
public DateTime? UpdatedUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanReviewDecision
|
||||
{
|
||||
public Guid PreviewID { get; init; }
|
||||
public string Status { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
|
||||
public IReadOnlyList<ManuscriptScanChapterReviewDecision> Chapters { get; init; } = [];
|
||||
public IReadOnlyList<ManuscriptScanSceneReviewDecision> Scenes { get; init; } = [];
|
||||
public IReadOnlyList<ManuscriptScanCharacterReviewDecision> Characters { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanChapterReviewDecision
|
||||
{
|
||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||
public bool Include { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public int ChapterNumber { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanSceneReviewDecision
|
||||
{
|
||||
public string TemporarySceneKey { get; init; } = string.Empty;
|
||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||
public bool Include { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public int SceneNumberWithinChapter { get; init; }
|
||||
public bool AllowZeroWords { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanCharacterReviewDecision
|
||||
{
|
||||
public string TemporaryCharacterKey { get; init; } = string.Empty;
|
||||
public bool Include { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Category { get; init; } = "PossibleCharacter";
|
||||
public int? ExistingCharacterID { get; init; }
|
||||
}
|
||||
|
||||
@ -13,12 +13,15 @@ public interface IManuscriptScanPreviewStore
|
||||
Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId);
|
||||
Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId);
|
||||
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
|
||||
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
|
||||
Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review);
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
|
||||
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
|
||||
private readonly ConcurrentDictionary<Guid, ManuscriptScanReviewDecision> reviews = new();
|
||||
|
||||
public Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId)
|
||||
{
|
||||
@ -71,6 +74,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||
});
|
||||
|
||||
session.Status = ManuscriptScanStatuses.Complete;
|
||||
session.ReviewStatus = ManuscriptScanReviewStatuses.ScanComplete;
|
||||
session.Message = "Scan complete";
|
||||
session.PercentComplete = 100;
|
||||
session.ChapterCount = stored.ChapterCount;
|
||||
@ -132,10 +136,48 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||
public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId)
|
||||
=> Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null);
|
||||
|
||||
public Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId)
|
||||
{
|
||||
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
|
||||
{
|
||||
return Task.FromResult<ManuscriptScanReviewDecision?>(null);
|
||||
}
|
||||
|
||||
var review = reviews.GetOrAdd(previewId, _ => BuildDefaultReview(preview));
|
||||
return Task.FromResult<ManuscriptScanReviewDecision?>(review);
|
||||
}
|
||||
|
||||
public Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review)
|
||||
{
|
||||
if (!previews.TryGetValue(review.PreviewID, out var preview) || preview.UserID != userId)
|
||||
{
|
||||
return Task.FromResult<ManuscriptScanReviewDecision?>(null);
|
||||
}
|
||||
|
||||
var stored = new ManuscriptScanReviewDecision
|
||||
{
|
||||
PreviewID = review.PreviewID,
|
||||
Status = review.Status,
|
||||
UpdatedUtc = DateTime.UtcNow,
|
||||
Chapters = review.Chapters,
|
||||
Scenes = review.Scenes,
|
||||
Characters = review.Characters
|
||||
};
|
||||
reviews[review.PreviewID] = stored;
|
||||
if (sessions.TryGetValue((userId, preview.OnboardingID), out var session))
|
||||
{
|
||||
session.ReviewStatus = stored.Status;
|
||||
session.UpdatedUtc = stored.UpdatedUtc;
|
||||
}
|
||||
|
||||
return Task.FromResult<ManuscriptScanReviewDecision?>(stored);
|
||||
}
|
||||
|
||||
private static ManuscriptScanState ToState(ScanSession session)
|
||||
=> new()
|
||||
{
|
||||
Status = session.Status,
|
||||
ReviewStatus = session.ReviewStatus,
|
||||
Message = session.Message,
|
||||
PercentComplete = session.PercentComplete,
|
||||
ChapterCount = session.ChapterCount,
|
||||
@ -152,6 +194,44 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned;
|
||||
}
|
||||
|
||||
private static ManuscriptScanReviewDecision BuildDefaultReview(ManuscriptScanPreview preview)
|
||||
=> new()
|
||||
{
|
||||
PreviewID = preview.PreviewID,
|
||||
Status = ManuscriptScanReviewStatuses.ScanComplete,
|
||||
Chapters = preview.Chapters
|
||||
.OrderBy(chapter => chapter.ChapterNumber)
|
||||
.Select(chapter => new ManuscriptScanChapterReviewDecision
|
||||
{
|
||||
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
||||
Include = !string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0,
|
||||
Title = chapter.Title,
|
||||
ChapterNumber = chapter.ChapterNumber
|
||||
})
|
||||
.ToList(),
|
||||
Scenes = preview.Scenes
|
||||
.OrderBy(scene => scene.TemporaryChapterKey)
|
||||
.ThenBy(scene => scene.SceneNumberWithinChapter)
|
||||
.Select(scene => new ManuscriptScanSceneReviewDecision
|
||||
{
|
||||
TemporarySceneKey = scene.TemporarySceneKey,
|
||||
TemporaryChapterKey = scene.TemporaryChapterKey,
|
||||
Include = true,
|
||||
Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title,
|
||||
SceneNumberWithinChapter = scene.SceneNumberWithinChapter
|
||||
})
|
||||
.ToList(),
|
||||
Characters = preview.CharacterCandidates
|
||||
.Select(candidate => new ManuscriptScanCharacterReviewDecision
|
||||
{
|
||||
TemporaryCharacterKey = candidate.TemporaryCharacterKey,
|
||||
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
|
||||
Name = candidate.Name,
|
||||
Category = candidate.Category
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
private sealed class ScanSession
|
||||
{
|
||||
public int UserID { get; init; }
|
||||
@ -159,6 +239,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string Status { get; set; } = ManuscriptScanStatuses.NotStarted;
|
||||
public string ReviewStatus { get; set; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int? PercentComplete { get; set; }
|
||||
public int ChapterCount { get; set; }
|
||||
|
||||
@ -9,6 +9,7 @@ public interface IOnboardingService
|
||||
Task<UserOnboardingState> GetOrCreateAsync();
|
||||
Task<OnboardingWizardViewModel> GetWizardAsync();
|
||||
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
|
||||
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
|
||||
Task<bool> ShouldShowOnboardingAsync();
|
||||
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
|
||||
Task<bool> ShouldShowWordCompanionHeaderAsync();
|
||||
@ -29,6 +30,7 @@ public sealed class OnboardingService(
|
||||
IProjectActivityService activity,
|
||||
IWordCompanionPresenceService companionPresence,
|
||||
IManuscriptScanPreviewStore scanPreviews,
|
||||
ICharacterRepository characters,
|
||||
ICurrentUserService currentUser) : IOnboardingService
|
||||
{
|
||||
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
|
||||
@ -102,6 +104,87 @@ public sealed class OnboardingService(
|
||||
}
|
||||
|
||||
public async Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null)
|
||||
{
|
||||
var context = await GetScanReviewContextAsync(previewId);
|
||||
return context is null ? null : BuildScanReviewViewModel(context.Value);
|
||||
}
|
||||
|
||||
public async Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport)
|
||||
{
|
||||
var context = await GetScanReviewContextAsync(form.PreviewID);
|
||||
if (context is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var (userId, preview, _, project, book, existingCharacters) = context.Value;
|
||||
var chapterLookup = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||
var sceneLookup = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
|
||||
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||
var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet();
|
||||
|
||||
var chapterDecisions = form.Chapters
|
||||
.Where(item => chapterLookup.ContainsKey(item.TemporaryChapterKey))
|
||||
.Select(item => new ManuscriptScanChapterReviewDecision
|
||||
{
|
||||
TemporaryChapterKey = item.TemporaryChapterKey,
|
||||
Include = item.Include,
|
||||
Title = Clean(item.Title),
|
||||
ChapterNumber = item.ChapterNumber
|
||||
})
|
||||
.OrderBy(item => item.ChapterNumber)
|
||||
.ToList();
|
||||
|
||||
var includedChapterKeys = chapterDecisions.Where(item => item.Include).Select(item => item.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
||||
var sceneDecisions = form.Scenes
|
||||
.Where(item => sceneLookup.ContainsKey(item.TemporarySceneKey))
|
||||
.Select(item => new ManuscriptScanSceneReviewDecision
|
||||
{
|
||||
TemporarySceneKey = item.TemporarySceneKey,
|
||||
TemporaryChapterKey = item.TemporaryChapterKey,
|
||||
Include = item.Include && includedChapterKeys.Contains(item.TemporaryChapterKey),
|
||||
Title = CleanOptional(item.Title),
|
||||
SceneNumberWithinChapter = item.SceneNumberWithinChapter,
|
||||
AllowZeroWords = item.AllowZeroWords
|
||||
})
|
||||
.OrderBy(item => item.TemporaryChapterKey)
|
||||
.ThenBy(item => item.SceneNumberWithinChapter)
|
||||
.ToList();
|
||||
|
||||
var characterDecisions = form.Characters
|
||||
.Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey))
|
||||
.Select(item =>
|
||||
{
|
||||
var cleanName = Clean(item.Name);
|
||||
var matchedExistingId = item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
|
||||
? item.ExistingCharacterID
|
||||
: MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID;
|
||||
return new ManuscriptScanCharacterReviewDecision
|
||||
{
|
||||
TemporaryCharacterKey = item.TemporaryCharacterKey,
|
||||
Include = item.Include,
|
||||
Name = cleanName,
|
||||
Category = item.Category,
|
||||
ExistingCharacterID = matchedExistingId
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var review = new ManuscriptScanReviewDecision
|
||||
{
|
||||
PreviewID = preview.PreviewID,
|
||||
Status = readyToImport ? ManuscriptScanReviewStatuses.ReadyToImport : ManuscriptScanReviewStatuses.ReviewInProgress,
|
||||
Chapters = chapterDecisions,
|
||||
Scenes = sceneDecisions,
|
||||
Characters = characterDecisions
|
||||
};
|
||||
|
||||
ValidateScanReview(review, preview);
|
||||
var saved = await scanPreviews.SaveReviewAsync(userId, review);
|
||||
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters));
|
||||
}
|
||||
|
||||
private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters)?> GetScanReviewContextAsync(Guid? previewId)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
var state = await onboarding.GetAsync(userId);
|
||||
@ -123,14 +206,111 @@ public sealed class OnboardingService(
|
||||
|
||||
var project = await projects.GetForUserAsync(preview.ProjectID, userId);
|
||||
var book = await books.GetAsync(preview.BookID);
|
||||
if (project is null || book is null || book.ProjectID != project.ProjectID)
|
||||
if (project is null || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var review = await scanPreviews.GetReviewAsync(userId, preview.PreviewID);
|
||||
if (review is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var existingCharacters = await characters.ListCharactersAsync(project.ProjectID);
|
||||
return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList());
|
||||
}
|
||||
|
||||
private static ManuscriptScanReviewViewModel BuildScanReviewViewModel(
|
||||
(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters) context)
|
||||
{
|
||||
var (userId, preview, review, project, book, existingCharacters) = context;
|
||||
_ = userId;
|
||||
var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal);
|
||||
var sceneReview = review.Scenes.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal);
|
||||
var characterReview = review.Characters.ToDictionary(item => item.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||
var existingOptions = existingCharacters
|
||||
.OrderBy(character => character.CharacterName)
|
||||
.Select(character => new ManuscriptScanExistingCharacterOptionViewModel
|
||||
{
|
||||
CharacterID = character.CharacterID,
|
||||
CharacterName = character.CharacterName
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var chapters = preview.Chapters
|
||||
.OrderBy(chapter => chapter.ChapterNumber)
|
||||
.Select(chapter =>
|
||||
{
|
||||
chapterReview.TryGetValue(chapter.TemporaryChapterKey, out var decision);
|
||||
var scenes = preview.Scenes
|
||||
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
|
||||
.OrderBy(scene => scene.SceneNumberWithinChapter)
|
||||
.Select(scene =>
|
||||
{
|
||||
sceneReview.TryGetValue(scene.TemporarySceneKey, out var sceneDecision);
|
||||
return new ManuscriptScanReviewSceneViewModel
|
||||
{
|
||||
TemporarySceneKey = scene.TemporarySceneKey,
|
||||
TemporaryChapterKey = scene.TemporaryChapterKey,
|
||||
SceneNumberWithinChapter = scene.SceneNumberWithinChapter,
|
||||
Title = scene.Title,
|
||||
ReviewTitle = sceneDecision?.Title ?? (string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title),
|
||||
Include = sceneDecision?.Include ?? true,
|
||||
WordCount = scene.WordCount,
|
||||
OpeningTextPreview = scene.OpeningTextPreview
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new ManuscriptScanReviewChapterViewModel
|
||||
{
|
||||
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
||||
ChapterNumber = chapter.ChapterNumber,
|
||||
Title = chapter.Title,
|
||||
ReviewTitle = decision?.Title ?? chapter.Title,
|
||||
Include = decision?.Include ?? (!string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0),
|
||||
WordCount = chapter.WordCount,
|
||||
Scenes = scenes
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var characters = preview.CharacterCandidates
|
||||
.OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
|
||||
.ThenByDescending(candidate => candidate.QualityScore)
|
||||
.ThenByDescending(candidate => candidate.MentionCount)
|
||||
.ThenBy(candidate => candidate.Name)
|
||||
.Select(candidate =>
|
||||
{
|
||||
characterReview.TryGetValue(candidate.TemporaryCharacterKey, out var decision);
|
||||
var reviewName = decision?.Name ?? candidate.Name;
|
||||
var existing = decision?.ExistingCharacterID.HasValue == true
|
||||
? existingCharacters.FirstOrDefault(character => character.CharacterID == decision.ExistingCharacterID.Value)
|
||||
: MatchExistingCharacter(reviewName, existingCharacters);
|
||||
return new ManuscriptScanReviewCharacterViewModel
|
||||
{
|
||||
TemporaryCharacterKey = candidate.TemporaryCharacterKey,
|
||||
Name = candidate.Name,
|
||||
ReviewName = reviewName,
|
||||
Include = decision?.Include ?? string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
|
||||
MentionCount = candidate.MentionCount,
|
||||
QualityScore = candidate.QualityScore,
|
||||
Category = candidate.Category,
|
||||
Reason = candidate.Reason,
|
||||
ExistingCharacterID = existing?.CharacterID,
|
||||
ExistingCharacterName = existing?.CharacterName
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
||||
var selectedScenes = chapters.SelectMany(chapter => chapter.Scenes).Where(scene => scene.Include && selectedChapterKeys.Contains(scene.TemporaryChapterKey)).ToList();
|
||||
|
||||
return new ManuscriptScanReviewViewModel
|
||||
{
|
||||
PreviewID = preview.PreviewID,
|
||||
ReviewStatus = review.Status,
|
||||
DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!,
|
||||
SelectedProjectName = project.ProjectName,
|
||||
SelectedBookTitle = BookOptionTitle(book),
|
||||
@ -138,29 +318,83 @@ public sealed class OnboardingService(
|
||||
ChapterCount = preview.ChapterCount,
|
||||
SceneCount = preview.SceneCount,
|
||||
CharacterCandidateCount = preview.CharacterCandidateCount,
|
||||
Chapters = preview.Chapters
|
||||
.OrderBy(chapter => chapter.ChapterNumber)
|
||||
.Select(chapter => new ManuscriptScanReviewChapterViewModel
|
||||
{
|
||||
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
||||
ChapterNumber = chapter.ChapterNumber,
|
||||
Title = chapter.Title,
|
||||
WordCount = chapter.WordCount,
|
||||
Scenes = preview.Scenes
|
||||
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
|
||||
.OrderBy(scene => scene.SceneNumberWithinChapter)
|
||||
.ToList()
|
||||
})
|
||||
.ToList(),
|
||||
CharacterCandidates = preview.CharacterCandidates
|
||||
.OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
|
||||
.ThenByDescending(candidate => candidate.QualityScore)
|
||||
.ThenByDescending(candidate => candidate.MentionCount)
|
||||
.ThenBy(candidate => candidate.Name)
|
||||
.ToList()
|
||||
SelectedChapterCount = selectedChapterKeys.Count,
|
||||
SelectedSceneCount = selectedScenes.Count,
|
||||
SelectedCharacterCount = characters.Count(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
|
||||
SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount),
|
||||
Chapters = chapters,
|
||||
CharacterCandidates = characters,
|
||||
ExistingCharacterOptions = existingOptions
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateScanReview(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
|
||||
{
|
||||
var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||
var previewScenes = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
|
||||
var includedChapterKeys = review.Chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (review.Chapters.Where(chapter => chapter.Include).Any(chapter => string.IsNullOrWhiteSpace(chapter.Title)))
|
||||
{
|
||||
throw new InvalidOperationException("Every included chapter needs a title.");
|
||||
}
|
||||
|
||||
if (review.Chapters.Select(chapter => chapter.ChapterNumber).Distinct().Count() != review.Chapters.Count)
|
||||
{
|
||||
throw new InvalidOperationException("Chapter order is not valid. Restore the scan defaults and try again.");
|
||||
}
|
||||
|
||||
foreach (var scene in review.Scenes.Where(scene => scene.Include))
|
||||
{
|
||||
if (!includedChapterKeys.Contains(scene.TemporaryChapterKey))
|
||||
{
|
||||
throw new InvalidOperationException("Included scenes must belong to included chapters.");
|
||||
}
|
||||
|
||||
if (!previewScenes.TryGetValue(scene.TemporarySceneKey, out var previewScene))
|
||||
{
|
||||
throw new InvalidOperationException("One of the selected scenes no longer exists in the scan preview.");
|
||||
}
|
||||
|
||||
if (previewScene.WordCount <= 0 && !scene.AllowZeroWords)
|
||||
{
|
||||
throw new InvalidOperationException("Included scenes with no words must be explicitly allowed.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var chapter in review.Chapters)
|
||||
{
|
||||
if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey))
|
||||
{
|
||||
throw new InvalidOperationException("One of the selected chapters no longer exists in the scan preview.");
|
||||
}
|
||||
}
|
||||
|
||||
var duplicateName = review.Characters
|
||||
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
||||
.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.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Character? MatchExistingCharacter(string? name, IReadOnlyList<Character> existingCharacters)
|
||||
{
|
||||
var clean = Clean(name);
|
||||
return string.IsNullOrWhiteSpace(clean)
|
||||
? null
|
||||
: existingCharacters.FirstOrDefault(character => string.Equals(character.CharacterName, clean, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<bool> ShouldShowOnboardingAsync()
|
||||
{
|
||||
if (!currentUser.UserId.HasValue)
|
||||
@ -500,6 +734,7 @@ public sealed class OnboardingService(
|
||||
=> new()
|
||||
{
|
||||
Status = state.Status,
|
||||
ReviewStatus = state.ReviewStatus,
|
||||
Message = state.Message,
|
||||
PercentComplete = state.PercentComplete,
|
||||
ChapterCount = state.ChapterCount,
|
||||
|
||||
@ -117,6 +117,7 @@ public sealed class CompanionPresenceViewModel
|
||||
public sealed class ManuscriptScanStateViewModel
|
||||
{
|
||||
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
|
||||
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public int? PercentComplete { get; init; }
|
||||
public int ChapterCount { get; init; }
|
||||
@ -132,6 +133,7 @@ public sealed class ManuscriptScanStateViewModel
|
||||
public sealed class ManuscriptScanReviewViewModel
|
||||
{
|
||||
public Guid PreviewID { get; init; }
|
||||
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||
public string DocumentTitle { get; init; } = "Word manuscript";
|
||||
public string SelectedProjectName { get; init; } = string.Empty;
|
||||
public string SelectedBookTitle { get; init; } = string.Empty;
|
||||
@ -139,8 +141,13 @@ public sealed class ManuscriptScanReviewViewModel
|
||||
public int ChapterCount { get; init; }
|
||||
public int SceneCount { get; init; }
|
||||
public int CharacterCandidateCount { get; init; }
|
||||
public int SelectedChapterCount { get; init; }
|
||||
public int SelectedSceneCount { get; init; }
|
||||
public int SelectedCharacterCount { get; init; }
|
||||
public int SelectedWordCount { get; init; }
|
||||
public IReadOnlyList<ManuscriptScanReviewChapterViewModel> Chapters { get; init; } = [];
|
||||
public IReadOnlyList<ManuscriptScanCharacterCandidatePreview> CharacterCandidates { get; init; } = [];
|
||||
public IReadOnlyList<ManuscriptScanReviewCharacterViewModel> CharacterCandidates { get; init; } = [];
|
||||
public IReadOnlyList<ManuscriptScanExistingCharacterOptionViewModel> ExistingCharacterOptions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanReviewChapterViewModel
|
||||
@ -148,6 +155,76 @@ public sealed class ManuscriptScanReviewChapterViewModel
|
||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||
public int ChapterNumber { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string ReviewTitle { get; init; } = string.Empty;
|
||||
public bool Include { get; init; }
|
||||
public int WordCount { get; init; }
|
||||
public IReadOnlyList<ManuscriptScanScenePreview> Scenes { get; init; } = [];
|
||||
public IReadOnlyList<ManuscriptScanReviewSceneViewModel> Scenes { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanReviewSceneViewModel
|
||||
{
|
||||
public string TemporarySceneKey { get; init; } = string.Empty;
|
||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||
public int SceneNumberWithinChapter { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? ReviewTitle { get; init; }
|
||||
public bool Include { get; init; }
|
||||
public int WordCount { get; init; }
|
||||
public string? OpeningTextPreview { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanReviewCharacterViewModel
|
||||
{
|
||||
public string TemporaryCharacterKey { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string ReviewName { get; init; } = string.Empty;
|
||||
public bool Include { get; init; }
|
||||
public int MentionCount { get; init; }
|
||||
public int QualityScore { get; init; }
|
||||
public string Category { get; init; } = "PossibleCharacter";
|
||||
public string? Reason { get; init; }
|
||||
public int? ExistingCharacterID { get; init; }
|
||||
public string? ExistingCharacterName { get; init; }
|
||||
public bool IsExistingCharacterMatch => ExistingCharacterID.HasValue;
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanExistingCharacterOptionViewModel
|
||||
{
|
||||
public int CharacterID { get; init; }
|
||||
public string CharacterName { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanReviewForm
|
||||
{
|
||||
public Guid PreviewID { get; set; }
|
||||
public List<ManuscriptScanChapterReviewFormItem> Chapters { get; set; } = [];
|
||||
public List<ManuscriptScanSceneReviewFormItem> Scenes { get; set; } = [];
|
||||
public List<ManuscriptScanCharacterReviewFormItem> Characters { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanChapterReviewFormItem
|
||||
{
|
||||
public string TemporaryChapterKey { get; set; } = string.Empty;
|
||||
public bool Include { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public int ChapterNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanSceneReviewFormItem
|
||||
{
|
||||
public string TemporarySceneKey { get; set; } = string.Empty;
|
||||
public string TemporaryChapterKey { get; set; } = string.Empty;
|
||||
public bool Include { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public int SceneNumberWithinChapter { get; set; }
|
||||
public bool AllowZeroWords { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanCharacterReviewFormItem
|
||||
{
|
||||
public string TemporaryCharacterKey { get; set; } = string.Empty;
|
||||
public bool Include { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = "PossibleCharacter";
|
||||
public int? ExistingCharacterID { get; set; }
|
||||
}
|
||||
|
||||
@ -310,9 +310,13 @@
|
||||
data-onboarding-scan-review
|
||||
href="@(Model.ScanState.PreviewID.HasValue ? Url.Action("ScanReview", "Onboarding", new { previewId = Model.ScanState.PreviewID }) : "#")"
|
||||
aria-disabled="@(!Model.ScanState.IsComplete)">
|
||||
Review scan
|
||||
@(string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) ? "Edit review" : "Review scan")
|
||||
</a>
|
||||
</div>
|
||||
@if (string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
||||
{
|
||||
<p class="onboarding-scan-next">Next: import into PlotDirector.</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -16,32 +16,50 @@
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="scan-review-title">
|
||||
<div class="onboarding-panel onboarding-review-panel">
|
||||
<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" />
|
||||
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">@Model.SelectedBookTitle</p>
|
||||
<h1 id="scan-review-title">Review manuscript scan</h1>
|
||||
<p>@Model.DocumentTitle</p>
|
||||
<p>Nothing has been added to your project yet. Review the detected structure, then continue when you're happy.</p>
|
||||
<p>You can tidy this up now, or import the structure and refine it later.</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.ChapterCount</strong>
|
||||
<strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Scenes</span>
|
||||
<strong>@Model.SceneCount</strong>
|
||||
<strong>@Model.SelectedSceneCount / @Model.SceneCount</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Characters</span>
|
||||
<strong>@Model.CharacterCandidateCount</strong>
|
||||
<strong>@Model.SelectedCharacterCount / @Model.CharacterCandidateCount</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Words</span>
|
||||
<strong>@Model.TotalWordCount.ToString("N0")</strong>
|
||||
<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-grid" aria-label="Scan preview">
|
||||
<div class="onboarding-review-section">
|
||||
<h2>Chapters and scenes</h2>
|
||||
@ -52,27 +70,63 @@
|
||||
else
|
||||
{
|
||||
<div class="onboarding-review-chapters">
|
||||
@{
|
||||
var chapterIndex = 0;
|
||||
var sceneIndex = 0;
|
||||
}
|
||||
@foreach (var chapter in Model.Chapters)
|
||||
{
|
||||
<article class="onboarding-review-chapter">
|
||||
<header>
|
||||
<details class="onboarding-review-chapter" open>
|
||||
<summary>
|
||||
<span>Chapter @chapter.ChapterNumber</span>
|
||||
<strong>@chapter.Title</strong>
|
||||
<strong>@chapter.ReviewTitle</strong>
|
||||
<small>@chapter.WordCount.ToString("N0") words / @chapter.Scenes.Count scene@(chapter.Scenes.Count == 1 ? string.Empty : "s")</small>
|
||||
</header>
|
||||
</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="hidden" name="Chapters[@chapterIndex].Include" value="false" />
|
||||
<input type="checkbox" name="Chapters[@chapterIndex].Include" value="true" checked="@chapter.Include" data-review-kind="chapter" />
|
||||
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>
|
||||
<strong>@(scene.Title ?? $"Scene {scene.SceneNumberWithinChapter}")</strong>
|
||||
<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="hidden" name="Scenes[@sceneIndex].Include" value="false" />
|
||||
<input type="checkbox" name="Scenes[@sceneIndex].Include" value="true" checked="@scene.Include" data-review-kind="scene" />
|
||||
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="hidden" name="Scenes[@sceneIndex].AllowZeroWords" value="false" />
|
||||
<input type="checkbox" name="Scenes[@sceneIndex].AllowZeroWords" value="true" />
|
||||
Allow empty scene
|
||||
</label>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(scene.OpeningTextPreview))
|
||||
{
|
||||
<p>@scene.OpeningTextPreview</p>
|
||||
}
|
||||
</li>
|
||||
sceneIndex++;
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
@ -80,7 +134,8 @@
|
||||
{
|
||||
<p>No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.</p>
|
||||
}
|
||||
</article>
|
||||
</details>
|
||||
chapterIndex++;
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@ -94,72 +149,103 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
var characterIndex = 0;
|
||||
<div class="onboarding-character-groups">
|
||||
<section>
|
||||
<h3>Probable characters</h3>
|
||||
@if (!probableCharacters.Any())
|
||||
{
|
||||
<p>No high-confidence character names yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="onboarding-character-candidates">
|
||||
@foreach (var candidate in probableCharacters)
|
||||
{
|
||||
<li>
|
||||
<strong>@candidate.Name</strong>
|
||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (possibleCharacters.Any())
|
||||
@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>Possible characters</h3>
|
||||
<h3>@group.Title</h3>
|
||||
<ul class="onboarding-character-candidates">
|
||||
@foreach (var candidate in possibleCharacters)
|
||||
@foreach (var candidate in group.Candidates)
|
||||
{
|
||||
<li>
|
||||
<strong>@candidate.Name</strong>
|
||||
<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="hidden" name="Characters[@characterIndex].Include" value="false" />
|
||||
<input type="checkbox" name="Characters[@characterIndex].Include" value="true" checked="@candidate.Include" />
|
||||
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>
|
||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
||||
@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 (relationshipTitles.Any())
|
||||
{
|
||||
<section>
|
||||
<h3>Relationship titles</h3>
|
||||
<ul class="onboarding-character-candidates">
|
||||
@foreach (var candidate in relationshipTitles)
|
||||
{
|
||||
<li>
|
||||
<strong>@candidate.Name</strong>
|
||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s")</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (excludedCharacters.Any())
|
||||
{
|
||||
<details class="onboarding-excluded-candidates">
|
||||
<summary>Show excluded candidates</summary>
|
||||
<ul class="onboarding-character-candidates">
|
||||
@foreach (var candidate in excludedCharacters)
|
||||
{
|
||||
<li>
|
||||
<strong>@candidate.Name</strong>
|
||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / @candidate.Reason</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<section>
|
||||
<h3>Excluded candidates</h3>
|
||||
<ul class="onboarding-character-candidates">
|
||||
@foreach (var candidate in excludedCharacters)
|
||||
{
|
||||
<li>
|
||||
<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="hidden" name="Characters[@characterIndex].Include" value="false" />
|
||||
<input type="checkbox" name="Characters[@characterIndex].Include" value="true" checked="@candidate.Include" />
|
||||
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>
|
||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
|
||||
{
|
||||
<small>@candidate.Reason</small>
|
||||
}
|
||||
</li>
|
||||
characterIndex++;
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
@ -169,6 +255,23 @@
|
||||
|
||||
<div class="onboarding-actions">
|
||||
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
|
||||
<button class="btn btn-outline-primary" type="submit" name="intent" value="save">Save review</button>
|
||||
<button class="btn btn-primary" type="submit" name="intent" value="continue">Save and continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.querySelector("[data-review-tools]")?.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;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -309,6 +309,12 @@
|
||||
gap: .65rem;
|
||||
}
|
||||
|
||||
.onboarding-scan-next {
|
||||
margin: .25rem 0 0;
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onboarding-review-panel {
|
||||
width: min(1120px, 100%);
|
||||
}
|
||||
@ -317,6 +323,13 @@
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.onboarding-review-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-review-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(280px, .8fr);
|
||||
@ -346,14 +359,15 @@
|
||||
background: rgba(255, 255, 255, .58);
|
||||
}
|
||||
|
||||
.onboarding-review-chapter header {
|
||||
.onboarding-review-chapter summary {
|
||||
display: grid;
|
||||
gap: .2rem;
|
||||
margin-bottom: .75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.onboarding-review-chapter header span,
|
||||
.onboarding-review-chapter header small,
|
||||
.onboarding-review-chapter summary span,
|
||||
.onboarding-review-chapter summary small,
|
||||
.onboarding-review-chapter li span {
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 700;
|
||||
@ -379,7 +393,7 @@
|
||||
|
||||
.onboarding-review-chapter li {
|
||||
display: grid;
|
||||
gap: .2rem;
|
||||
gap: .45rem;
|
||||
}
|
||||
|
||||
.onboarding-review-chapter li p {
|
||||
@ -388,10 +402,22 @@
|
||||
}
|
||||
|
||||
.onboarding-character-candidates li {
|
||||
display: flex;
|
||||
display: grid;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.onboarding-review-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .75rem;
|
||||
gap: .45rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onboarding-review-field {
|
||||
display: grid;
|
||||
gap: .25rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onboarding-character-groups section {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user