Phase 6C Plot Line maintenance.

This commit is contained in:
Nick Beckley 2026-06-11 20:00:18 +01:00
parent 023ca1404e
commit ba5960f907
6 changed files with 467 additions and 18 deletions

View File

@ -107,19 +107,33 @@ public sealed class ScenesController(
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> AddThreadEvent(ThreadEventCreateViewModel model) public async Task<IActionResult> AddThreadEvent(ThreadEventCreateViewModel model)
{
try
{ {
await plots.AddThreadEventAsync(model); await plots.AddThreadEventAsync(model);
if (model.ReturnProjectID.HasValue) }
catch (InvalidOperationException ex)
{ {
return RedirectToAction("Index", "Timeline", new TempData["ThreadEventAddError"] = ex.Message;
{
projectId = model.ReturnProjectID.Value,
bookId = model.ReturnBookID,
selectedSceneId = model.SceneID
});
} }
return RedirectToAction(nameof(Edit), new { id = model.SceneID }); return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateThreadEvent(ThreadEventEditViewModel model)
{
try
{
await plots.UpdateThreadEventAsync(model);
}
catch (InvalidOperationException ex)
{
TempData[$"ThreadEventEditError:{model.ThreadEventID}"] = ex.Message;
}
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
} }
[HttpPost] [HttpPost]

View File

@ -126,6 +126,7 @@ public interface IPlotRepository
Task<IReadOnlyList<ThreadEvent>> ListThreadEventsBySceneAsync(int sceneId); Task<IReadOnlyList<ThreadEvent>> ListThreadEventsBySceneAsync(int sceneId);
Task<IReadOnlyList<ThreadEvent>> ListThreadEventsByPlotThreadAsync(int plotThreadId); Task<IReadOnlyList<ThreadEvent>> ListThreadEventsByPlotThreadAsync(int plotThreadId);
Task<int> SaveThreadEventAsync(ThreadEvent threadEvent); Task<int> SaveThreadEventAsync(ThreadEvent threadEvent);
Task SaveThreadEventTargetsAsync(int threadEventId, IEnumerable<int> plotLineIds);
Task DeleteThreadEventAsync(int threadEventId); Task DeleteThreadEventAsync(int threadEventId);
Task<IReadOnlyList<SceneOption>> ListSceneOptionsAsync(int projectId); Task<IReadOnlyList<SceneOption>> ListSceneOptionsAsync(int projectId);
} }
@ -1950,15 +1951,47 @@ public sealed class PlotRepository(ISqlConnectionFactory connectionFactory) : IP
public async Task<IReadOnlyList<ThreadEvent>> ListThreadEventsBySceneAsync(int sceneId) public async Task<IReadOnlyList<ThreadEvent>> ListThreadEventsBySceneAsync(int sceneId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<ThreadEvent>("dbo.ThreadEvent_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); var rows = (await connection.QueryAsync<ThreadEvent>("dbo.ThreadEvent_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure)).ToList();
return rows.ToList(); await PopulateThreadEventTargetsAsync(connection, rows);
return rows;
} }
public async Task<IReadOnlyList<ThreadEvent>> ListThreadEventsByPlotThreadAsync(int plotThreadId) public async Task<IReadOnlyList<ThreadEvent>> ListThreadEventsByPlotThreadAsync(int plotThreadId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<ThreadEvent>("dbo.ThreadEvent_ListByPlotThread", new { PlotThreadID = plotThreadId }, commandType: CommandType.StoredProcedure); var rows = (await connection.QueryAsync<ThreadEvent>("dbo.ThreadEvent_ListByPlotThread", new { PlotThreadID = plotThreadId }, commandType: CommandType.StoredProcedure)).ToList();
return rows.ToList(); await PopulateThreadEventTargetsAsync(connection, rows);
return rows;
}
private static async Task PopulateThreadEventTargetsAsync(IDbConnection connection, IReadOnlyList<ThreadEvent> threadEvents)
{
var threadEventIds = threadEvents.Select(x => x.ThreadEventID).ToList();
if (threadEventIds.Count == 0)
{
return;
}
var targetRows = await connection.QueryAsync<ThreadEventTargetRow>(
"SELECT PlotEventID, PlotLineID FROM dbo.PlotEventTargetPlotLines WHERE PlotEventID IN @ThreadEventIDs;",
new { ThreadEventIDs = threadEventIds });
var targetsByEvent = targetRows
.GroupBy(x => x.PlotEventID)
.ToDictionary(x => x.Key, x => (IReadOnlyList<int>)x.Select(row => row.PlotLineID).ToList());
foreach (var threadEvent in threadEvents)
{
if (targetsByEvent.TryGetValue(threadEvent.ThreadEventID, out var targets))
{
threadEvent.TargetPlotLineIDs = targets;
}
}
}
private sealed class ThreadEventTargetRow
{
public int PlotEventID { get; set; }
public int PlotLineID { get; set; }
} }
public async Task<int> SaveThreadEventAsync(ThreadEvent threadEvent) public async Task<int> SaveThreadEventAsync(ThreadEvent threadEvent)
@ -1980,6 +2013,24 @@ public sealed class PlotRepository(ISqlConnectionFactory connectionFactory) : IP
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task SaveThreadEventTargetsAsync(int threadEventId, IEnumerable<int> plotLineIds)
{
using var connection = connectionFactory.CreateConnection();
var targetIds = plotLineIds.Where(id => id > 0).Distinct().ToList();
await connection.ExecuteAsync(
"DELETE FROM dbo.PlotEventTargetPlotLines WHERE PlotEventID = @ThreadEventID;",
new { ThreadEventID = threadEventId });
if (targetIds.Count == 0)
{
return;
}
await connection.ExecuteAsync(
"INSERT dbo.PlotEventTargetPlotLines (PlotEventID, PlotLineID) VALUES (@ThreadEventID, @PlotLineID);",
targetIds.Select(plotLineId => new { ThreadEventID = threadEventId, PlotLineID = plotLineId }));
}
public async Task DeleteThreadEventAsync(int threadEventId) public async Task DeleteThreadEventAsync(int threadEventId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -469,6 +469,7 @@ public sealed class ThreadEvent
public string PlotEventTypeName { get; set; } = "Progress"; public string PlotEventTypeName { get; set; } = "Progress";
public int? TargetPlotLineID { get; set; } public int? TargetPlotLineID { get; set; }
public string? TargetPlotLineName { get; set; } public string? TargetPlotLineName { get; set; }
public IReadOnlyList<int> TargetPlotLineIDs { get; set; } = [];
public string EventTitle { get; set; } = string.Empty; public string EventTitle { get; set; } = string.Empty;
public string? EventDescription { get; set; } public string? EventDescription { get; set; }
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }

