PlotDirector Phase 17E - Writer Tools Editor Migration

This commit is contained in:
Nick Beckley 2026-06-30 14:10:31 +01:00
parent be1c508df4
commit 0d72dc64d0
9 changed files with 580 additions and 4 deletions

View File

@ -142,6 +142,33 @@ public sealed class ScenesController(
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneAssetsEditor.cshtml", model);
}
[HttpGet]
public async Task<IActionResult> SceneNotesEditor(int sceneId)
{
var model = await scenes.GetEditAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneNotesEditor.cshtml", model);
}
[HttpGet]
public async Task<IActionResult> SceneChecklistEditor(int sceneId)
{
var model = await scenes.GetEditAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneChecklistEditor.cshtml", model);
}
[HttpGet]
public async Task<IActionResult> SceneWorkflowEditor(int sceneId)
{
var model = await scenes.GetEditAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneWorkflowEditor.cshtml", model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSceneDetails(SceneDetailsSaveViewModel model)
@ -502,6 +529,199 @@ public sealed class ScenesController(
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSceneNotes(SceneNotesSaveViewModel model)
{
var current = await scenes.GetEditAsync(model.SceneID);
if (current is null)
{
return NotFound();
}
var existingRows = current.Notes.ToDictionary(x => x.SceneNoteID);
var validNoteTypeIds = current.SceneNoteTypeOptions
.Where(x => int.TryParse(x.Value, out _))
.Select(x => int.Parse(x.Value))
.ToHashSet();
foreach (var note in model.Notes)
{
if (!existingRows.ContainsKey(note.SceneNoteID))
{
continue;
}
if (note.Remove)
{
await writerWorkspace.DeleteNoteAsync(note.SceneNoteID);
continue;
}
if (string.IsNullOrWhiteSpace(note.NoteText))
{
return BadRequest(new { success = false, message = "Enter note text before saving." });
}
if (!validNoteTypeIds.Contains(note.SceneNoteTypeID))
{
return BadRequest(new { success = false, message = "Choose an existing note type." });
}
note.SceneID = model.SceneID;
await writerWorkspace.SaveNoteAsync(new SceneNoteEditViewModel
{
SceneNoteID = note.SceneNoteID,
SceneID = note.SceneID,
SceneNoteTypeID = note.SceneNoteTypeID,
NoteTitle = note.NoteTitle,
NoteText = note.NoteText,
SortOrder = note.SortOrder,
IsPinned = note.IsPinned,
IsResolved = note.IsResolved
});
}
var hasNewNoteContent = !string.IsNullOrWhiteSpace(model.NewNote.NoteTitle)
|| !string.IsNullOrWhiteSpace(model.NewNote.NoteText);
if (hasNewNoteContent)
{
if (string.IsNullOrWhiteSpace(model.NewNote.NoteText))
{
return BadRequest(new { success = false, message = "Enter note text before saving." });
}
if (!validNoteTypeIds.Contains(model.NewNote.SceneNoteTypeID))
{
return BadRequest(new { success = false, message = "Choose an existing note type." });
}
model.NewNote.SceneID = model.SceneID;
await writerWorkspace.SaveNoteAsync(model.NewNote);
}
var updated = await scenes.GetEditAsync(model.SceneID);
if (updated is null)
{
return NotFound();
}
return Json(new
{
success = true,
html = new
{
writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated),
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated)
},
scene = new
{
updated.SceneID
}
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSceneChecklist(SceneChecklistSaveViewModel model)
{
var current = await scenes.GetEditAsync(model.SceneID);
if (current is null)
{
return NotFound();
}
var existingRows = current.ChecklistItems.ToDictionary(x => x.SceneChecklistItemID);
foreach (var item in model.ChecklistItems)
{
if (!existingRows.ContainsKey(item.SceneChecklistItemID))
{
continue;
}
if (item.Remove)
{
await writerWorkspace.DeleteChecklistItemAsync(item.SceneChecklistItemID);
continue;
}
if (string.IsNullOrWhiteSpace(item.ItemText))
{
return BadRequest(new { success = false, message = "Enter checklist text before saving." });
}
item.SceneID = model.SceneID;
await writerWorkspace.SaveChecklistItemAsync(new SceneChecklistItemEditViewModel
{
SceneChecklistItemID = item.SceneChecklistItemID,
SceneID = item.SceneID,
ItemText = item.ItemText,
IsCompleted = item.IsCompleted,
SortOrder = item.SortOrder
});
}
if (!string.IsNullOrWhiteSpace(model.NewChecklistItem.ItemText))
{
model.NewChecklistItem.SceneID = model.SceneID;
await writerWorkspace.SaveChecklistItemAsync(model.NewChecklistItem);
}
var updated = await scenes.GetEditAsync(model.SceneID);
if (updated is null)
{
return NotFound();
}
return Json(new
{
success = true,
html = new
{
writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated),
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated)
},
scene = new
{
updated.SceneID
}
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSceneWorkflow(SceneWorkflowEditViewModel model)
{
var current = await scenes.GetEditAsync(model.SceneID);
if (current is null)
{
return NotFound();
}
await writerWorkspace.SaveSceneWorkflowAsync(model);
var updated = await scenes.GetEditAsync(model.SceneID);
if (updated is null)
{
return NotFound();
}
return Json(new
{
success = true,
html = new
{
writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated),
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated),
overview = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml", updated)
},
scene = new
{
updated.SceneID
}
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Archive(int id, int chapterId)

View File

@ -505,6 +505,43 @@ public sealed class SceneAssetEventInputViewModel
public bool Remove { get; set; }
}
public sealed class SceneNotesSaveViewModel
{
public int SceneID { get; set; }
public List<SceneNoteInputViewModel> Notes { get; set; } = [];
public SceneNoteEditViewModel NewNote { get; set; } = new();
}
public sealed class SceneNoteInputViewModel
{
public int SceneNoteID { get; set; }
public int SceneID { get; set; }
public int SceneNoteTypeID { get; set; }
public string? NoteTitle { get; set; }
public string NoteText { get; set; } = string.Empty;
public int? SortOrder { get; set; }
public bool IsPinned { get; set; }
public bool IsResolved { get; set; }
public bool Remove { get; set; }
}
public sealed class SceneChecklistSaveViewModel
{
public int SceneID { get; set; }
public List<SceneChecklistItemInputViewModel> ChecklistItems { get; set; } = [];
public SceneChecklistItemEditViewModel NewChecklistItem { get; set; } = new();
}
public sealed class SceneChecklistItemInputViewModel
{
public int SceneChecklistItemID { get; set; }
public int SceneID { get; set; }
public string ItemText { get; set; } = string.Empty;
public bool IsCompleted { get; set; }
public int? SortOrder { get; set; }
public bool Remove { get; set; }
}
public sealed class SceneCharacterParticipationInputViewModel
{
public int SceneCharacterID { get; set; }

View File

@ -0,0 +1,68 @@
@model SceneEditViewModel
@{
var completed = Model.ChecklistItems.Count(x => x.IsCompleted);
}
<form asp-controller="Scenes" asp-action="SaveSceneChecklist" method="post" class="scene-inspector-editor-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" asp-for="SceneID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<div class="scene-inspector-editor-fields">
<section class="scene-writer-editor-section">
<div class="scene-writer-editor-heading">
<span>Current checklist</span>
<strong>@completed / @Model.ChecklistItems.Count</strong>
</div>
@if (!Model.ChecklistItems.Any())
{
<p class="muted">No checklist items yet.</p>
}
else
{
<div class="scene-writer-editor-list">
@for (var index = 0; index < Model.ChecklistItems.Count; index++)
{
var item = Model.ChecklistItems[index];
<article class="scene-writer-checklist-row">
<input type="hidden" name="ChecklistItems[@index].SceneChecklistItemID" value="@item.SceneChecklistItemID" />
<input type="hidden" name="ChecklistItems[@index].SceneID" value="@Model.SceneID" />
<input type="hidden" name="ChecklistItems[@index].SortOrder" value="@item.SortOrder" />
<label class="scene-writer-checklist-complete">
<input type="checkbox" name="ChecklistItems[@index].IsCompleted" value="true" checked="@item.IsCompleted" />
<input type="hidden" name="ChecklistItems[@index].IsCompleted" value="false" />
<span>Done</span>
</label>
<div class="form-group">
<label class="visually-hidden" for="ChecklistItems_@(index)__ItemText">Checklist item</label>
<input class="form-control form-control-sm" id="ChecklistItems_@(index)__ItemText" name="ChecklistItems[@index].ItemText" value="@item.ItemText" />
</div>
<label class="scene-writer-remove">
<input type="checkbox" name="ChecklistItems[@index].Remove" value="true" />
<input type="hidden" name="ChecklistItems[@index].Remove" value="false" />
<span>Remove</span>
</label>
</article>
}
</div>
}
</section>
<section class="scene-writer-editor-section">
<div class="scene-writer-editor-heading">
<span>Add checklist item</span>
</div>
<div class="form-group">
<label for="NewChecklistItem_ItemText">Item text</label>
<input class="form-control form-control-sm" id="NewChecklistItem_ItemText" name="NewChecklistItem.ItemText" />
</div>
</section>
</div>
<div class="scene-inspector-editor-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" data-scene-editor-close>Cancel</button>
<button type="submit" class="btn btn-sm btn-primary" data-scene-editor-save>Save checklist</button>
</div>
</form>

View File

@ -0,0 +1,109 @@
@model SceneEditViewModel
<form asp-controller="Scenes" asp-action="SaveSceneNotes" method="post" class="scene-inspector-editor-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" asp-for="SceneID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<div class="scene-inspector-editor-fields">
<section class="scene-writer-editor-section">
<div class="scene-writer-editor-heading">
<span>Current notes</span>
<strong>@Model.Notes.Count</strong>
</div>
@if (!Model.Notes.Any())
{
<p class="muted">No working notes yet.</p>
}
else
{
<div class="scene-writer-editor-list">
@for (var index = 0; index < Model.Notes.Count; index++)
{
var note = Model.Notes[index];
<article class="scene-writer-editor-card">
<input type="hidden" name="Notes[@index].SceneNoteID" value="@note.SceneNoteID" />
<input type="hidden" name="Notes[@index].SceneID" value="@Model.SceneID" />
<input type="hidden" name="Notes[@index].SortOrder" value="@note.SortOrder" />
<div class="scene-writer-editor-card-header">
<div>
<h4>@(string.IsNullOrWhiteSpace(note.NoteTitle) ? note.TypeName : note.NoteTitle)</h4>
<p>@note.TypeName</p>
</div>
<label class="scene-writer-remove">
<input type="checkbox" name="Notes[@index].Remove" value="true" />
<input type="hidden" name="Notes[@index].Remove" value="false" />
<span>Remove</span>
</label>
</div>
<div class="form-group">
<label for="Notes_@(index)__SceneNoteTypeID">Type</label>
<select class="form-control form-control-sm" id="Notes_@(index)__SceneNoteTypeID" name="Notes[@index].SceneNoteTypeID">
@foreach (var option in Model.SceneNoteTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == note.SceneNoteTypeID.ToString())">@option.Text</option>
}
</select>
</div>
<div class="form-group">
<label for="Notes_@(index)__NoteTitle">Title</label>
<input class="form-control form-control-sm" id="Notes_@(index)__NoteTitle" name="Notes[@index].NoteTitle" value="@note.NoteTitle" />
</div>
<div class="form-group">
<label for="Notes_@(index)__NoteText">Note text</label>
<textarea class="form-control form-control-sm" id="Notes_@(index)__NoteText" name="Notes[@index].NoteText" rows="3">@note.NoteText</textarea>
</div>
<div class="scene-inspector-editor-checks">
<label>
<input type="checkbox" name="Notes[@index].IsPinned" value="true" checked="@note.IsPinned" />
<input type="hidden" name="Notes[@index].IsPinned" value="false" />
<span>Pinned</span>
</label>
<label>
<input type="checkbox" name="Notes[@index].IsResolved" value="true" checked="@note.IsResolved" />
<input type="hidden" name="Notes[@index].IsResolved" value="false" />
<span>Resolved</span>
</label>
</div>
</article>
}
</div>
}
</section>
<section class="scene-writer-editor-section">
<div class="scene-writer-editor-heading">
<span>Add note</span>
</div>
<div class="form-group">
<label for="NewNote_SceneNoteTypeID">Type</label>
<select class="form-control form-control-sm" id="NewNote_SceneNoteTypeID" name="NewNote.SceneNoteTypeID" asp-items="Model.SceneNoteTypeOptions"></select>
</div>
<div class="form-group">
<label for="NewNote_NoteTitle">Title</label>
<input class="form-control form-control-sm" id="NewNote_NoteTitle" name="NewNote.NoteTitle" />
</div>
<div class="form-group">
<label for="NewNote_NoteText">Note text</label>
<textarea class="form-control form-control-sm" id="NewNote_NoteText" name="NewNote.NoteText" rows="3"></textarea>
</div>
<label class="scene-writer-inline-check">
<input type="checkbox" name="NewNote.IsPinned" value="true" />
<input type="hidden" name="NewNote.IsPinned" value="false" />
<span>Pinned</span>
</label>
</section>
</div>
<div class="scene-inspector-editor-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" data-scene-editor-close>Cancel</button>
<button type="submit" class="btn btn-sm btn-primary" data-scene-editor-save>Save notes</button>
</div>
</form>

View File

@ -0,0 +1,61 @@
@model SceneEditViewModel
<form asp-controller="Scenes" asp-action="SaveSceneWorkflow" method="post" class="scene-inspector-editor-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" asp-for="SceneID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<div class="scene-inspector-editor-fields">
<section class="scene-writer-editor-section">
<div class="scene-writer-editor-heading">
<span>Workflow</span>
</div>
<div class="scene-writer-editor-card">
<div class="form-group">
<label for="Priority">Priority</label>
<input class="form-control form-control-sm" id="Priority" name="Priority" type="number" min="1" max="10" value="@Model.Workflow.Priority" />
</div>
<div class="scene-inspector-editor-grid">
<div class="form-group">
<label for="EstimatedWordCount">Estimated words</label>
<input class="form-control form-control-sm" id="EstimatedWordCount" name="EstimatedWordCount" type="number" min="0" value="@Model.Workflow.EstimatedWordCount" />
</div>
<div class="form-group">
<label for="ActualWordCount">Actual words</label>
<input class="form-control form-control-sm" id="ActualWordCount" name="ActualWordCount" type="number" min="0" value="@Model.Workflow.ActualWordCount" />
</div>
</div>
<div class="scene-inspector-editor-grid">
<div class="form-group">
<label for="DraftedDate">Drafted date</label>
<input class="form-control form-control-sm" id="DraftedDate" name="DraftedDate" type="date" value="@Model.Workflow.DraftedDate?.ToString("yyyy-MM-dd")" />
</div>
<div class="form-group">
<label for="LastWorkedOn">Last worked on</label>
<input class="form-control form-control-sm" id="LastWorkedOn" name="LastWorkedOn" type="date" value="@Model.Workflow.LastWorkedOn?.ToString("yyyy-MM-dd")" />
</div>
</div>
<label class="scene-writer-inline-check">
<input type="checkbox" name="IsBlocked" value="true" checked="@Model.Workflow.IsBlocked" />
<input type="hidden" name="IsBlocked" value="false" />
<span>Blocked</span>
</label>
<div class="form-group">
<label for="BlockedReason">Blocked reason</label>
<input class="form-control form-control-sm" id="BlockedReason" name="BlockedReason" value="@Model.Workflow.BlockedReason" />
</div>
</div>
</section>
</div>
<div class="scene-inspector-editor-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" data-scene-editor-close>Cancel</button>
<button type="submit" class="btn btn-sm btn-primary" data-scene-editor-save>Save workflow</button>
</div>
</form>

View File

@ -5,7 +5,7 @@
var checklistLabel = total == 0 ? "No checklist yet" : $"{completed} of {total} complete";
}
<article class="scene-inspector-v2-card">
<article class="scene-inspector-v2-card" data-scene-summary-part="writer">
<div class="scene-inspector-v2-card-header">
<div>
<p class="eyebrow">Writer</p>
@ -37,8 +37,9 @@
}
<div class="scene-inspector-v2-actions">
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="notes" data-scene-id="@Model.SceneID">Manage notes</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="checklist" data-scene-id="@Model.SceneID">Manage checklist</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="notes" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneNotesEditor", "Scenes", new { sceneId = Model.SceneID })">Manage notes</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="checklist" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneChecklistEditor", "Scenes", new { sceneId = Model.SceneID })">Manage checklist</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="workflow" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneWorkflowEditor", "Scenes", new { sceneId = Model.SceneID })">Manage workflow</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="attachments" data-scene-id="@Model.SceneID">Manage attachments</button>
</div>
</article>

View File

@ -5234,6 +5234,80 @@ body.scene-inspector-editor-resizing {
white-space: nowrap;
}
.scene-writer-editor-section {
display: grid;
gap: 10px;
}
.scene-writer-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-writer-editor-heading strong {
border-radius: 999px;
background: rgba(47, 111, 99, 0.1);
padding: 2px 8px;
color: var(--plotline-accent-dark);
}
.scene-writer-editor-list {
display: grid;
gap: 10px;
}
.scene-writer-editor-card,
.scene-writer-checklist-row {
display: grid;
gap: 10px;
border: 1px solid var(--plotline-line);
border-radius: 8px;
background: rgba(47, 111, 99, 0.04);
padding: 12px;
}
.scene-writer-editor-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.scene-writer-editor-card-header h4 {
margin: 0;
font-size: 1rem;
}
.scene-writer-editor-card-header p {
margin: 2px 0 0;
color: var(--plotline-muted);
font-size: 0.86rem;
}
.scene-writer-checklist-row {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
}
.scene-writer-remove,
.scene-writer-checklist-complete,
.scene-writer-inline-check {
display: inline-flex;
align-items: center;
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));
}
@ -5282,6 +5356,11 @@ body.scene-inspector-editor-resizing {
.scene-inspector-editor-checks {
grid-template-columns: 1fr;
}
.scene-writer-checklist-row {
grid-template-columns: 1fr;
align-items: stretch;
}
}
.floor-plan-toolbar-floor-group {

File diff suppressed because one or more lines are too long

View File

@ -12,6 +12,7 @@
custody: "Manage custody",
notes: "Manage notes",
checklist: "Manage checklist",
workflow: "Manage workflow",
attachments: "Manage attachments",
warnings: "Review warnings"
};