PlotDirector Phase 17G - Story Structure Editor Migration

This commit is contained in:
Nick Beckley 2026-06-30 15:02:35 +01:00
parent 7243538b2b
commit 0adca386d9
7 changed files with 520 additions and 2 deletions

View File

@ -178,6 +178,24 @@ public sealed class ScenesController(
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneAttachmentsEditor.cshtml", model);
}
[HttpGet]
public async Task<IActionResult> SceneThreadsEditor(int sceneId)
{
var model = await scenes.GetEditAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneThreadsEditor.cshtml", model);
}
[HttpGet]
public async Task<IActionResult> SceneDependenciesEditor(int sceneId)
{
var model = await scenes.GetEditAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneDependenciesEditor.cshtml", model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSceneDetails(SceneDetailsSaveViewModel model)
@ -780,9 +798,19 @@ public sealed class ScenesController(
}
catch (InvalidOperationException ex)
{
if (IsAjaxRequest())
{
return BadRequest(new { success = false, message = ex.Message });
}
TempData["ThreadEventAddError"] = ex.Message;
}
if (IsAjaxRequest())
{
return await SceneStoryRefreshJsonAsync(model.SceneID);
}
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
@ -796,9 +824,19 @@ public sealed class ScenesController(
}
catch (InvalidOperationException ex)
{
if (IsAjaxRequest())
{
return BadRequest(new { success = false, message = ex.Message });
}
TempData[$"ThreadEventEditError:{model.ThreadEventID}"] = ex.Message;
}
if (IsAjaxRequest())
{
return await SceneStoryRefreshJsonAsync(model.SceneID);
}
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
@ -807,6 +845,11 @@ public sealed class ScenesController(
public async Task<IActionResult> DeleteThreadEvent(int id, int sceneId, int? projectId, int? bookId)
{
await plots.DeleteThreadEventAsync(id);
if (IsAjaxRequest())
{
return await SceneStoryRefreshJsonAsync(sceneId);
}
return RedirectForScene(sceneId, projectId, bookId);
}
@ -991,6 +1034,11 @@ public sealed class ScenesController(
public async Task<IActionResult> AddSceneDependency(SceneDependencyCreateViewModel model)
{
await scenes.AddDependencyAsync(model);
if (IsAjaxRequest())
{
return await SceneStoryRefreshJsonAsync(model.CurrentSceneID);
}
return RedirectForScene(model.CurrentSceneID, model.ReturnProjectID, model.ReturnBookID);
}
@ -999,6 +1047,11 @@ public sealed class ScenesController(
public async Task<IActionResult> DeleteSceneDependency(int id, int sceneId, int? projectId, int? bookId)
{
await scenes.DeleteDependencyAsync(id);
if (IsAjaxRequest())
{
return await SceneStoryRefreshJsonAsync(sceneId);
}
return RedirectForScene(sceneId, projectId, bookId);
}
@ -1284,6 +1337,28 @@ public sealed class ScenesController(
});
}
private async Task<IActionResult> SceneStoryRefreshJsonAsync(int sceneId)
{
var updated = await scenes.GetEditAsync(sceneId);
if (updated is null)
{
return NotFound();
}
return Json(new
{
success = true,
html = new
{
story = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneStoryCard.cshtml", updated)
},
scene = new
{
updated.SceneID
}
});
}
private IActionResult RedirectForScene(int sceneId, int? projectId, int? bookId)
{
if (TryGetLocalReturnUrl(out var returnUrl))

View File

@ -0,0 +1,147 @@
@model SceneEditViewModel
@{
var dependencyCount = Model.DependenciesThisSceneNeeds.Count + Model.DependenciesNeedingThisScene.Count;
}
<div class="scene-inspector-editor-form">
<div class="scene-inspector-editor-fields">
<section class="scene-structure-editor-section">
<div class="scene-structure-editor-heading">
<span>Dependencies</span>
<strong>@dependencyCount</strong>
</div>
<div class="scene-structure-dependency-columns">
<div>
<p class="eyebrow">This scene depends on</p>
@if (!Model.DependenciesThisSceneNeeds.Any())
{
<p class="muted">No dependencies yet.</p>
}
else
{
<div class="scene-structure-editor-list">
@foreach (var dependency in Model.DependenciesThisSceneNeeds)
{
<article class="scene-structure-editor-card">
<div class="scene-structure-card-header">
<div>
<h4>Ch @dependency.SourceChapterNumber, Scene @dependency.SourceSceneNumber</h4>
<p>@dependency.SourceSceneTitle</p>
</div>
</div>
<p class="muted">@dependency.SceneDependencyTypeName @(dependency.IsHardDependency ? "/ hard" : "/ soft")</p>
@if (!string.IsNullOrWhiteSpace(dependency.Description))
{
<p>@dependency.Description</p>
}
<form asp-controller="Scenes" asp-action="DeleteSceneDependency" method="post" class="scene-structure-delete-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@dependency.SceneDependencyID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<input type="hidden" name="projectId" value="@Model.ReturnProjectID" />
<input type="hidden" name="bookId" value="@Model.ReturnBookID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<button class="btn btn-sm btn-outline-danger" type="submit" data-scene-editor-save>Remove dependency</button>
</form>
</article>
}
</div>
}
</div>
<div>
<p class="eyebrow">Scenes depending on this</p>
@if (!Model.DependenciesNeedingThisScene.Any())
{
<p class="muted">No dependent scenes yet.</p>
}
else
{
<div class="scene-structure-editor-list">
@foreach (var dependency in Model.DependenciesNeedingThisScene)
{
<article class="scene-structure-editor-card">
<div class="scene-structure-card-header">
<div>
<h4>Ch @dependency.TargetChapterNumber, Scene @dependency.TargetSceneNumber</h4>
<p>@dependency.TargetSceneTitle</p>
</div>
</div>
<p class="muted">@dependency.SceneDependencyTypeName @(dependency.IsHardDependency ? "/ hard" : "/ soft")</p>
@if (!string.IsNullOrWhiteSpace(dependency.Description))
{
<p>@dependency.Description</p>
}
<form asp-controller="Scenes" asp-action="DeleteSceneDependency" method="post" class="scene-structure-delete-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@dependency.SceneDependencyID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<input type="hidden" name="projectId" value="@Model.ReturnProjectID" />
<input type="hidden" name="bookId" value="@Model.ReturnBookID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<button class="btn btn-sm btn-outline-danger" type="submit" data-scene-editor-save>Remove dependency</button>
</form>
</article>
}
</div>
}
</div>
</div>
</section>
<section class="scene-structure-editor-section">
<div class="scene-structure-editor-heading">
<span>Add dependency</span>
</div>
<form asp-controller="Scenes" asp-action="AddSceneDependency" method="post" class="scene-structure-editor-card scene-structure-editor-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="ProjectID" value="@Model.ReturnProjectID" />
<input type="hidden" name="CurrentSceneID" value="@Model.SceneID" />
<input type="hidden" name="ReturnProjectID" value="@Model.ReturnProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.ReturnBookID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<div class="form-group">
<label for="DependencyDirection">Plain English</label>
<select class="form-control form-control-sm" id="DependencyDirection" name="Direction">
<option value="CurrentDependsOnRelated">This scene depends on another scene</option>
<option value="RelatedDependsOnCurrent">Another scene depends on this scene</option>
</select>
</div>
<div class="form-group">
<label for="RelatedSceneID">Related scene</label>
<select class="form-control form-control-sm" id="RelatedSceneID" name="RelatedSceneID" asp-items="Model.SceneOptions"></select>
</div>
<div class="scene-inspector-editor-grid">
<div class="form-group">
<label for="SceneDependencyTypeID">Dependency type</label>
<select class="form-control form-control-sm" id="SceneDependencyTypeID" name="SceneDependencyTypeID" asp-items="Model.SceneDependencyTypeOptions"></select>
</div>
<label class="scene-structure-inline-check">
<input type="checkbox" name="IsHardDependency" value="true" />
<input type="hidden" name="IsHardDependency" value="false" />
<span>Hard dependency</span>
</label>
</div>
<div class="form-group">
<label for="Description">Description</label>
<textarea class="form-control form-control-sm" id="Description" name="Description" rows="2" placeholder="Why this order matters"></textarea>
</div>
<div class="scene-structure-action-row">
<button class="btn btn-sm btn-primary" type="submit" data-scene-editor-save>Add dependency</button>
</div>
</form>
</section>
</div>
<div class="scene-inspector-editor-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" data-scene-editor-close>Close</button>
</div>
</div>

View File

@ -5,6 +5,7 @@
.Select(x => x.PurposeName)
.ToList();
var threadCount = Model.ThreadEvents.Count;
var dependencyCount = Model.DependenciesThisSceneNeeds.Count + Model.DependenciesNeedingThisScene.Count;
}
<article class="scene-inspector-v2-card" data-scene-summary-part="story">
@ -46,6 +47,11 @@
<p>@threadCount thread event@(threadCount == 1 ? "" : "s") in this scene.</p>
</div>
<div class="scene-inspector-v2-section">
<span>Dependencies</span>
<p>@dependencyCount scene dependenc@(dependencyCount == 1 ? "y" : "ies") attached.</p>
</div>
<div class="scene-inspector-v2-section">
<span>Metrics</span>
@if (!Model.Metrics.Any())
@ -69,6 +75,7 @@
<div class="scene-inspector-v2-actions">
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="purpose" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("ScenePurposeEditor", "Scenes", new { sceneId = Model.SceneID })">Manage purpose</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="metrics" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneMetricsEditor", "Scenes", new { sceneId = Model.SceneID })">Manage metrics</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="threads" data-scene-id="@Model.SceneID">Manage threads</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="threads" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneThreadsEditor", "Scenes", new { sceneId = Model.SceneID })">Manage threads</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="dependencies" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneDependenciesEditor", "Scenes", new { sceneId = Model.SceneID })">Manage dependencies</button>
</div>
</article>

