PlotDirector Phase 17D - World Editor Migration
This commit is contained in:
parent
fb5b7b6fd9
commit
be1c508df4
@ -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)
|
||||
|
||||
@ -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; }
|
||||
|
||||
105
PlotLine/Views/Shared/SceneInspectorV2/_SceneAssetsEditor.cshtml
Normal file
105
PlotLine/Views/Shared/SceneInspectorV2/_SceneAssetsEditor.cshtml
Normal 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>
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user