PlotDirector Phase 17D - World Editor Migration

This commit is contained in:
Nick Beckley 2026-06-30 13:52:32 +01:00
parent fb5b7b6fd9
commit be1c508df4
8 changed files with 388 additions and 6 deletions

View File

@ -124,6 +124,24 @@ public sealed class ScenesController(
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneCharactersEditor.cshtml", model);
}
[HttpGet]
public async Task<IActionResult> SceneLocationsEditor(int sceneId)
{
var model = await scenes.GetEditAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneLocationsEditor.cshtml", model);
}
[HttpGet]
public async Task<IActionResult> SceneAssetsEditor(int sceneId)
{
var model = await scenes.GetEditAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneAssetsEditor.cshtml", model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSceneDetails(SceneDetailsSaveViewModel model)
@ -360,6 +378,130 @@ public sealed class ScenesController(
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSceneLocations(SceneLocationsSaveViewModel model)
{
var current = await scenes.GetEditAsync(model.SceneID);
if (current is null)
{
return NotFound();
}
if (model.PrimaryLocationID.HasValue &&
!current.LocationOptions.Any(x => x.Value == model.PrimaryLocationID.Value.ToString()))
{
return BadRequest(new { success = false, message = "Choose an existing project location, or clear the primary location." });
}
current.PrimaryLocationID = model.PrimaryLocationID;
await scenes.SaveAsync(current);
var updated = await scenes.GetEditAsync(model.SceneID);
if (updated is null)
{
return NotFound();
}
var primaryLocationName = updated.LocationOptions.FirstOrDefault(x => x.Value == updated.PrimaryLocationID?.ToString())?.Text?.Trim();
return Json(new
{
success = true,
html = new
{
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated),
overview = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml", updated),
world = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWorldCard.cshtml", updated)
},
scene = new
{
updated.SceneID,
PrimaryLocationName = primaryLocationName ?? "No location"
}
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSceneAssets(SceneAssetsSaveViewModel model)
{
var current = await scenes.GetEditAsync(model.SceneID);
if (current is null)
{
return NotFound();
}
var existingRows = current.AssetEvents.ToDictionary(x => x.AssetEventID);
var retainedAssetIds = new HashSet<int>();
foreach (var row in model.AssetEvents.Where(x => !x.Remove))
{
if (!existingRows.ContainsKey(row.AssetEventID))
{
continue;
}
retainedAssetIds.Add(row.StoryAssetID);
}
var addAssetId = model.NewAssetEvent.StoryAssetID.GetValueOrDefault();
if (addAssetId > 0)
{
if (!current.AssetOptions.Any(x => x.Value == addAssetId.ToString()))
{
return BadRequest(new { success = false, message = "Choose an existing project asset." });
}
if (!retainedAssetIds.Add(addAssetId))
{
return BadRequest(new { success = false, message = "That asset is already in this scene." });
}
}
foreach (var row in model.AssetEvents)
{
if (!existingRows.ContainsKey(row.AssetEventID))
{
continue;
}
if (row.Remove)
{
await assets.DeleteAssetEventAsync(row.AssetEventID);
}
}
if (addAssetId > 0)
{
model.NewAssetEvent.SceneID = model.SceneID;
model.NewAssetEvent.NewAssetName = null;
model.NewAssetEvent.NewAssetKindID = null;
model.NewAssetEvent.ReturnProjectID = current.ReturnProjectID;
model.NewAssetEvent.ReturnBookID = current.ReturnBookID;
await assets.AddAssetEventAsync(model.NewAssetEvent);
}
var updated = await scenes.GetEditAsync(model.SceneID);
if (updated is null)
{
return NotFound();
}
return Json(new
{
success = true,
html = new
{
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated),
world = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWorldCard.cshtml", updated)
},
scene = new
{
updated.SceneID
}
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Archive(int id, int chapterId)

View File

@ -485,6 +485,26 @@ public sealed class SceneCharactersSaveViewModel
public SceneCharacterCreateViewModel NewCharacter { get; set; } = new();
}
public sealed class SceneLocationsSaveViewModel
{
public int SceneID { get; set; }
public int? PrimaryLocationID { get; set; }
}
public sealed class SceneAssetsSaveViewModel
{
public int SceneID { get; set; }
public List<SceneAssetEventInputViewModel> AssetEvents { get; set; } = [];
public AssetEventCreateViewModel NewAssetEvent { get; set; } = new();
}
public sealed class SceneAssetEventInputViewModel
{
public int AssetEventID { get; set; }
public int StoryAssetID { get; set; }
public bool Remove { get; set; }
}
public sealed class SceneCharacterParticipationInputViewModel
{
public int SceneCharacterID { get; set; }

View File

@ -0,0 +1,105 @@
@model SceneEditViewModel
@{
var currentAssetIds = Model.AssetEvents.Select(x => x.StoryAssetID).ToHashSet();
var availableAssets = Model.AssetOptions
.Where(x => int.TryParse(x.Value, out var id) && !currentAssetIds.Contains(id))
.ToList();
}
<form asp-controller="Scenes" asp-action="SaveSceneAssets" 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-world-editor-section">
<div class="scene-world-editor-heading">
<span>Current assets</span>
<strong>@Model.AssetEvents.Select(x => x.StoryAssetID).Distinct().Count()</strong>
</div>
@if (!Model.AssetEvents.Any())
{
<p class="muted">No story assets appear in this scene yet.</p>
}
else
{
<div class="scene-world-editor-list">
@for (var index = 0; index < Model.AssetEvents.Count; index++)
{
var assetEvent = Model.AssetEvents[index];
<article class="scene-world-editor-card">
<input type="hidden" name="AssetEvents[@index].AssetEventID" value="@assetEvent.AssetEventID" />
<input type="hidden" name="AssetEvents[@index].StoryAssetID" value="@assetEvent.StoryAssetID" />
<div class="scene-world-editor-card-header">
<div>
<h4>@assetEvent.AssetName</h4>
<p>@assetEvent.EventTitle / @assetEvent.AssetEventTypeName</p>
@if (!string.IsNullOrWhiteSpace(assetEvent.ToStateName))
{
<p>State: @(assetEvent.FromStateName ?? "Any") to @assetEvent.ToStateName</p>
}
@if (!string.IsNullOrWhiteSpace(assetEvent.EventDescription))
{
<p>@assetEvent.EventDescription</p>
}
</div>
<label class="scene-world-remove">
<input type="checkbox" name="AssetEvents[@index].Remove" value="true" />
<input type="hidden" name="AssetEvents[@index].Remove" value="false" />
<span>Remove</span>
</label>
</div>
</article>
}
</div>
}
</section>
<section class="scene-world-editor-section">
<div class="scene-world-editor-heading">
<span>Add asset</span>
</div>
<div class="form-group">
<label for="NewAssetEvent_StoryAssetID">Asset</label>
<select class="form-control form-control-sm" id="NewAssetEvent_StoryAssetID" name="NewAssetEvent.StoryAssetID">
<option value="">Choose a project asset</option>
@foreach (var option in availableAssets)
{
<option value="@option.Value">@option.Text</option>
}
</select>
</div>
<div class="scene-inspector-editor-grid">
<div class="form-group">
<label for="NewAssetEvent_AssetEventTypeID">Event type</label>
<select class="form-control form-control-sm" id="NewAssetEvent_AssetEventTypeID" name="NewAssetEvent.AssetEventTypeID" asp-items="Model.AssetEventTypeOptions"></select>
</div>
<div class="form-group">
<label for="NewAssetEvent_ToStateID">To state</label>
<select class="form-control form-control-sm" id="NewAssetEvent_ToStateID" name="NewAssetEvent.ToStateID" asp-items="Model.AssetStateOptions">
<option value="">No change</option>
</select>
</div>
</div>
<div class="form-group">
<label for="NewAssetEvent_EventTitle">Event title</label>
<input class="form-control form-control-sm" id="NewAssetEvent_EventTitle" name="NewAssetEvent.EventTitle" placeholder="Defaults to event type if blank" />
</div>
<div class="form-group">
<label for="NewAssetEvent_EventDescription">Description</label>
<textarea class="form-control form-control-sm" id="NewAssetEvent_EventDescription" name="NewAssetEvent.EventDescription" rows="3"></textarea>
</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 assets</button>
</div>
</form>

View File

@ -5,7 +5,8 @@
?? Model.SceneCharacters.FirstOrDefault(x => x.RoleInSceneTypeName?.Contains("POV", StringComparison.OrdinalIgnoreCase) == true)?.CharacterName
?? "No POV";
var wordCount = Model.Workflow.ActualWordCount ?? Model.Workflow.EstimatedWordCount ?? 0;
var locationCount = Model.SceneCharacters.Where(x => x.LocationID.HasValue).Select(x => x.LocationID!.Value)
var locationCount = new[] { Model.PrimaryLocationID }.Where(x => x.HasValue).Select(x => x!.Value)
.Concat(Model.SceneCharacters.Where(x => x.LocationID.HasValue).Select(x => x.LocationID!.Value))
.Concat(Model.SceneAssetLocations.Select(x => x.LocationID))
.Distinct()
.Count();

View File

@ -0,0 +1,45 @@
@model SceneEditViewModel
@{
var primaryLocation = Model.LocationOptions.FirstOrDefault(x => x.Value == Model.PrimaryLocationID?.ToString())?.Text?.Trim();
}
<form asp-controller="Scenes" asp-action="SaveSceneLocations" 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-world-editor-section">
<div class="scene-world-editor-heading">
<span>Scene location</span>
<strong>@(Model.PrimaryLocationID.HasValue ? "1" : "0")</strong>
</div>
<article class="scene-world-editor-card">
<div class="scene-world-editor-card-header">
<div>
<h4>@(primaryLocation ?? "No primary location")</h4>
<p>Primary location is the supported scene-level location for this scene.</p>
</div>
</div>
<div class="form-group">
<label for="PrimaryLocationID">Primary location</label>
<select class="form-control form-control-sm" id="PrimaryLocationID" name="PrimaryLocationID">
<option value="">No primary location</option>
@foreach (var option in Model.LocationOptions)
{
<option value="@option.Value" selected="@(option.Value == Model.PrimaryLocationID?.ToString())">@option.Text</option>
}
</select>
</div>
</article>
</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 locations</button>
</div>
</form>

View File

@ -1,13 +1,16 @@
@model SceneEditViewModel
@{
var locationNames = Model.SceneCharacters.Where(x => !string.IsNullOrWhiteSpace(x.LocationName)).Select(x => x.LocationName!)
var primaryLocation = Model.LocationOptions.FirstOrDefault(x => x.Value == Model.PrimaryLocationID?.ToString())?.Text?.Trim();
var locationNames = new[] { primaryLocation }.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x!)
.Concat(Model.SceneCharacters.Where(x => !string.IsNullOrWhiteSpace(x.LocationName)).Select(x => x.LocationName!))
.Concat(Model.SceneAssetLocations.Where(x => !string.IsNullOrWhiteSpace(x.LocationName)).Select(x => x.LocationName!))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var assetNames = Model.AssetEvents.Select(x => x.AssetName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
<article class="scene-inspector-v2-card">
<article class="scene-inspector-v2-card" data-scene-summary-part="world">
<div class="scene-inspector-v2-card-header">
<div>
<p class="eyebrow">World</p>
@ -65,8 +68,8 @@
</div>
<div class="scene-inspector-v2-actions">
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="locations" data-scene-id="@Model.SceneID">Manage locations</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="assets" data-scene-id="@Model.SceneID">Manage assets</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="locations" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneLocationsEditor", "Scenes", new { sceneId = Model.SceneID })">Manage locations</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="assets" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneAssetsEditor", "Scenes", new { sceneId = Model.SceneID })">Manage assets</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="custody" data-scene-id="@Model.SceneID">Manage custody</button>
</div>
</article>

View File

@ -5168,6 +5168,72 @@ body.scene-inspector-editor-resizing {
white-space: nowrap;
}
.scene-world-editor-section {
display: grid;
gap: 10px;
}
.scene-world-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-world-editor-heading strong {
border-radius: 999px;
background: rgba(47, 111, 99, 0.1);
padding: 2px 8px;
color: var(--plotline-accent-dark);
}
.scene-world-editor-list {
display: grid;
gap: 10px;
}
.scene-world-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-world-editor-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.scene-world-editor-card-header h4 {
margin: 0;
font-size: 1rem;
}
.scene-world-editor-card-header p {
margin: 2px 0 0;
color: var(--plotline-muted);
font-size: 0.86rem;
}
.scene-world-remove {
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));
}

File diff suppressed because one or more lines are too long