Phase 20AJ – Rationalise Story Intelligence Entry Points
This commit is contained in:
parent
058b3d10dc
commit
5b4c78c040
@ -7,7 +7,7 @@ using PlotLine.ViewModels;
|
|||||||
namespace PlotLine.Controllers;
|
namespace PlotLine.Controllers;
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public sealed class BooksController(IBookService books) : Controller
|
public sealed class BooksController(IBookService books, IOnboardingService onboarding) : Controller
|
||||||
{
|
{
|
||||||
public async Task<IActionResult> Details(int id)
|
public async Task<IActionResult> Details(int id)
|
||||||
{
|
{
|
||||||
@ -31,6 +31,11 @@ public sealed class BooksController(IBookService books) : Controller
|
|||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Save(BookEditViewModel model)
|
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))
|
if (!BookStatuses.IsValid(model.Status))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.Status), "Select a valid book 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.TargetCompletionDate = model.TargetCompletionDate;
|
||||||
createModel.PublicationDate = model.PublicationDate;
|
createModel.PublicationDate = model.PublicationDate;
|
||||||
createModel.UsesExplicitScenes = model.UsesExplicitScenes;
|
createModel.UsesExplicitScenes = model.UsesExplicitScenes;
|
||||||
|
createModel.BeginMode = model.BeginMode;
|
||||||
await books.PopulateEditContextAsync(createModel);
|
await books.PopulateEditContextAsync(createModel);
|
||||||
return View("Edit", createModel);
|
return View("Edit", createModel);
|
||||||
}
|
}
|
||||||
@ -80,6 +86,12 @@ public sealed class BooksController(IBookService books) : Controller
|
|||||||
return View("Edit", model);
|
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 });
|
return RedirectToAction(nameof(Details), new { id = result.BookID });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,22 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return View(model);
|
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")]
|
[HttpGet("scan-review")]
|
||||||
public async Task<IActionResult> ScanReview(Guid? previewId)
|
public async Task<IActionResult> ScanReview(Guid? previewId)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -6,6 +6,7 @@ namespace PlotLine.Services;
|
|||||||
public interface IManuscriptScanPreviewStore
|
public interface IManuscriptScanPreviewStore
|
||||||
{
|
{
|
||||||
Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId);
|
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> ProgressAsync(int userId, ManuscriptScanProgress progress);
|
||||||
Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview);
|
Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview);
|
||||||
Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message);
|
Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message);
|
||||||
@ -45,6 +46,12 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
|||||||
return Task.FromResult(ToState(session));
|
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)
|
public Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress)
|
||||||
{
|
{
|
||||||
var key = (userId, progress.OnboardingID);
|
var key = (userId, progress.OnboardingID);
|
||||||
|
|||||||
@ -16,6 +16,9 @@ public interface IOnboardingService
|
|||||||
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
|
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
|
||||||
Task<bool> ShouldShowWordCompanionHeaderAsync();
|
Task<bool> ShouldShowWordCompanionHeaderAsync();
|
||||||
Task MoveToNextStepAsync();
|
Task MoveToNextStepAsync();
|
||||||
|
Task StartManualSetupAsync();
|
||||||
|
Task StartManuscriptImportSetupAsync();
|
||||||
|
Task BeginManuscriptImportForBookAsync(int projectId, int bookId);
|
||||||
Task SetCurrentStepAsync(string currentStep);
|
Task SetCurrentStepAsync(string currentStep);
|
||||||
Task SaveWritingJourneyAsync(string writingJourney);
|
Task SaveWritingJourneyAsync(string writingJourney);
|
||||||
Task SaveWritingSoftwareAsync(string writingSoftware);
|
Task SaveWritingSoftwareAsync(string writingSoftware);
|
||||||
@ -572,6 +575,42 @@ public sealed class OnboardingService(
|
|||||||
await onboarding.SetCurrentStepAsync(RequireUserId(), nextStep);
|
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)
|
public async Task SetCurrentStepAsync(string currentStep)
|
||||||
{
|
{
|
||||||
if (currentStep is not (OnboardingSteps.Welcome
|
if (currentStep is not (OnboardingSteps.Welcome
|
||||||
|
|||||||
@ -28,6 +28,7 @@ public sealed class ProjectIndexViewModel
|
|||||||
public TrialVisibilityViewModel? Trial { get; init; }
|
public TrialVisibilityViewModel? Trial { get; init; }
|
||||||
public OnboardingNudgeViewModel OnboardingNudge { get; init; } = new();
|
public OnboardingNudgeViewModel OnboardingNudge { get; init; } = new();
|
||||||
public StoryIntelligenceDashboardViewModel StoryIntelligence { get; init; } = new();
|
public StoryIntelligenceDashboardViewModel StoryIntelligence { get; init; } = new();
|
||||||
|
public bool HasAnyProjects => ActiveProjects.Any() || ArchivedProjects.Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ProjectIndexCardViewModel
|
public sealed class ProjectIndexCardViewModel
|
||||||
@ -228,6 +229,13 @@ public sealed class BookEditViewModel
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public Project? Project { get; set; }
|
public Project? Project { get; set; }
|
||||||
public IReadOnlyList<SelectListItem> StatusOptions { 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
|
public sealed class BookDetailViewModel
|
||||||
|
|||||||
@ -14,7 +14,38 @@
|
|||||||
<form asp-action="Save" method="post" enctype="multipart/form-data" class="edit-panel">
|
<form asp-action="Save" method="post" enctype="multipart/form-data" class="edit-panel">
|
||||||
<input asp-for="BookID" type="hidden" />
|
<input asp-for="BookID" type="hidden" />
|
||||||
<input asp-for="ProjectID" 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>
|
<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="row g-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label asp-for="BookNumber" class="form-label">Book number <help-icon key="books.fields.bookNumber" mode="inline" /></label>
|
<label asp-for="BookNumber" class="form-label">Book number <help-icon key="books.fields.bookNumber" mode="inline" /></label>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Projects";
|
ViewData["Title"] = "Projects";
|
||||||
var trial = Model.Trial;
|
var trial = Model.Trial;
|
||||||
|
var hasAnyProjects = Model.HasAnyProjects;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
@ -33,33 +34,6 @@
|
|||||||
</section>
|
</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)
|
@if (TempData["ArchiveMessage"] is string archiveMessage)
|
||||||
{
|
{
|
||||||
<div class="alert alert-success">@archiveMessage</div>
|
<div class="alert alert-success">@archiveMessage</div>
|
||||||
@ -73,12 +47,19 @@
|
|||||||
<div class="alert alert-warning">@projectExportMessage</div>
|
<div class="alert alert-warning">@projectExportMessage</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!Model.ActiveProjects.Any() && !Model.ArchivedProjects.Any())
|
@if (!hasAnyProjects)
|
||||||
{
|
{
|
||||||
<section class="empty-panel">
|
<section class="empty-panel">
|
||||||
<h2>Start with a series or standalone novel</h2>
|
<h2>Welcome to PlotDirector</h2>
|
||||||
<p>Create a project, then add books, chapters and scenes in narrative order.</p>
|
<p>Let's create your first project.</p>
|
||||||
<a class="btn btn-primary" asp-action="Create">Create your first project</a>
|
<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>
|
</section>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user