View File

@ -122,6 +122,7 @@ public interface IPlotService
Task<int> SavePlotThreadAsync(PlotThreadEditViewModel model); Task<int> SavePlotThreadAsync(PlotThreadEditViewModel model);
Task ArchivePlotThreadAsync(int plotThreadId); Task ArchivePlotThreadAsync(int plotThreadId);
Task<int> AddThreadEventAsync(ThreadEventCreateViewModel model); Task<int> AddThreadEventAsync(ThreadEventCreateViewModel model);
Task UpdateThreadEventAsync(ThreadEventEditViewModel model);
Task DeleteThreadEventAsync(int threadEventId); Task DeleteThreadEventAsync(int threadEventId);
} }
@ -1081,8 +1082,11 @@ public sealed class SceneService(
.ToList(); .ToList();
model.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName); model.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName);
model.PlotThreadOptions = ToOptionalSelectList(plotThreads, x => x.PlotThreadID, x => $"{x.PlotLineName}: {x.ThreadTitle}"); model.PlotThreadOptions = ToOptionalSelectList(plotThreads, x => x.PlotThreadID, x => $"{x.PlotLineName}: {x.ThreadTitle}");
model.PlotLineEventOptions = plotLines;
model.PlotThreadEventOptions = plotThreads;
model.ThreadTypeOptions = ToOptionalSelectList(plotLookups.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName); model.ThreadTypeOptions = ToOptionalSelectList(plotLookups.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName);
model.ThreadEventTypeOptions = ChapterService.ToSelectList(plotLookups.ThreadEventTypes, x => x.ThreadEventTypeID, x => x.TypeName); model.ThreadEventTypeOptions = ChapterService.ToSelectList(plotLookups.ThreadEventTypes, x => x.ThreadEventTypeID, x => x.TypeName);
model.PlotEventTypeOptions = ChapterService.ToSelectList(plotLookups.PlotEventTypes, x => x.PlotEventTypeID, x => x.TypeName);
model.AssetOptions = ToOptionalSelectList(projectAssets, x => x.StoryAssetID, x => x.AssetName); model.AssetOptions = ToOptionalSelectList(projectAssets, x => x.StoryAssetID, x => x.AssetName);
model.AssetKindOptions = ChapterService.ToSelectList(assetLookups.AssetKinds, x => x.AssetKindID, x => x.KindName); model.AssetKindOptions = ChapterService.ToSelectList(assetLookups.AssetKinds, x => x.AssetKindID, x => x.KindName);
model.AssetStateOptions = ToOptionalSelectList(assetLookups.AssetStates, x => x.AssetStateID, x => x.StateName); model.AssetStateOptions = ToOptionalSelectList(assetLookups.AssetStates, x => x.AssetStateID, x => x.StateName);
@ -1107,6 +1111,9 @@ public sealed class SceneService(
ReturnProjectID = model.ReturnProjectID, ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID, ReturnBookID = model.ReturnBookID,
EventTitle = string.Empty, EventTitle = string.Empty,
PlotEventTypeID = plotLookups.PlotEventTypes.FirstOrDefault(x => x.TypeName == "Progress")?.PlotEventTypeID
?? plotLookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
?? 0,
EventTypeID = plotLookups.ThreadEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.ThreadEventTypeID EventTypeID = plotLookups.ThreadEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.ThreadEventTypeID
?? plotLookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID ?? plotLookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
?? 0 ?? 0
@ -2255,12 +2262,20 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo
{ {
var plotThreadId = model.PlotThreadID.GetValueOrDefault(); var plotThreadId = model.PlotThreadID.GetValueOrDefault();
var lookupData = await plots.GetLookupsAsync(); var lookupData = await plots.GetLookupsAsync();
if (model.PlotEventTypeID <= 0)
{
throw new InvalidOperationException("Choose an event type.");
}
var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID) var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID)
?? lookupData.ThreadEventTypes.First(); ?? lookupData.ThreadEventTypes.First();
var plotEventType = lookupData.PlotEventTypes.FirstOrDefault(x => x.PlotEventTypeID == model.PlotEventTypeID)
?? throw new InvalidOperationException("Choose a valid event type.");
var title = string.IsNullOrWhiteSpace(model.EventTitle) var title = string.IsNullOrWhiteSpace(model.EventTitle)
? eventType.TypeName ? eventType.TypeName
: model.EventTitle.Trim(); : model.EventTitle.Trim();
var sourcePlotLineId = model.PlotLineID;
if (plotThreadId == 0) if (plotThreadId == 0)
{ {
if (!model.PlotLineID.HasValue) if (!model.PlotLineID.HasValue)
@ -2284,16 +2299,53 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo
Importance = 5 Importance = 5
}); });
} }
else
{
var existingPlotThread = await plots.GetPlotThreadAsync(plotThreadId);
if (existingPlotThread is null)
{
throw new InvalidOperationException("Choose a valid plot thread.");
}
sourcePlotLineId = existingPlotThread.PlotLineID;
}
if (!sourcePlotLineId.HasValue)
{
throw new InvalidOperationException("Choose a source plot line.");
}
var projectPlotLines = await plots.ListPlotLinesAsync(model.ReturnProjectID.GetValueOrDefault());
if (projectPlotLines.Count == 0)
{
var sourcePlotLine = await plots.GetPlotLineAsync(sourcePlotLineId.Value);
projectPlotLines = sourcePlotLine is null ? [] : await plots.ListPlotLinesAsync(sourcePlotLine.ProjectID);
}
ValidatePlotEventTargets(
plotEventType.TypeName,
sourcePlotLineId.Value,
projectPlotLines,
model.TargetPlotLineID,
model.SelectedTargetPlotLineIDs);
var targetPlotLineId = plotEventType.TypeName is "Branch" or "Merge"
? model.TargetPlotLineID
: null;
var splitTargetPlotLineIds = plotEventType.TypeName == "Split"
? model.SelectedTargetPlotLineIDs
: [];
var threadEventId = await plots.SaveThreadEventAsync(new ThreadEvent var threadEventId = await plots.SaveThreadEventAsync(new ThreadEvent
{ {
PlotThreadID = plotThreadId, PlotThreadID = plotThreadId,
SceneID = model.SceneID, SceneID = model.SceneID,
EventTypeID = eventType.ThreadEventTypeID, EventTypeID = eventType.ThreadEventTypeID,
PlotEventTypeID = lookupData.PlotEventTypes.FirstOrDefault(x => x.TypeName == "Progress")?.PlotEventTypeID ?? 2, PlotEventTypeID = plotEventType.PlotEventTypeID,
TargetPlotLineID = targetPlotLineId,
EventTitle = title, EventTitle = title,
EventDescription = model.EventDescription EventDescription = model.EventDescription
}); });
await plots.SaveThreadEventTargetsAsync(threadEventId, splitTargetPlotLineIds);
var plotThread = await plots.GetPlotThreadAsync(plotThreadId); var plotThread = await plots.GetPlotThreadAsync(plotThreadId);
if (plotThread is not null) if (plotThread is not null)
{ {
@ -2302,6 +2354,100 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo
return threadEventId; return threadEventId;
} }
public async Task UpdateThreadEventAsync(ThreadEventEditViewModel model)
{
var lookupData = await plots.GetLookupsAsync();
if (model.PlotEventTypeID <= 0)
{
throw new InvalidOperationException("Choose an event type.");
}
var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID)
?? throw new InvalidOperationException("Choose a valid marker type.");
var plotEventType = lookupData.PlotEventTypes.FirstOrDefault(x => x.PlotEventTypeID == model.PlotEventTypeID)
?? throw new InvalidOperationException("Choose a valid event type.");
var plotThread = await plots.GetPlotThreadAsync(model.PlotThreadID)
?? throw new InvalidOperationException("Choose a valid plot thread.");
var projectPlotLines = await plots.ListPlotLinesAsync(plotThread.ProjectID);
ValidatePlotEventTargets(
plotEventType.TypeName,
plotThread.PlotLineID,
projectPlotLines,
model.TargetPlotLineID,
model.SelectedTargetPlotLineIDs);
var targetPlotLineId = plotEventType.TypeName is "Branch" or "Merge"
? model.TargetPlotLineID
: null;
var splitTargetPlotLineIds = plotEventType.TypeName == "Split"
? model.SelectedTargetPlotLineIDs
: [];
await plots.SaveThreadEventAsync(new ThreadEvent
{
ThreadEventID = model.ThreadEventID,
PlotThreadID = plotThread.PlotThreadID,
SceneID = model.SceneID,
EventTypeID = eventType.ThreadEventTypeID,
PlotEventTypeID = plotEventType.PlotEventTypeID,
TargetPlotLineID = targetPlotLineId,
EventTitle = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim(),
EventDescription = model.EventDescription
});
await plots.SaveThreadEventTargetsAsync(model.ThreadEventID, splitTargetPlotLineIds);
await activity.RecordAsync(plotThread.ProjectID, "Updated", "Thread Event", model.ThreadEventID, model.EventTitle);
}
private static void ValidatePlotEventTargets(
string plotEventTypeName,
int sourcePlotLineId,
IReadOnlyList<PlotLineItem> projectPlotLines,
int? targetPlotLineId,
IEnumerable<int> selectedTargetPlotLineIds)
{
var projectPlotLineIds = projectPlotLines.Select(x => x.PlotLineID).ToHashSet();
if (plotEventTypeName is "Branch" or "Merge")
{
if (!targetPlotLineId.HasValue)
{
throw new InvalidOperationException(plotEventTypeName == "Branch"
? "Choose a target plot line for the branch event."
: "Choose the plot line this event merges into.");
}
if (targetPlotLineId.Value == sourcePlotLineId)
{
throw new InvalidOperationException("Target plot line cannot be the source plot line.");
}
if (!projectPlotLineIds.Contains(targetPlotLineId.Value))
{
throw new InvalidOperationException("Target plot line must belong to this project.");
}
}
if (plotEventTypeName == "Split")
{
var targets = selectedTargetPlotLineIds.Where(id => id > 0).Distinct().ToList();
if (targets.Count < 2)
{
throw new InvalidOperationException("Choose at least two target plot lines for a split event.");
}
if (targets.Contains(sourcePlotLineId))
{
throw new InvalidOperationException("Split target plot lines cannot include the source plot line.");
}
if (targets.Any(id => !projectPlotLineIds.Contains(id)))
{
throw new InvalidOperationException("Split target plot lines must belong to this project.");
}
}
}
public Task DeleteThreadEventAsync(int threadEventId) => plots.DeleteThreadEventAsync(threadEventId); public Task DeleteThreadEventAsync(int threadEventId) => plots.DeleteThreadEventAsync(threadEventId);
private static int GetDefaultThreadTypeId(PlotLookupData lookupData, string eventTypeName) private static int GetDefaultThreadTypeId(PlotLookupData lookupData, string eventTypeName)