View File

@ -0,0 +1,194 @@
@model SceneEditViewModel
<div class="scene-inspector-editor-form">
<div class="scene-inspector-editor-fields">
<section class="scene-structure-editor-section">
<div class="scene-structure-editor-heading">
<span>Current thread events</span>
<strong>@Model.ThreadEvents.Count</strong>
</div>
@if (!Model.ThreadEvents.Any())
{
<p class="muted">No plot thread events are attached to this scene yet.</p>
}
else
{
<div class="scene-structure-editor-list">
@foreach (var threadEvent in Model.ThreadEvents)
{
<article class="scene-structure-editor-card">
<div class="scene-structure-card-header" style="--plot-colour:@threadEvent.Colour">
<div>
<h4>@threadEvent.EventTitle</h4>
<p>@threadEvent.PlotEventTypeName / @threadEvent.PlotLineName / @threadEvent.ThreadTitle</p>
</div>
<span class="scene-structure-marker">@threadEvent.MarkerText</span>
</div>
<form asp-controller="Scenes" asp-action="UpdateThreadEvent" method="post" class="scene-structure-editor-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="ThreadEventID" value="@threadEvent.ThreadEventID" />
<input type="hidden" name="SceneID" value="@Model.SceneID" />
<input type="hidden" name="PlotThreadID" value="@threadEvent.PlotThreadID" />
<input type="hidden" name="ReturnProjectID" value="@Model.ReturnProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.ReturnBookID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<div class="scene-inspector-editor-grid">
<div class="form-group">
<label for="ThreadEvent_@(threadEvent.ThreadEventID)__PlotEventTypeID">Event type</label>
<select class="form-control form-control-sm" id="ThreadEvent_@(threadEvent.ThreadEventID)__PlotEventTypeID" name="PlotEventTypeID">
<option value="">Choose event type</option>
@foreach (var option in Model.PlotEventTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == threadEvent.PlotEventTypeID.ToString())">@option.Text</option>
}
</select>
</div>
<div class="form-group">
<label for="ThreadEvent_@(threadEvent.ThreadEventID)__EventTypeID">Marker type</label>
<select class="form-control form-control-sm" id="ThreadEvent_@(threadEvent.ThreadEventID)__EventTypeID" name="EventTypeID">
@foreach (var option in Model.ThreadEventTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == threadEvent.EventTypeID.ToString())">@option.Text</option>
}
</select>
</div>
</div>
<div class="form-group">
<label for="ThreadEvent_@(threadEvent.ThreadEventID)__TargetPlotLineID">Target plot line</label>
<select class="form-control form-control-sm" id="ThreadEvent_@(threadEvent.ThreadEventID)__TargetPlotLineID" name="TargetPlotLineID">
<option value="">No target plot line</option>
@foreach (var plotLine in Model.PlotLineEventOptions)
{
<option value="@plotLine.PlotLineID" selected="@(threadEvent.TargetPlotLineID == plotLine.PlotLineID)">@plotLine.PlotLineName</option>
}
</select>
</div>
<div class="form-group">
<label for="ThreadEvent_@(threadEvent.ThreadEventID)__SelectedTargetPlotLineIDs">Split target plot lines</label>
<select class="form-control form-control-sm" id="ThreadEvent_@(threadEvent.ThreadEventID)__SelectedTargetPlotLineIDs" name="SelectedTargetPlotLineIDs" multiple size="4">
@foreach (var plotLine in Model.PlotLineEventOptions)
{
<option value="@plotLine.PlotLineID" selected="@threadEvent.TargetPlotLineIDs.Contains(plotLine.PlotLineID)">@plotLine.PlotLineName</option>
}
</select>
</div>
<div class="form-group">
<label for="ThreadEvent_@(threadEvent.ThreadEventID)__EventTitle">Event title</label>
<input class="form-control form-control-sm" id="ThreadEvent_@(threadEvent.ThreadEventID)__EventTitle" name="EventTitle" value="@threadEvent.EventTitle" />
</div>
<div class="form-group">
<label for="ThreadEvent_@(threadEvent.ThreadEventID)__EventDescription">Description</label>
<textarea class="form-control form-control-sm" id="ThreadEvent_@(threadEvent.ThreadEventID)__EventDescription" name="EventDescription" rows="3">@threadEvent.EventDescription</textarea>
</div>
<div class="scene-structure-action-row">
<button class="btn btn-sm btn-primary" type="submit" data-scene-editor-save>Save event</button>
</div>
</form>
<form asp-controller="Scenes" asp-action="DeleteThreadEvent" method="post" class="scene-structure-delete-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@threadEvent.ThreadEventID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<input type="hidden" name="projectId" value="@Model.ReturnProjectID" />
<input type="hidden" name="bookId" value="@Model.ReturnBookID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<button class="btn btn-sm btn-outline-danger" type="submit" data-scene-editor-save>Remove event</button>
</form>
</article>
}
</div>
}
</section>
<section class="scene-structure-editor-section">
<div class="scene-structure-editor-heading">
<span>Add thread event</span>
</div>
<form asp-controller="Scenes" asp-action="AddThreadEvent" method="post" class="scene-structure-editor-card scene-structure-editor-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="SceneID" value="@Model.SceneID" />
<input type="hidden" name="ReturnProjectID" value="@Model.ReturnProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.ReturnBookID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<div class="form-group">
<label for="PlotThreadID">Existing plot thread</label>
<select class="form-control form-control-sm" id="PlotThreadID" name="PlotThreadID">
<option value="">Choose a plot thread</option>
@foreach (var thread in Model.PlotThreadEventOptions)
{
<option value="@thread.PlotThreadID">@thread.PlotLineName: @thread.ThreadTitle</option>
}
</select>
</div>
<div class="scene-inspector-editor-grid">
<div class="form-group">
<label for="PlotEventTypeID">Event type</label>
<select class="form-control form-control-sm" id="PlotEventTypeID" name="PlotEventTypeID">
<option value="">Choose event type</option>
@foreach (var option in Model.PlotEventTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == Model.NewThreadEvent.PlotEventTypeID.ToString())">@option.Text</option>
}
</select>
</div>
<div class="form-group">
<label for="EventTypeID">Marker type</label>
<select class="form-control form-control-sm" id="EventTypeID" name="EventTypeID" asp-items="Model.ThreadEventTypeOptions"></select>
</div>
</div>
<div class="form-group">
<label for="TargetPlotLineID">Target plot line</label>
<select class="form-control form-control-sm" id="TargetPlotLineID" name="TargetPlotLineID">
<option value="">No target plot line</option>
@foreach (var plotLine in Model.PlotLineEventOptions)
{
<option value="@plotLine.PlotLineID">@plotLine.PlotLineName</option>
}
</select>
</div>
<div class="form-group">
<label for="SelectedTargetPlotLineIDs">Split target plot lines</label>
<select class="form-control form-control-sm" id="SelectedTargetPlotLineIDs" name="SelectedTargetPlotLineIDs" multiple size="4">
@foreach (var plotLine in Model.PlotLineEventOptions)
{
<option value="@plotLine.PlotLineID">@plotLine.PlotLineName</option>
}
</select>
</div>
<div class="form-group">
<label for="EventTitle">Event title</label>
<input class="form-control form-control-sm" id="EventTitle" name="EventTitle" placeholder="Defaults to marker type if blank" />
</div>
<div class="form-group">
<label for="EventDescription">Description</label>
<textarea class="form-control form-control-sm" id="EventDescription" name="EventDescription" rows="3"></textarea>
</div>
<div class="scene-structure-action-row">
<button class="btn btn-sm btn-primary" type="submit" data-scene-editor-save>Add thread event</button>
</div>
</form>
</section>
</div>
<div class="scene-inspector-editor-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" data-scene-editor-close>Close</button>
</div>
</div>

