Phase 20AJ – Rationalise Story Intelligence Entry Points

This commit is contained in:
Nick Beckley 2026-07-07 20:11:12 +01:00
parent 058b3d10dc
commit 5b4c78c040
7 changed files with 126 additions and 32 deletions

View File

@ -7,7 +7,7 @@ using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
public sealed class BooksController(IBookService books) : Controller
public sealed class BooksController(IBookService books, IOnboardingService onboarding) : Controller
{
public async Task<IActionResult> Details(int id)
{
@ -31,6 +31,11 @@ public sealed class BooksController(IBookService books) : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(BookEditViewModel model)
{
if (model.BeginMode is not (BookBeginModes.Manual or BookBeginModes.Import))
{
model.BeginMode = BookBeginModes.Manual;
}
if (!BookStatuses.IsValid(model.Status))
{
ModelState.AddModelError(nameof(model.Status), "Select a valid book status.");
@ -68,6 +73,7 @@ public sealed class BooksController(IBookService books) : Controller
createModel.TargetCompletionDate = model.TargetCompletionDate;
createModel.PublicationDate = model.PublicationDate;
createModel.UsesExplicitScenes = model.UsesExplicitScenes;
createModel.BeginMode = model.BeginMode;
await books.PopulateEditContextAsync(createModel);
return View("Edit", createModel);
}
@ -80,6 +86,12 @@ public sealed class BooksController(IBookService books) : Controller
return View("Edit", model);
}
if (model.BookID == 0 && string.Equals(model.BeginMode, BookBeginModes.Import, StringComparison.Ordinal))
{
await onboarding.BeginManuscriptImportForBookAsync(model.ProjectID, result.BookID);
return RedirectToAction("Index", "Onboarding");
}
return RedirectToAction(nameof(Details), new { id = result.BookID });
}

View File

@ -17,6 +17,22 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return View(model);
}
[HttpPost("start-manual")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartManual()
{
await onboarding.StartManualSetupAsync();
return RedirectToAction(nameof(Index));
}
[HttpPost("start-import")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartImport()
{
await onboarding.StartManuscriptImportSetupAsync();
return RedirectToAction(nameof(Index));
}
[HttpGet("scan-review")]
public async Task<IActionResult> ScanReview(Guid? previewId)
{

View File

@ -6,6 +6,7 @@ namespace PlotLine.Services;
public interface IManuscriptScanPreviewStore
{
Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId);
Task ClearAsync(int userId, int onboardingId);
Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress);
Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview);
Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message);
@ -45,6 +46,12 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
return Task.FromResult(ToState(session));
}
public Task ClearAsync(int userId, int onboardingId)
{
sessions.TryRemove((userId, onboardingId), out _);
return Task.CompletedTask;
}
public Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress)
{
var key = (userId, progress.OnboardingID);

View File

@ -16,6 +16,9 @@ public interface IOnboardingService
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
Task<bool> ShouldShowWordCompanionHeaderAsync();
Task MoveToNextStepAsync();
Task StartManualSetupAsync();
Task StartManuscriptImportSetupAsync();
Task BeginManuscriptImportForBookAsync(int projectId, int bookId);
Task SetCurrentStepAsync(string currentStep);
Task SaveWritingJourneyAsync(string writingJourney);
Task SaveWritingSoftwareAsync(string writingSoftware);
@ -572,6 +575,42 @@ public sealed class OnboardingService(
await onboarding.SetCurrentStepAsync(RequireUserId(), nextStep);
}
public async Task StartManualSetupAsync()
{
var userId = RequireUserId();
await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.PlanningNewStory);
var state = await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.Other);
await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID);
await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.Project);
}
public async Task StartManuscriptImportSetupAsync()
{
var userId = RequireUserId();
await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.ManuscriptMostlyComplete);
var state = await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.MicrosoftWord);
await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID);
await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.Project);
}
public async Task BeginManuscriptImportForBookAsync(int projectId, int bookId)
{
var userId = RequireUserId();
var project = await projects.GetForUserAsync(projectId, userId);
var book = await books.GetAsync(bookId);
if (project is null || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived)
{
throw new InvalidOperationException("Choose one of your active books before importing a manuscript.");
}
await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.ManuscriptMostlyComplete);
await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.MicrosoftWord);
await onboarding.SaveProjectAsync(userId, project.ProjectID);
var state = await onboarding.SaveBookAsync(userId, book.BookID);
await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID);
await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.NextPathPreview);
}
public async Task SetCurrentStepAsync(string currentStep)
{
if (currentStep is not (OnboardingSteps.Welcome

View File

@ -28,6 +28,7 @@ public sealed class ProjectIndexViewModel
public TrialVisibilityViewModel? Trial { get; init; }
public OnboardingNudgeViewModel OnboardingNudge { get; init; } = new();
public StoryIntelligenceDashboardViewModel StoryIntelligence { get; init; } = new();
public bool HasAnyProjects => ActiveProjects.Any() || ArchivedProjects.Any();
}
public sealed class ProjectIndexCardViewModel
@ -228,6 +229,13 @@ public sealed class BookEditViewModel
public string? Description { get; set; }
public Project? Project { get; set; }
public IReadOnlyList<SelectListItem> StatusOptions { get; set; } = [];
public string BeginMode { get; set; } = BookBeginModes.Manual;
}
public static class BookBeginModes
{
public const string Manual = "Manual";
public const string Import = "Import";
}
public sealed class BookDetailViewModel

View File

@ -14,7 +14,38 @@
<form asp-action="Save" method="post" enctype="multipart/form-data" class="edit-panel">
<input asp-for="BookID" type="hidden" />
<input asp-for="ProjectID" type="hidden" />
@if (Model.BookID != 0)
{
<input asp-for="BeginMode" type="hidden" value="@BookBeginModes.Manual" />
}
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
@if (Model.BookID == 0)
{
<section class="mb-4" aria-label="Choose how to begin this book">
<p class="eyebrow">New book</p>
<h2 class="h5">How would you like to begin?</h2>
<div class="row g-3">
<div class="col-md-6">
<label class="onboarding-option-card h-100">
<input type="radio" name="BeginMode" value="@BookBeginModes.Manual" checked="@(Model.BeginMode != BookBeginModes.Import)" />
<span>
<strong>Start with an empty book</strong>
<span>Create chapters and scenes manually.</span>
</span>
</label>
</div>
<div class="col-md-6">
<label class="onboarding-option-card h-100">
<input type="radio" name="BeginMode" value="@BookBeginModes.Import" checked="@(Model.BeginMode == BookBeginModes.Import)" />
<span>
<strong>Import an existing manuscript</strong>
<span>Use the Word Companion and Story Intelligence.</span>
</span>
</label>
</div>
</div>
</section>
}
<div class="row g-3">
<div class="col-md-3">
<label asp-for="BookNumber" class="form-label">Book number <help-icon key="books.fields.bookNumber" mode="inline" /></label>

View File

@ -2,6 +2,7 @@
@{
ViewData["Title"] = "Projects";
var trial = Model.Trial;
var hasAnyProjects = Model.HasAnyProjects;
}
<div class="page-heading">
@ -33,33 +34,6 @@
</section>
}
@if (Model.OnboardingNudge.ShouldShow && !Model.StoryIntelligence.ShouldShow)
{
<section class="onboarding-dashboard-nudge @(Model.OnboardingNudge.CompanionConnected ? "is-connected" : string.Empty)"
data-word-companion-presence="@(Model.OnboardingNudge.IsCompanionPresence.ToString().ToLowerInvariant())"
aria-label="PlotDirector setup">
<div>
<p class="eyebrow">First setup</p>
<h2 data-word-companion-dashboard-title>@Model.OnboardingNudge.Title</h2>
<p data-word-companion-dashboard-description>@Model.OnboardingNudge.Description</p>
</div>
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="Index">@Model.OnboardingNudge.ButtonText</a>
</section>
}
@if (Model.StoryIntelligence.ShouldShow)
{
<section class="story-intelligence-dashboard-card story-intelligence-dashboard-card--compact"
aria-label="Story Intelligence">
<div>
<p class="eyebrow">Manuscript import</p>
<h2>@Model.StoryIntelligence.Title</h2>
<p>@Model.StoryIntelligence.Description</p>
</div>
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="Index">@Model.StoryIntelligence.ButtonText</a>
</section>
}
@if (TempData["ArchiveMessage"] is string archiveMessage)
{
<div class="alert alert-success">@archiveMessage</div>
@ -73,12 +47,19 @@
<div class="alert alert-warning">@projectExportMessage</div>
}
@if (!Model.ActiveProjects.Any() && !Model.ArchivedProjects.Any())
@if (!hasAnyProjects)
{
<section class="empty-panel">
<h2>Start with a series or standalone novel</h2>
<p>Create a project, then add books, chapters and scenes in narrative order.</p>
<a class="btn btn-primary" asp-action="Create">Create your first project</a>
<h2>Welcome to PlotDirector</h2>
<p>Let's create your first project.</p>
<div class="button-row justify-content-center">
<form asp-controller="Onboarding" asp-action="StartManual" method="post">
<button class="btn btn-primary" type="submit">Start writing manually</button>
</form>
<form asp-controller="Onboarding" asp-action="StartImport" method="post">
<button class="btn btn-outline-primary" type="submit">Import an existing manuscript</button>
</form>
</div>
</section>
}
else