View File

@ -178,8 +178,11 @@ public sealed class SceneEditViewModel
public RelationshipEventCreateViewModel NewRelationshipEvent { get; set; } = new(); public RelationshipEventCreateViewModel NewRelationshipEvent { get; set; } = new();
public IReadOnlyList<SelectListItem> PlotLineOptions { get; set; } = []; public IReadOnlyList<SelectListItem> PlotLineOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> PlotThreadOptions { get; set; } = []; public IReadOnlyList<SelectListItem> PlotThreadOptions { get; set; } = [];
public IReadOnlyList<PlotLineItem> PlotLineEventOptions { get; set; } = [];
public IReadOnlyList<PlotThread> PlotThreadEventOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ThreadTypeOptions { get; set; } = []; public IReadOnlyList<SelectListItem> ThreadTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ThreadEventTypeOptions { get; set; } = []; public IReadOnlyList<SelectListItem> ThreadEventTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> PlotEventTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> AssetOptions { get; set; } = []; public IReadOnlyList<SelectListItem> AssetOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> AssetKindOptions { get; set; } = []; public IReadOnlyList<SelectListItem> AssetKindOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> AssetStateOptions { get; set; } = []; public IReadOnlyList<SelectListItem> AssetStateOptions { get; set; } = [];
@ -801,6 +804,26 @@ public sealed class ThreadEventCreateViewModel
public string? NewThreadTitle { get; set; } public string? NewThreadTitle { get; set; }
public int? NewThreadTypeID { get; set; } public int? NewThreadTypeID { get; set; }
public int EventTypeID { get; set; } public int EventTypeID { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "Choose an event type.")]
public int PlotEventTypeID { get; set; } = 2;
public int? TargetPlotLineID { get; set; }
public List<int> SelectedTargetPlotLineIDs { get; set; } = [];
public string EventTitle { get; set; } = string.Empty;
public string? EventDescription { get; set; }
public int? ReturnProjectID { get; set; }
public int? ReturnBookID { get; set; }
}
public sealed class ThreadEventEditViewModel
{
public int ThreadEventID { get; set; }
public int SceneID { get; set; }
public int PlotThreadID { get; set; }
public int EventTypeID { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "Choose an event type.")]
public int PlotEventTypeID { get; set; } = 2;
public int? TargetPlotLineID { get; set; }
public List<int> SelectedTargetPlotLineIDs { get; set; } = [];
public string EventTitle { get; set; } = string.Empty; public string EventTitle { get; set; } = string.Empty;
public string? EventDescription { get; set; } public string? EventDescription { get; set; }
public int? ReturnProjectID { get; set; } public int? ReturnProjectID { get; set; }