View File

@ -5362,6 +5362,96 @@ body.scene-inspector-editor-resizing {
gap: 8px;
}
.scene-structure-editor-section {
display: grid;
gap: 10px;
}
.scene-structure-editor-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--plotline-muted);
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
}
.scene-structure-editor-heading strong {
border-radius: 999px;
background: rgba(47, 111, 99, 0.1);
padding: 2px 8px;
color: var(--plotline-accent-dark);
}
.scene-structure-editor-list,
.scene-structure-editor-form,
.scene-structure-delete-form {
display: grid;
gap: 10px;
}
.scene-structure-editor-card {
display: grid;
gap: 10px;
border: 1px solid var(--plotline-line);
border-radius: 8px;
background: rgba(47, 111, 99, 0.04);
padding: 12px;
}
.scene-structure-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.scene-structure-card-header h4 {
margin: 0;
font-size: 1rem;
}
.scene-structure-card-header p,
.scene-structure-editor-card p {
margin: 2px 0 0;
}
.scene-structure-marker {
flex: 0 0 auto;
border-radius: 999px;
background: var(--plot-colour, var(--plotline-accent));
padding: 3px 8px;
color: #fff;
font-size: 0.78rem;
font-weight: 800;
}
.scene-structure-action-row {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.scene-structure-dependency-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.scene-structure-inline-check {
display: inline-flex;
align-items: center;
align-self: end;
gap: 6px;
margin: 0;
color: var(--plotline-muted);
font-size: 0.82rem;
font-weight: 800;
white-space: nowrap;
}
.scene-inspector-v2-timeline .scene-inspector-v2-health {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -5419,6 +5509,10 @@ body.scene-inspector-editor-resizing {
.scene-attachment-preview-row {
grid-template-columns: 1fr;
}
.scene-structure-dependency-columns {
grid-template-columns: 1fr;
}
}
.floor-plan-toolbar-floor-group {

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,7 @@
purpose: "Manage purpose",
metrics: "Manage metrics",
threads: "Manage threads",
dependencies: "Manage dependencies",
characters: "Manage characters",
knowledge: "Manage knowledge",
relationships: "Manage relationships",