PlotDirector Phase 17C - People Editor Migration
This commit is contained in:
parent
26846f57e7
commit
fb5b7b6fd9
@ -115,6 +115,15 @@ public sealed class ScenesController(
|
||||
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneMetricsEditor.cshtml", model);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> SceneCharactersEditor(int sceneId)
|
||||
{
|
||||
var model = await scenes.GetEditAsync(sceneId);
|
||||
return model is null
|
||||
? NotFound()
|
||||
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneCharactersEditor.cshtml", model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveSceneDetails(SceneDetailsSaveViewModel model)
|
||||
@ -242,6 +251,115 @@ public sealed class ScenesController(
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveSceneCharacters(SceneCharactersSaveViewModel model)
|
||||
{
|
||||
var current = await scenes.GetEditAsync(model.SceneID);
|
||||
if (current is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var existingRows = current.SceneCharacters.ToDictionary(x => x.SceneCharacterID);
|
||||
var retainedCharacterIds = new HashSet<int>();
|
||||
foreach (var row in model.Characters.Where(x => !x.Remove))
|
||||
{
|
||||
if (!existingRows.ContainsKey(row.SceneCharacterID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!retainedCharacterIds.Add(row.CharacterID))
|
||||
{
|
||||
return BadRequest(new { success = false, message = "Each character can only appear once in a scene." });
|
||||
}
|
||||
}
|
||||
|
||||
var addCharacterId = model.NewCharacter.CharacterID.GetValueOrDefault();
|
||||
if (addCharacterId > 0 && !retainedCharacterIds.Add(addCharacterId))
|
||||
{
|
||||
return BadRequest(new { success = false, message = "That character is already in this scene." });
|
||||
}
|
||||
|
||||
foreach (var row in model.Characters)
|
||||
{
|
||||
if (!existingRows.ContainsKey(row.SceneCharacterID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.Remove)
|
||||
{
|
||||
await characters.DeleteSceneCharacterAsync(row.SceneCharacterID);
|
||||
continue;
|
||||
}
|
||||
|
||||
await characters.UpdateSceneCharacterAsync(new SceneCharacterEditViewModel
|
||||
{
|
||||
SceneCharacterID = row.SceneCharacterID,
|
||||
SceneID = model.SceneID,
|
||||
CharacterID = row.CharacterID,
|
||||
RoleInSceneTypeID = row.RoleInSceneTypeID,
|
||||
PresenceTypeID = row.PresenceTypeID,
|
||||
LocationID = row.LocationID,
|
||||
EntryLocationID = row.EntryLocationID,
|
||||
ExitLocationID = row.ExitLocationID,
|
||||
AppearanceNotes = row.AppearanceNotes,
|
||||
OutfitDescription = row.OutfitDescription,
|
||||
PhysicalCondition = row.PhysicalCondition,
|
||||
EmotionalState = row.EmotionalState
|
||||
});
|
||||
}
|
||||
|
||||
if (addCharacterId > 0)
|
||||
{
|
||||
model.NewCharacter.SceneID = model.SceneID;
|
||||
model.NewCharacter.NewCharacterName = null;
|
||||
model.NewCharacter.KnowledgeNotes = null;
|
||||
model.NewCharacter.ReturnProjectID = current.ReturnProjectID;
|
||||
model.NewCharacter.ReturnBookID = current.ReturnBookID;
|
||||
await characters.AddSceneCharacterAsync(model.NewCharacter);
|
||||
}
|
||||
|
||||
var updatedBeforePov = await scenes.GetEditAsync(model.SceneID);
|
||||
if (updatedBeforePov is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var validPovIds = updatedBeforePov.SceneCharacters.Select(x => x.CharacterID).ToHashSet();
|
||||
if (model.POVCharacterID.HasValue && !validPovIds.Contains(model.POVCharacterID.Value))
|
||||
{
|
||||
return BadRequest(new { success = false, message = "Choose a POV character who is in this scene, or clear the POV." });
|
||||
}
|
||||
|
||||
await scenes.UpdatePovCharacterAsync(model.SceneID, model.POVCharacterID);
|
||||
|
||||
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),
|
||||
overview = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml", updated),
|
||||
people = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_ScenePeopleCard.cshtml", updated)
|
||||
},
|
||||
scene = new
|
||||
{
|
||||
updated.SceneID,
|
||||
updated.SceneTitle,
|
||||
PovLabel = updated.CharacterOptions.FirstOrDefault(x => x.Value == updated.POVCharacterID?.ToString())?.Text ?? "No POV"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Archive(int id, int chapterId)
|
||||
|
||||
@ -477,6 +477,31 @@ public sealed class SceneMetricsSaveViewModel
|
||||
public List<SceneMetricInputViewModel> Metrics { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SceneCharactersSaveViewModel
|
||||
{
|
||||
public int SceneID { get; set; }
|
||||
public int? POVCharacterID { get; set; }
|
||||
public List<SceneCharacterParticipationInputViewModel> Characters { get; set; } = [];
|
||||
public SceneCharacterCreateViewModel NewCharacter { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SceneCharacterParticipationInputViewModel
|
||||
{
|
||||
public int SceneCharacterID { get; set; }
|
||||
public int SceneID { get; set; }
|
||||
public int CharacterID { get; set; }
|
||||
public bool Remove { get; set; }
|
||||
public int? RoleInSceneTypeID { get; set; }
|
||||
public int? PresenceTypeID { get; set; }
|
||||
public int? LocationID { get; set; }
|
||||
public int? EntryLocationID { get; set; }
|
||||
public int? ExitLocationID { get; set; }
|
||||
public string? AppearanceNotes { get; set; }
|
||||
public string? OutfitDescription { get; set; }
|
||||
public string? PhysicalCondition { get; set; }
|
||||
public string? EmotionalState { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneOccupancyEditorViewModel
|
||||
{
|
||||
public int SceneID { get; set; }
|
||||
|
||||
@ -0,0 +1,193 @@
|
||||
@model SceneEditViewModel
|
||||
@{
|
||||
var currentCharacterIds = Model.SceneCharacters.Select(x => x.CharacterID).ToHashSet();
|
||||
var availableCharacters = Model.CharacterOptions
|
||||
.Where(x => int.TryParse(x.Value, out var id) && !currentCharacterIds.Contains(id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
<form asp-controller="Scenes" asp-action="SaveSceneCharacters" 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">
|
||||
<fieldset class="scene-inspector-editor-fieldset">
|
||||
<legend>POV character</legend>
|
||||
<div class="scene-character-pov-options">
|
||||
<label>
|
||||
<input type="radio" name="POVCharacterID" value="" checked="@(!Model.POVCharacterID.HasValue)" />
|
||||
<span>No POV</span>
|
||||
</label>
|
||||
@foreach (var character in Model.SceneCharacters)
|
||||
{
|
||||
<label>
|
||||
<input type="radio" name="POVCharacterID" value="@character.CharacterID" checked="@(Model.POVCharacterID == character.CharacterID)" />
|
||||
<span>@character.CharacterName</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<section class="scene-character-editor-section">
|
||||
<div class="scene-character-editor-heading">
|
||||
<span>Current characters</span>
|
||||
<strong>@Model.SceneCharacters.Count</strong>
|
||||
</div>
|
||||
|
||||
@if (!Model.SceneCharacters.Any())
|
||||
{
|
||||
<p class="muted">No characters have been added to this scene.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="scene-character-editor-list">
|
||||
@for (var index = 0; index < Model.SceneCharacters.Count; index++)
|
||||
{
|
||||
var character = Model.SceneCharacters[index];
|
||||
<article class="scene-character-editor-card">
|
||||
<input type="hidden" name="Characters[@index].SceneCharacterID" value="@character.SceneCharacterID" />
|
||||
<input type="hidden" name="Characters[@index].SceneID" value="@Model.SceneID" />
|
||||
<input type="hidden" name="Characters[@index].CharacterID" value="@character.CharacterID" />
|
||||
<div class="scene-character-editor-card-header">
|
||||
<div>
|
||||
<h4>@character.CharacterName</h4>
|
||||
<p>@(character.RoleInSceneTypeName ?? "Role not set") / @(character.PresenceTypeName ?? "Presence not set")</p>
|
||||
</div>
|
||||
<label class="scene-character-remove">
|
||||
<input type="checkbox" name="Characters[@index].Remove" value="true" />
|
||||
<input type="hidden" name="Characters[@index].Remove" value="false" />
|
||||
<span>Remove</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="scene-inspector-editor-grid">
|
||||
<div class="form-group">
|
||||
<label for="Characters_@(index)__RoleInSceneTypeID">Role</label>
|
||||
<select class="form-control form-control-sm" id="Characters_@(index)__RoleInSceneTypeID" name="Characters[@index].RoleInSceneTypeID">
|
||||
<option value="">Not set</option>
|
||||
@foreach (var option in Model.CharacterRoleOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@(option.Value == character.RoleInSceneTypeID?.ToString())">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="Characters_@(index)__PresenceTypeID">Presence</label>
|
||||
<select class="form-control form-control-sm" id="Characters_@(index)__PresenceTypeID" name="Characters[@index].PresenceTypeID">
|
||||
<option value="">Not set</option>
|
||||
@foreach (var option in Model.PresenceTypeOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@(option.Value == character.PresenceTypeID?.ToString())">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="Characters_@(index)__LocationID">Current location</label>
|
||||
<select class="form-control form-control-sm" id="Characters_@(index)__LocationID" name="Characters[@index].LocationID">
|
||||
<option value="">Not set</option>
|
||||
@foreach (var option in Model.LocationOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@(option.Value == character.LocationID?.ToString())">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="scene-inspector-editor-grid">
|
||||
<div class="form-group">
|
||||
<label for="Characters_@(index)__EntryLocationID">Enters from</label>
|
||||
<select class="form-control form-control-sm" id="Characters_@(index)__EntryLocationID" name="Characters[@index].EntryLocationID">
|
||||
<option value="">Not set</option>
|
||||
@foreach (var option in Model.LocationOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@(option.Value == character.EntryLocationID?.ToString())">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="Characters_@(index)__ExitLocationID">Exits to</label>
|
||||
<select class="form-control form-control-sm" id="Characters_@(index)__ExitLocationID" name="Characters[@index].ExitLocationID">
|
||||
<option value="">Not set</option>
|
||||
@foreach (var option in Model.LocationOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@(option.Value == character.ExitLocationID?.ToString())">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="Characters_@(index)__AppearanceNotes">Appearance notes</label>
|
||||
<textarea class="form-control form-control-sm" id="Characters_@(index)__AppearanceNotes" name="Characters[@index].AppearanceNotes" rows="2">@character.AppearanceNotes</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="Characters_@(index)__OutfitDescription">Outfit description</label>
|
||||
<input class="form-control form-control-sm" id="Characters_@(index)__OutfitDescription" name="Characters[@index].OutfitDescription" value="@character.OutfitDescription" />
|
||||
</div>
|
||||
|
||||
<div class="scene-inspector-editor-grid">
|
||||
<div class="form-group">
|
||||
<label for="Characters_@(index)__PhysicalCondition">Physical condition</label>
|
||||
<input class="form-control form-control-sm" id="Characters_@(index)__PhysicalCondition" name="Characters[@index].PhysicalCondition" value="@character.PhysicalCondition" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="Characters_@(index)__EmotionalState">Emotional state</label>
|
||||
<input class="form-control form-control-sm" id="Characters_@(index)__EmotionalState" name="Characters[@index].EmotionalState" value="@character.EmotionalState" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="scene-character-editor-section">
|
||||
<div class="scene-character-editor-heading">
|
||||
<span>Add character</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="NewCharacter_CharacterID">Character</label>
|
||||
<select class="form-control form-control-sm" id="NewCharacter_CharacterID" name="NewCharacter.CharacterID">
|
||||
<option value="">Choose a project character</option>
|
||||
@foreach (var option in availableCharacters)
|
||||
{
|
||||
<option value="@option.Value">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="scene-inspector-editor-grid">
|
||||
<div class="form-group">
|
||||
<label for="NewCharacter_RoleInSceneTypeID">Role</label>
|
||||
<select class="form-control form-control-sm" id="NewCharacter_RoleInSceneTypeID" name="NewCharacter.RoleInSceneTypeID" asp-items="Model.CharacterRoleOptions">
|
||||
<option value="">Not set</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="NewCharacter_PresenceTypeID">Presence</label>
|
||||
<select class="form-control form-control-sm" id="NewCharacter_PresenceTypeID" name="NewCharacter.PresenceTypeID" asp-items="Model.PresenceTypeOptions">
|
||||
<option value="">Not set</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="NewCharacter_LocationID">Current location</label>
|
||||
<select class="form-control form-control-sm" id="NewCharacter_LocationID" name="NewCharacter.LocationID" asp-items="Model.LocationOptions">
|
||||
<option value="">Not set</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="NewCharacter_AppearanceNotes">Appearance notes</label>
|
||||
<textarea class="form-control form-control-sm" id="NewCharacter_AppearanceNotes" name="NewCharacter.AppearanceNotes" rows="2"></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 characters</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -1,9 +1,10 @@
|
||||
@model SceneEditViewModel
|
||||
@{
|
||||
var povCharacterId = Model.SceneCharacters.FirstOrDefault(x => x.RoleInSceneTypeName?.Contains("POV", StringComparison.OrdinalIgnoreCase) == true)?.CharacterID;
|
||||
var povCharacterId = Model.POVCharacterID
|
||||
?? Model.SceneCharacters.FirstOrDefault(x => x.RoleInSceneTypeName?.Contains("POV", StringComparison.OrdinalIgnoreCase) == true)?.CharacterID;
|
||||
}
|
||||
|
||||
<article class="scene-inspector-v2-card">
|
||||
<article class="scene-inspector-v2-card" data-scene-summary-part="people">
|
||||
<div class="scene-inspector-v2-card-header">
|
||||
<div>
|
||||
<p class="eyebrow">People</p>
|
||||
@ -51,7 +52,7 @@
|
||||
</div>
|
||||
|
||||
<div class="scene-inspector-v2-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="characters" data-scene-id="@Model.SceneID">Manage characters</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="characters" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneCharactersEditor", "Scenes", new { sceneId = Model.SceneID })">Manage characters</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="knowledge" data-scene-id="@Model.SceneID">Manage knowledge</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="relationships" data-scene-id="@Model.SceneID">Manage relationships</button>
|
||||
</div>
|
||||
|
||||
@ -5089,6 +5089,85 @@ body.scene-inspector-editor-resizing {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.scene-character-editor-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scene-character-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-character-editor-heading strong {
|
||||
border-radius: 999px;
|
||||
background: rgba(47, 111, 99, 0.1);
|
||||
padding: 2px 8px;
|
||||
color: var(--plotline-accent-dark);
|
||||
}
|
||||
|
||||
.scene-character-pov-options {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.scene-character-pov-options label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scene-character-editor-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scene-character-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-character-editor-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scene-character-editor-card-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.scene-character-editor-card-header p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--plotline-muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.scene-character-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