View File

@ -695,7 +695,7 @@
<div> <div>
<span class="thread-event-marker">@threadEvent.MarkerText</span> <span class="thread-event-marker">@threadEvent.MarkerText</span>
<strong>@threadEvent.EventTitle</strong> <strong>@threadEvent.EventTitle</strong>
<span class="muted">@threadEvent.PlotLineName / @threadEvent.ThreadTitle</span> <span class="muted">@threadEvent.PlotEventTypeName / @threadEvent.PlotLineName / @threadEvent.ThreadTitle</span>
</div> </div>
<form asp-controller="Scenes" asp-action="DeleteThreadEvent" method="post"> <form asp-controller="Scenes" asp-action="DeleteThreadEvent" method="post">
<input type="hidden" name="id" value="@threadEvent.ThreadEventID" /> <input type="hidden" name="id" value="@threadEvent.ThreadEventID" />
@ -704,28 +704,149 @@
<input type="hidden" name="bookId" value="@Model.ReturnBookID" /> <input type="hidden" name="bookId" value="@Model.ReturnBookID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Remove</button> <button class="btn btn-outline-secondary btn-sm" type="submit">Remove</button>
</form> </form>
<details class="mt-2">
<summary>Edit</summary>
<form asp-controller="Scenes" asp-action="UpdateThreadEvent" method="post" class="thread-event-create mt-2" data-thread-event-form>
<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" />
<input type="hidden" data-thread-event-source-plot-line value="@threadEvent.PlotLineID" />
@if (TempData[$"ThreadEventEditError:{threadEvent.ThreadEventID}"] is string editThreadEventError)
{
<p class="text-danger form-helper-message">@editThreadEventError</p>
}
<div class="row g-2">
<div class="col-12">
<label class="form-label" for="PlotEventTypeID-@threadEvent.ThreadEventID">Event type</label>
<select class="form-select form-select-sm" id="PlotEventTypeID-@threadEvent.ThreadEventID" name="PlotEventTypeID" data-plot-event-type>
<option value="">Choose event type</option>
@foreach (var eventType in Model.PlotEventTypeOptions)
{
if (eventType.Value == threadEvent.PlotEventTypeID.ToString())
{
<option value="@eventType.Value" selected>@eventType.Text</option>
}
else
{
<option value="@eventType.Value">@eventType.Text</option>
}
}
</select>
</div>
<div class="col-12" data-plot-event-target="branch">
<label class="form-label" for="TargetPlotLineID-@threadEvent.ThreadEventID">Target plot line</label>
<select class="form-select form-select-sm" id="TargetPlotLineID-@threadEvent.ThreadEventID" name="TargetPlotLineID" data-thread-event-target-single>
<option value="">Choose target plot line</option>
@foreach (var plotLine in Model.PlotLineEventOptions)
{
if (threadEvent.TargetPlotLineID == plotLine.PlotLineID)
{
<option value="@plotLine.PlotLineID" data-target-option selected>@plotLine.PlotLineName</option>
}
else
{
<option value="@plotLine.PlotLineID" data-target-option>@plotLine.PlotLineName</option>
}
}
</select>
</div>
<div class="col-12" data-plot-event-target="merge">
<label class="form-label" for="MergeTargetPlotLineID-@threadEvent.ThreadEventID">Merge into plot line</label>
<select class="form-select form-select-sm" id="MergeTargetPlotLineID-@threadEvent.ThreadEventID" data-thread-event-target-merge>
<option value="">Choose merge target</option>
@foreach (var plotLine in Model.PlotLineEventOptions)
{
if (threadEvent.TargetPlotLineID == plotLine.PlotLineID)
{
<option value="@plotLine.PlotLineID" data-target-option selected>@plotLine.PlotLineName</option>
}
else
{
<option value="@plotLine.PlotLineID" data-target-option>@plotLine.PlotLineName</option>
}
}
</select>
</div>
<div class="col-12" data-plot-event-target="split">
<label class="form-label" for="SelectedTargetPlotLineIDs-@threadEvent.ThreadEventID">Target plot lines</label>
<select class="form-select form-select-sm" id="SelectedTargetPlotLineIDs-@threadEvent.ThreadEventID" name="SelectedTargetPlotLineIDs" multiple size="4" data-thread-event-target-split>
@foreach (var plotLine in Model.PlotLineEventOptions)
{
if (threadEvent.TargetPlotLineIDs.Contains(plotLine.PlotLineID))
{
<option value="@plotLine.PlotLineID" data-target-option selected>@plotLine.PlotLineName</option>
}
else
{
<option value="@plotLine.PlotLineID" data-target-option>@plotLine.PlotLineName</option>
}
}
</select>
</div>
<div class="col-12">
<label class="form-label" for="EventTypeID-@threadEvent.ThreadEventID">Marker type</label>
<select class="form-select form-select-sm" id="EventTypeID-@threadEvent.ThreadEventID" name="EventTypeID">
@foreach (var markerType in Model.ThreadEventTypeOptions)
{
if (markerType.Value == threadEvent.EventTypeID.ToString())
{
<option value="@markerType.Value" selected>@markerType.Text</option>
}
else
{
<option value="@markerType.Value">@markerType.Text</option>
}
}
</select>
</div>
<div class="col-12">
<label class="form-label" for="EventTitle-@threadEvent.ThreadEventID">Event title</label>
<input class="form-control form-control-sm" id="EventTitle-@threadEvent.ThreadEventID" name="EventTitle" value="@threadEvent.EventTitle" />
</div>
<div class="col-12">
<label class="form-label" for="EventDescription-@threadEvent.ThreadEventID">Description</label>
<textarea class="form-control form-control-sm" id="EventDescription-@threadEvent.ThreadEventID" name="EventDescription" rows="3">@threadEvent.EventDescription</textarea>
</div>
</div>
<button class="btn btn-outline-primary btn-sm mt-2" type="submit">Save event</button>
</form>
</details>
</div> </div>
} }
</div> </div>
} }
<form asp-controller="Scenes" asp-action="AddThreadEvent" method="post" class="thread-event-create"> <form asp-controller="Scenes" asp-action="AddThreadEvent" method="post" class="thread-event-create" data-thread-event-form>
<input type="hidden" name="SceneID" value="@Model.SceneID" /> <input type="hidden" name="SceneID" value="@Model.SceneID" />
<input type="hidden" name="ReturnProjectID" value="@Model.ReturnProjectID" /> <input type="hidden" name="ReturnProjectID" value="@Model.ReturnProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.ReturnBookID" /> <input type="hidden" name="ReturnBookID" value="@Model.ReturnBookID" />
<p class="form-text mb-2">Choose an existing thread, or choose a plot line to create one from the event title.</p> <p class="form-text mb-2">Choose an existing thread, or choose a plot line to create one from the event title.</p>
@if (TempData["ThreadEventAddError"] is string addThreadEventError)
{
<p class="text-danger form-helper-message">@addThreadEventError</p>
}
<div class="row g-2"> <div class="row g-2">
<div class="col-12"> <div class="col-12">
<label class="form-label" for="PlotLineID">Plot line</label> <label class="form-label" for="PlotLineID">Plot line</label>
<select class="form-select form-select-sm" id="PlotLineID" name="PlotLineID" asp-items="Model.PlotLineOptions"> <select class="form-select form-select-sm" id="PlotLineID" name="PlotLineID" data-thread-event-source-plot-line>
<option value="">Choose plot line</option> <option value="">Choose plot line</option>
@foreach (var plotLine in Model.PlotLineEventOptions)
{
<option value="@plotLine.PlotLineID">@plotLine.PlotLineName</option>
}
</select> </select>
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label" for="PlotThreadID">Existing plot thread</label> <label class="form-label" for="PlotThreadID">Existing plot thread</label>
<select class="form-select form-select-sm" id="PlotThreadID" name="PlotThreadID" asp-items="Model.PlotThreadOptions"> <select class="form-select form-select-sm" id="PlotThreadID" name="PlotThreadID" data-thread-event-source-thread>
<option value="">Create or choose a thread</option> <option value="">Create or choose a thread</option>
@foreach (var plotThread in Model.PlotThreadEventOptions)
{
<option value="@plotThread.PlotThreadID" data-plot-line-id="@plotThread.PlotLineID">@plotThread.PlotLineName: @plotThread.ThreadTitle</option>
}
</select> </select>
</div> </div>
<div class="col-8"> <div class="col-8">
@ -740,6 +861,52 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label" for="EventTypeID">Event type</label> <label class="form-label" for="EventTypeID">Event type</label>
<select class="form-select form-select-sm" id="PlotEventTypeID" name="PlotEventTypeID" data-plot-event-type>
<option value="">Choose event type</option>
@foreach (var eventType in Model.PlotEventTypeOptions)
{
if (eventType.Value == Model.NewThreadEvent.PlotEventTypeID.ToString())
{
<option value="@eventType.Value" selected>@eventType.Text</option>
}
else
{
<option value="@eventType.Value">@eventType.Text</option>
}
}
</select>
</div>
<div class="col-12" data-plot-event-target="branch">
<label class="form-label" for="TargetPlotLineID">Target plot line</label>
<select class="form-select form-select-sm" id="TargetPlotLineID" name="TargetPlotLineID" data-thread-event-target-single>
<option value="">Choose target plot line</option>
@foreach (var plotLine in Model.PlotLineEventOptions)
{
<option value="@plotLine.PlotLineID" data-target-option>@plotLine.PlotLineName</option>
}
</select>
</div>
<div class="col-12" data-plot-event-target="merge">
<label class="form-label" for="MergeTargetPlotLineID">Merge into plot line</label>
<select class="form-select form-select-sm" id="MergeTargetPlotLineID" data-thread-event-target-merge>
<option value="">Choose merge target</option>
@foreach (var plotLine in Model.PlotLineEventOptions)
{
<option value="@plotLine.PlotLineID" data-target-option>@plotLine.PlotLineName</option>
}
</select>
</div>
<div class="col-12" data-plot-event-target="split">
<label class="form-label" for="SelectedTargetPlotLineIDs">Target plot lines</label>
<select class="form-select form-select-sm" id="SelectedTargetPlotLineIDs" name="SelectedTargetPlotLineIDs" multiple size="4" data-thread-event-target-split>
@foreach (var plotLine in Model.PlotLineEventOptions)
{
<option value="@plotLine.PlotLineID" data-target-option>@plotLine.PlotLineName</option>
}
</select>
</div>
<div class="col-12">
<label class="form-label" for="EventTypeID">Marker type</label>
<select class="form-select form-select-sm" id="EventTypeID" name="EventTypeID" asp-items="Model.ThreadEventTypeOptions"></select> <select class="form-select form-select-sm" id="EventTypeID" name="EventTypeID" asp-items="Model.ThreadEventTypeOptions"></select>
</div> </div>
<div class="col-12"> <div class="col-12">
@ -754,6 +921,53 @@
<button class="btn btn-outline-primary btn-sm mt-2" type="submit">Add thread event</button> <button class="btn btn-outline-primary btn-sm mt-2" type="submit">Add thread event</button>
</form> </form>
<script>
(() => {
const section = document.currentScript?.closest("[data-section-id='inspector-thread-events']");
section?.querySelectorAll("[data-thread-event-form]").forEach(form => {
const plotEventType = form.querySelector("[data-plot-event-type]");
const sourcePlotLine = form.querySelector("[data-thread-event-source-plot-line]");
const sourceThread = form.querySelector("[data-thread-event-source-thread]");
const branchSection = form.querySelector("[data-plot-event-target='branch']");
const mergeSection = form.querySelector("[data-plot-event-target='merge']");
const splitSection = form.querySelector("[data-plot-event-target='split']");
const branchTarget = form.querySelector("[data-thread-event-target-single]");
const mergeTarget = form.querySelector("[data-thread-event-target-merge]");
const splitTargets = form.querySelector("[data-thread-event-target-split]");
const eventTypeName = () => plotEventType?.selectedOptions?.[0]?.textContent?.trim().toLowerCase() || "";
const sourcePlotLineId = () => sourceThread?.selectedOptions?.[0]?.dataset.plotLineId || sourcePlotLine?.value || "";
const syncTargetOptions = () => {
const sourceId = sourcePlotLineId();
form.querySelectorAll("[data-target-option]").forEach(option => {
const isSource = sourceId && option.value === sourceId;
option.disabled = Boolean(isSource);
option.hidden = Boolean(isSource);
if (isSource) option.selected = false;
});
};
const sync = () => {
const type = eventTypeName();
branchSection.hidden = type !== "branch";
mergeSection.hidden = type !== "merge";
splitSection.hidden = type !== "split";
branchTarget.disabled = type !== "branch";
mergeTarget.disabled = type !== "merge";
splitTargets.disabled = type !== "split";
branchTarget.name = type === "branch" ? "TargetPlotLineID" : "";
mergeTarget.name = type === "merge" ? "TargetPlotLineID" : "";
syncTargetOptions();
};
plotEventType?.addEventListener("change", sync);
sourcePlotLine?.addEventListener("change", sync);
sourceThread?.addEventListener("change", sync);
sync();
});
})();
</script>
</section> </section>
<section class="inspector-section asset-events-section" data-section-title="Story Assets" data-section-id="inspector-asset-events"> <section class="inspector-section asset-events-section" data-section-title="Story Assets" data-section-id="inspector-asset-events">