From 5f15c19a20279adf99e4f7540dd0d57035914b44 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 8 Jun 2026 21:50:18 +0100 Subject: [PATCH] Scene inspector rearrange and fix. --- PlotLine/Controllers/ScenesController.cs | 11 +- .../Docs/SceneInspectorManualTestChecklist.md | 118 + PlotLine/Services/EmailQueueWorker.cs | 2 +- PlotLine/Views/Scenes/_SceneInspector.cshtml | 2381 +++++++++-------- PlotLine/appsettings.json | 2 +- PlotLine/wwwroot/js/site.js | 78 +- 6 files changed, 1413 insertions(+), 1179 deletions(-) create mode 100644 PlotLine/Docs/SceneInspectorManualTestChecklist.md diff --git a/PlotLine/Controllers/ScenesController.cs b/PlotLine/Controllers/ScenesController.cs index be4b79a..70d204d 100644 --- a/PlotLine/Controllers/ScenesController.cs +++ b/PlotLine/Controllers/ScenesController.cs @@ -36,7 +36,16 @@ public sealed class ScenesController( { if (!ModelState.IsValid) { - return View("Edit", model); + var hydratedModel = model.SceneID > 0 + ? await scenes.GetEditAsync(model.SceneID) + : await scenes.GetCreateAsync(model.ChapterID); + if (hydratedModel is null) + { + return NotFound(); + } + + CopyPostedSceneFields(model, hydratedModel); + return View("Edit", hydratedModel); } int sceneId; diff --git a/PlotLine/Docs/SceneInspectorManualTestChecklist.md b/PlotLine/Docs/SceneInspectorManualTestChecklist.md new file mode 100644 index 0000000..d97daac --- /dev/null +++ b/PlotLine/Docs/SceneInspectorManualTestChecklist.md @@ -0,0 +1,118 @@ +# Scene Inspector Manual Test Checklist + +Use an existing project with at least one book, one chapter, and one saved scene. Run each section from both the standalone Scene edit page and the Timeline Scene Inspector drawer when practical. + +For every section: +- Create or change a value. +- Save. +- Reload the inspector. +- Confirm the value remains. +- Edit the value where the UI supports editing. +- Save. +- Reload the inspector. +- Confirm the edit remains. +- Delete, remove, dismiss, or archive where supported. +- Reload the inspector. +- Confirm the deletion or status change remains. + +## 1. Overview / Scene Details +- Edit scene title, scene number, summary, revision status, and primary location. +- Save with **Save scene**. +- Reload and confirm the overview card, inspector header, timeline card, and form fields show the updated values. +- Clear a required title and save to confirm validation returns the full inspector with all lists and sections still available. + +## 2. Timing & Structure +- Edit time mode, confidence, start/end date-time, duration amount/unit, and relative time. +- Save with **Save scene**. +- Reload and confirm the timing chip and fields show the updated values. + +## 3. Scene Purpose & Outcome +- Select and clear purpose checkboxes. +- Edit purpose notes and outcome notes. +- Save with **Save scene**. +- Reload and confirm the purpose chips and notes persist. + +## 4. Scene Notes +- Add a note with type, title, text, and pinned state. +- Reload, edit the note text/title/resolved state, save, and confirm the update persists. +- Remove the note and confirm it stays removed. + +## 5. Characters +- Add an existing character or create a new character from the section. +- Reload and confirm the appearance row persists. +- Open the character edit panel, change role, presence, locations, and notes, save, and confirm the update persists. +- Remove the character appearance and confirm it stays removed. + +## 6. Character Attributes +- Add an attribute event with character, attribute type, value, and description. +- Reload and confirm it appears. +- Existing attribute events are currently add-only from the inspector; verify any broader edit/delete workflow separately if introduced. + +## 7. Character Knowledge +- Add knowledge with character, optional asset/thread, state, and description. +- Reload and confirm it appears. +- Remove the knowledge row and confirm it stays removed. + +## 8. Relationship Events +- Add a relationship event using either an existing relationship or a newly selected character pair/type. +- Reload and confirm it appears. +- Remove the relationship event and confirm it stays removed. + +## 9. Plot Thread Events +- Add a thread event using an existing thread. +- Add a thread event by choosing a plot line and entering a new thread title. +- Reload and confirm both appear. +- Remove each event and confirm it stays removed. + +## 10. Story Assets +- Add an asset event using an existing asset. +- Add an asset event by entering a new asset name. +- Reload and confirm the event appears and any new asset is available. +- Remove the event and confirm it stays removed. + +## 11. Asset Locations +- Place an asset at a location with description and current-location update checked. +- Reload and confirm the placement appears. +- Remove the placement and confirm it stays removed. + +## 12. Asset Custody +- Add a custody event with asset, event type, role, custodian characters or names, and description. +- Reload and confirm it appears. +- Remove the custody event and confirm it stays removed. + +## 13. Writer Workspace +- Edit draft status, priority, estimated/actual word counts, dates, readiness flags, blocked flag, and blocked reason. +- Save workflow. +- Reload and confirm all workflow values persist. + +## 14. Scene Checklist +- Add a checklist item. +- Reload, edit the item text/completed state, save, and confirm the update persists. +- Remove the item and confirm it stays removed. + +## 15. Attachments / Research +- Add an external-link attachment with type, title, URL, and notes. +- Reload, edit title/URL/notes, save, and confirm the update persists. +- Add or replace a file attachment if local test storage is available. +- Remove the attachment and confirm it stays removed. + +## 16. Scene Metrics +- Change each metric slider and metric note. +- Save with **Save scene**. +- Reload and confirm values and notes persist. + +## 17. Continuity Warnings +- Run **Validate scene**. +- Reload and confirm warnings appear or remain clear. +- Dismiss and mark intentional on available warnings, then reload and confirm status changes persist. + +## 18. Scene Dependencies +- Add a dependency in both directions when another scene is available. +- Reload and confirm it appears in the correct dependency column. +- Remove each dependency and confirm it stays removed. + +## 19. Move / Reorder +- Preview moving the scene to the start/end of another chapter. +- Confirm the move from the preview page. +- Reload the timeline and confirm the scene remains selected, keeps the same SceneID, and its related events/metrics/notes remain attached. +- Run **Renumber this chapter** and confirm ordering persists after reload. diff --git a/PlotLine/Services/EmailQueueWorker.cs b/PlotLine/Services/EmailQueueWorker.cs index e8a6abc..8026454 100644 --- a/PlotLine/Services/EmailQueueWorker.cs +++ b/PlotLine/Services/EmailQueueWorker.cs @@ -7,7 +7,7 @@ public sealed class EmailQueueWorker( ILogger logger) : BackgroundService { private const int BatchSize = 10; - private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { diff --git a/PlotLine/Views/Scenes/_SceneInspector.cshtml b/PlotLine/Views/Scenes/_SceneInspector.cshtml index 306ed37..1549aca 100644 --- a/PlotLine/Views/Scenes/_SceneInspector.cshtml +++ b/PlotLine/Views/Scenes/_SceneInspector.cshtml @@ -15,9 +15,11 @@ .Select(x => x.PurposeName) .ToList(); var dependencyCount = Model.DependenciesThisSceneNeeds.Count + Model.DependenciesNeedingThisScene.Count; + var exactDateTimeModeId = Model.TimeModes.FirstOrDefault(x => string.Equals(x.Text, "Exact DateTime", StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty; + var exactDateModeId = Model.TimeModes.FirstOrDefault(x => string.Equals(x.Text, "Exact Date", StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty; } -
+
-
- - - - - -
+ + + + + + +
-
-

Timing & Structure

-
-
- - +
+

Timing & Structure

+
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
-
- - -
-
- - - -
-
- - -
-
- - -
-
- @if (Model.LocationConsistencyMessages.Any()) - { -
-

Location checks

-
    - @foreach (var message in Model.LocationConsistencyMessages) - { -
  • @message
  • - } -
-
- } - -

Time

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-

Purposes

-
- @foreach (var purpose in Model.ScenePurposeTypes) + @if (Model.LocationConsistencyMessages.Any()) { -
+ +
+

Purposes

+
+ @foreach (var purpose in Model.ScenePurposeTypes) + { + + } +
+ +
+

Notes

+ + + + +
+
+ +
+

Scene Metrics

+ @if (!Model.Metrics.Any()) + { +
+

This project has no scene metrics yet.

+ @if (Model.ReturnProjectID.HasValue) { - + Add default metrics } - else +
+ } + else + { +
+ @for (var index = 0; index < Model.Metrics.Count; index++) { - +
+ + + + + +
+ + @Model.Metrics[index].Value +
+ + +
} - @purpose.PurposeName - +
}
-
-

Notes

- - - - -
-
+
+ +
+ -
-

Scene Metrics

- @if (!Model.Metrics.Any()) + @if (Model.SceneID > 0 && Model.ReturnToTimeline) + { +
+ + + +
+ } + + @if (TempData["WriterWorkspaceError"] is string writerWorkspaceError) + { +
@writerWorkspaceError
+ } + + + +
+

Scene Notes

+ @if (!Model.Notes.Any()) { -
-

This project has no scene metrics yet.

- @if (Model.ReturnProjectID.HasValue) +

No working notes yet. Capture drafting thoughts, research reminders, or revision questions as they come up.

+ } + else + { +
+ @foreach (var note in Model.Notes) { - Add default metrics +
+ + @note.TypeName + @(string.IsNullOrWhiteSpace(note.NoteTitle) ? note.NoteText : note.NoteTitle) + @if (note.IsPinned) + { + Pinned + } + @if (note.IsResolved) + { + Resolved + } + +
+ + + + + + + +
+ + +
+
+ +
+
+
+ + + + + +
+
+ } +
+ } +
+ + + + + + +
+ +
+ +
+
+ + +
+

Characters

+ @if (!Model.SceneCharacters.Any()) + { +

No characters attached to this scene yet. Add the people who appear, are mentioned, or meaningfully affect the scene.

+ } + else + { +
+ @foreach (var character in Model.SceneCharacters) + { + var editId = $"scene-character-edit-{character.SceneCharacterID}"; +
+
+
+ @character.CharacterName + @((character.RoleInSceneTypeName ?? "Role not set")) • @((character.PresenceTypeName ?? "Presence not set")) + Location: @(character.LocationName ?? "Not set") + Entry: @(character.EntryLocationName ?? "Not set") + Exit: @(character.ExitLocationName ?? "Not set") + @if (!string.IsNullOrWhiteSpace(character.OutfitDescription) || !string.IsNullOrWhiteSpace(character.PhysicalCondition) || !string.IsNullOrWhiteSpace(character.EmotionalState)) + { + @character.OutfitDescription @character.PhysicalCondition @character.EmotionalState + } +
+
+ +
+ + + + + +
+
+
+
+
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ } +
+ } + +
+ + + +

Add character to scene

+ @if (TempData["SceneCharacterAddError"] is string addCharacterError) + { +

@addCharacterError

+ } +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ +
+
+ +
+

Character Attributes

+ @if (Model.CharacterAttributeEvents.Any()) + { +
+ @foreach (var item in Model.CharacterAttributeEvents) + { +
+
+ @item.CharacterName: @item.AttributeName + @item.AttributeValue + @item.Description +
+
}
} else { -
- @for (var index = 0; index < Model.Metrics.Count; index++) +

Character attribute history for this scene will appear here when available.

+ } +
+ + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+

Character Knowledge

+ @if (Model.CharacterKnowledge.Any()) + { +
+ @foreach (var item in Model.CharacterKnowledge) { -
- - - - - -
- - @Model.Metrics[index].Value +
+
+ @item.CharacterName @item.KnowledgeStateName + @(item.AssetName ?? item.ThreadTitle) + @item.Description
- - +
+ + + + + +
}
} -
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ +
+
-
- -
- - -@if (Model.SceneID > 0 && Model.ReturnToTimeline) -{ -
- - - -
-} - -@if (TempData["WriterWorkspaceError"] is string writerWorkspaceError) -{ -
@writerWorkspaceError
-} - -
-
-

Writer Workspace

- Chapter workflow -
-
- - - -
-
- - +
+

Relationship Events

+ @if (Model.RelationshipEvents.Any()) + { +
+ @foreach (var item in Model.RelationshipEvents) + { +
+
+ @item.CharacterAName -> @item.CharacterBName + @item.RelationshipStateName / intensity @item.Intensity + @item.Description +
+ + + + + + + +
+ }
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - -
-
- - -
-
-
- -
- -
- -
-

Scene Notes

- @if (!Model.Notes.Any()) - { -

No working notes yet. Capture drafting thoughts, research reminders, or revision questions as they come up.

- } - else - { -
- @foreach (var note in Model.Notes) - { -
- - @note.TypeName - @(string.IsNullOrWhiteSpace(note.NoteTitle) ? note.NoteText : note.NoteTitle) - @if (note.IsPinned) { Pinned } - @if (note.IsResolved) { Resolved } - -
- - - - - + + +
+ @if (Model.RelationshipOptions.Count == 1) + { +
+ + @Model.RelationshipOptions[0].Text +
+ } + else + { +
+ - - -
- - +
+ } +
+
+
+
+
+ + +
+
+ +
+
+
+ + +
+ + +
+

Plot Thread Events

+ @if (!Model.ThreadEvents.Any()) + { +

No plot thread events attached to this scene yet. Add clues, reveals, promises, payoffs, or reversals when the scene touches a thread.

+ } + else + { +
+ @foreach (var threadEvent in Model.ThreadEvents) + { +
+
+ @threadEvent.MarkerText + @threadEvent.EventTitle + @threadEvent.PlotLineName / @threadEvent.ThreadTitle
-
- +
+ + + + + +
+
+ } +
+ } + +
+ + + +

Choose an existing thread, or choose a plot line to create one from the event title.

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Story Assets

+ @if (!Model.AssetEvents.Any()) + { +

No asset events attached to this scene yet. Add one when an object, clue, secret, or important item changes here.

+ } + else + { +
+ @foreach (var assetEvent in Model.AssetEvents) + { +
+
+ @assetEvent.MarkerText + @assetEvent.EventTitle + @assetEvent.AssetName / @assetEvent.AssetEventTypeName + @if (!string.IsNullOrWhiteSpace(assetEvent.ToStateName)) + { + State: @(assetEvent.FromStateName ?? "Any") -> @assetEvent.ToStateName + }
- -
- - - - - -
- +
+ + + + + +
+
+ } +
+ } + +
+ + + +

Choose an existing asset, or enter a new asset name to create it from this scene.

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Asset Locations

+ @if (!Model.SceneAssetLocations.Any()) + { +

No assets have been placed in a location for this scene yet.

+ } + else + { +
+ @foreach (var assetLocation in Model.SceneAssetLocations) + { +
+
+ @assetLocation.AssetName + @assetLocation.LocationName + @assetLocation.Description +
+
+ + + + + +
+
+ } +
+ } + +
+ + + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+

Asset Custody

+ @if (!Model.AssetCustodyEvents.Any()) + { +

No custody entries attached to this scene yet.

+ } + else + { +
+ @foreach (var custody in Model.AssetCustodyEvents) + { +
+
+ @custody.CustodyEventTypeName + @custody.AssetName + @custody.CustodianSummary +
+
+ + + + + +
+
+ } +
+ } + +
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

Writer Workspace

+ Chapter workflow +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+
+ +
+
+
+ +
+

Scene Checklist

+
+ @foreach (var item in Model.ChecklistItems) + { +
+ + + + + + + +
+
+ + + + + +
}
- } -
- - - - - - -
- -
- -
-
+
+ + + + + +
+
-
-

Scene Checklist

-
- @foreach (var item in Model.ChecklistItems) +
+

Attachments / Research

+ @if (Model.StorageUsage is not null) { -
- - - - - - - -
-
- - - - - -
+

Storage used: @Model.StorageUsage.DisplayLabel

} -
-
- - - - - -
-
- -
-

Attachments / Research

- @if (Model.StorageUsage is not null) - { -

Storage used: @Model.StorageUsage.DisplayLabel

- } - @if (!Model.Attachments.Any()) - { -

No scene references yet. Add images, links, floorplans, research notes, or files that help you write this scene.

- } - else - { -
- @foreach (var attachment in Model.Attachments) - { - var hasFile = !string.IsNullOrWhiteSpace(attachment.FilePath); - var fileHref = hasFile && attachment.FilePath!.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? attachment.FilePath - : hasFile && attachment.FilePath!.StartsWith("/") + @if (!Model.Attachments.Any()) + { +

No scene references yet. Add images, links, floorplans, research notes, or files that help you write this scene.

+ } + else + { +
+ @foreach (var attachment in Model.Attachments) + { + var hasFile = !string.IsNullOrWhiteSpace(attachment.FilePath); + var fileHref = hasFile && attachment.FilePath!.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? attachment.FilePath - : hasFile && !System.IO.Path.IsPathRooted(attachment.FilePath!) - ? "/" + attachment.FilePath!.TrimStart('/', '\\') - : null; - var fileExtension = hasFile ? System.IO.Path.GetExtension(attachment.FilePath!) : string.Empty; - var isImageAttachment = !string.IsNullOrWhiteSpace(fileHref) - && new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp" }.Contains(fileExtension, StringComparer.OrdinalIgnoreCase); -
- - @(hasFile ? "File" : "Link") - @attachment.TypeName - @attachment.Title - -
-
- @if (isImageAttachment) - { - - @attachment.Title preview - - } - else - { - - } -
- @if (!string.IsNullOrWhiteSpace(attachment.Notes)) + : hasFile && attachment.FilePath!.StartsWith("/") + ? attachment.FilePath + : hasFile && !System.IO.Path.IsPathRooted(attachment.FilePath!) + ? "/" + attachment.FilePath!.TrimStart('/', '\\') + : null; + var fileExtension = hasFile ? System.IO.Path.GetExtension(attachment.FilePath!) : string.Empty; + var isImageAttachment = !string.IsNullOrWhiteSpace(fileHref) + && new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp" }.Contains(fileExtension, StringComparer.OrdinalIgnoreCase); +
+ + @(hasFile ? "File" : "Link") + @attachment.TypeName + @attachment.Title + +
+
+ @if (isImageAttachment) { -

@attachment.Notes

+ + @attachment.Title preview + } - @if (hasFile) + else { -

@System.IO.Path.GetFileName(attachment.FilePath)

+ } -
- @if (!string.IsNullOrWhiteSpace(fileHref)) +
+ @if (!string.IsNullOrWhiteSpace(attachment.Notes)) { - Open - Download +

@attachment.Notes

} - @if (!string.IsNullOrWhiteSpace(attachment.ExternalUrl)) + @if (hasFile) { - Open link +

@System.IO.Path.GetFileName(attachment.FilePath)

} +
+ @if (!string.IsNullOrWhiteSpace(fileHref)) + { + Open + Download + } + @if (!string.IsNullOrWhiteSpace(attachment.ExternalUrl)) + { + Open link + } +
-
-
- - - - - - - - - - - - -
-
- - - - - - -
-
- } -
- } -
- + Add Attachment -
- - - - - - - - - - - - - -
-
-
- -
-
-

Continuity Warnings

- @if (Model.SceneID > 0 && Model.ReturnProjectID.HasValue) - { -
- - - - - -
+
+ + + + + + + + + + + + +
+
+ + + + + + +
+ + } +
} -
- @if (!Model.Warnings.Any()) - { -

No active warnings for this scene. Run validation again after major story changes.

- } - else - { -
- @foreach (var warning in Model.Warnings) +
+ + Add Attachment +
+ + + + + + + + + + + + + +
+
+ + + +
+
+

Continuity Warnings

+ @if (Model.SceneID > 0 && Model.ReturnProjectID.HasValue) { -
- @warning.SeverityName - @warning.WarningTypeName -

@warning.Message

-
-
- - - - - - -
-
- - - - - - -
-
-
+
+ + + + + +
}
- } -
- -
-

Scene Dependencies

-
-
-

This scene depends on

- @if (!Model.DependenciesThisSceneNeeds.Any()) - { -

No dependencies yet. Add one when this scene needs another moment to happen first.

- } - else - { - @foreach (var dependency in Model.DependenciesThisSceneNeeds) + @if (!Model.Warnings.Any()) + { +

No active warnings for this scene. Run validation again after major story changes.

+ } + else + { +
+ @foreach (var warning in Model.Warnings) { -
- Ch @dependency.SourceChapterNumber, Scene @dependency.SourceSceneNumber - @dependency.SourceSceneTitle - @dependency.SceneDependencyTypeName @(dependency.IsHardDependency ? "/ hard" : "/ soft") -
- - - - - -
-
- } - } -
-
-

Scenes depending on this

- @if (!Model.DependenciesNeedingThisScene.Any()) - { -

No dependent scenes yet. Later scenes that rely on this one will appear here.

- } - else - { - @foreach (var dependency in Model.DependenciesNeedingThisScene) - { -
- Ch @dependency.TargetChapterNumber, Scene @dependency.TargetSceneNumber - @dependency.TargetSceneTitle - @dependency.SceneDependencyTypeName @(dependency.IsHardDependency ? "/ hard" : "/ soft") -
- - - - - -
-
- } - } -
-
- -
- - - - -
-
- - -
-
- - -
-
- - -
-
- -
-
- -
-
- -
-
- -
-

Move / Reorder

-
- - - -
-
- - -
-
- - -
-
- -
-
- -
-
- - - - - -
-
- -
-

Plot Thread Events

- @if (!Model.ThreadEvents.Any()) - { -

No plot thread events attached to this scene yet. Add clues, reveals, promises, payoffs, or reversals when the scene touches a thread.

- } - else - { -
- @foreach (var threadEvent in Model.ThreadEvents) - { -
-
- @threadEvent.MarkerText - @threadEvent.EventTitle - @threadEvent.PlotLineName / @threadEvent.ThreadTitle -
-
- - - - - -
-
- } -
- } - -
- - - -

Choose an existing thread, or choose a plot line to create one from the event title.

- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

Story Assets

- @if (!Model.AssetEvents.Any()) - { -

No asset events attached to this scene yet. Add one when an object, clue, secret, or important item changes here.

- } - else - { -
- @foreach (var assetEvent in Model.AssetEvents) - { -
-
- @assetEvent.MarkerText - @assetEvent.EventTitle - @assetEvent.AssetName / @assetEvent.AssetEventTypeName - @if (!string.IsNullOrWhiteSpace(assetEvent.ToStateName)) - { - State: @(assetEvent.FromStateName ?? "Any") -> @assetEvent.ToStateName - } -
-
- - - - - -
-
- } -
- } - -
- - - -

Choose an existing asset, or enter a new asset name to create it from this scene.

- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

Asset Locations

- @if (!Model.SceneAssetLocations.Any()) - { -

No assets have been placed in a location for this scene yet.

- } - else - { -
- @foreach (var assetLocation in Model.SceneAssetLocations) - { -
-
- @assetLocation.AssetName - @assetLocation.LocationName - @assetLocation.Description -
-
- - - - - -
-
- } -
- } - -
- - - -
-
- - -
-
- - -
-
- -
-
- -
-
- -
-
- -
-

Asset Custody

- @if (!Model.AssetCustodyEvents.Any()) - { -

No custody entries attached to this scene yet.

- } - else - { -
- @foreach (var custody in Model.AssetCustodyEvents) - { -
-
- @custody.CustodyEventTypeName - @custody.AssetName - @custody.CustodianSummary -
-
- - - - - -
-
- } -
- } - -
- - - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

Characters

- @if (!Model.SceneCharacters.Any()) - { -

No characters attached to this scene yet. Add the people who appear, are mentioned, or meaningfully affect the scene.

- } - else - { -
- @foreach (var character in Model.SceneCharacters) - { - var editId = $"scene-character-edit-{character.SceneCharacterID}"; -
-
-
- @character.CharacterName - @((character.RoleInSceneTypeName ?? "Role not set")) • @((character.PresenceTypeName ?? "Presence not set")) - Location: @(character.LocationName ?? "Not set") - Entry: @(character.EntryLocationName ?? "Not set") - Exit: @(character.ExitLocationName ?? "Not set") - @if (!string.IsNullOrWhiteSpace(character.OutfitDescription) || !string.IsNullOrWhiteSpace(character.PhysicalCondition) || !string.IsNullOrWhiteSpace(character.EmotionalState)) - { - @character.OutfitDescription @character.PhysicalCondition @character.EmotionalState - } +
+ @warning.SeverityName + @warning.WarningTypeName +

@warning.Message

+
+
+ + + + + + +
+
+ + + + + + +
-
- -
- +
+ } +
+ } +
+ +
+

Scene Dependencies

+
+
+

This scene depends on

+ @if (!Model.DependenciesThisSceneNeeds.Any()) + { +

No dependencies yet. Add one when this scene needs another moment to happen first.

+ } + else + { + @foreach (var dependency in Model.DependenciesThisSceneNeeds) + { +
+ Ch @dependency.SourceChapterNumber, Scene @dependency.SourceSceneNumber + @dependency.SourceSceneTitle + @dependency.SceneDependencyTypeName @(dependency.IsHardDependency ? "/ hard" : "/ soft") + +
-
-
-
- - - - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- - } + } + } +
+
+

Scenes depending on this

+ @if (!Model.DependenciesNeedingThisScene.Any()) + { +

No dependent scenes yet. Later scenes that rely on this one will appear here.

+ } + else + { + @foreach (var dependency in Model.DependenciesNeedingThisScene) + { +
+ Ch @dependency.TargetChapterNumber, Scene @dependency.TargetSceneNumber + @dependency.TargetSceneTitle + @dependency.SceneDependencyTypeName @(dependency.IsHardDependency ? "/ hard" : "/ soft") +
+ + + + + +
+
+ } + } +
- } -
- - - -

Add character to scene

- @if (TempData["SceneCharacterAddError"] is string addCharacterError) - { -

@addCharacterError

- } -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
- -
- - -
-

Character Attributes

- @if (Model.CharacterAttributeEvents.Any()) - { -
- @foreach (var item in Model.CharacterAttributeEvents) - { -
-
- @item.CharacterName: @item.AttributeName - @item.AttributeValue - @item.Description -
-
- } -
- } - else - { -

Character attribute history for this scene will appear here when available.

- } -
- - - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-

Character Knowledge

- @if (Model.CharacterKnowledge.Any()) - { -
- @foreach (var item in Model.CharacterKnowledge) - { -
-
- @item.CharacterName @item.KnowledgeStateName - @(item.AssetName ?? item.ThreadTitle) - @item.Description -
-
- - - - - -
-
- } -
- } -
- - - -
-
-
- -
-
- -
-
-
-
- -
-
- -
-

Relationship Events

- @if (Model.RelationshipEvents.Any()) - { -
- @foreach (var item in Model.RelationshipEvents) - { -
-
- @item.CharacterAName -> @item.CharacterBName - @item.RelationshipStateName / intensity @item.Intensity - @item.Description -
-
- - - - - -
-
- } -
- } -
- - - -
- @if (Model.RelationshipOptions.Count == 1) - { + + + + + +
- - @Model.RelationshipOptions[0].Text -
- } - else - { -
- + +
- } -
-
-
-
-
- - +
+ + +
+
+ + +
+
+ +
+
+ +
-
- + + +
+ + +
+

Move / Reorder

+
+ + + +
+
+ + +
+
+ + +
+
+ +
-
-
- - - + + +
+ + + + + +
+ diff --git a/PlotLine/appsettings.json b/PlotLine/appsettings.json index 5b879f1..332b876 100644 --- a/PlotLine/appsettings.json +++ b/PlotLine/appsettings.json @@ -4,7 +4,7 @@ }, "EmailSettings": { "SmtpServer": "", - "Port": 465, + "Port": 587, "Username": "", "Password": "", "EnableSsl": true, diff --git a/PlotLine/wwwroot/js/site.js b/PlotLine/wwwroot/js/site.js index ecad0e1..5361d74 100644 --- a/PlotLine/wwwroot/js/site.js +++ b/PlotLine/wwwroot/js/site.js @@ -465,10 +465,82 @@ .replace(/^-|-$/g, ""); roots.forEach((root) => { - const sceneId = root.querySelector("[name='SceneID']")?.value || "new"; + const sceneId = root.dataset.sceneId + || root.querySelector(".scene-inspector-form [name='SceneID']")?.value + || "new"; const storagePrefix = `plotline.inspector.${sceneId}.`; const defaultOpen = new Set(["overview", "timing", "characters"]); const sections = [...root.querySelectorAll(".inspector-section")]; + const form = root.querySelector(".scene-inspector-form"); + const timeEditor = root.querySelector("[data-scene-time-editor]"); + + if (form && timeEditor && timeEditor.dataset.enhanced !== "true") { + timeEditor.dataset.enhanced = "true"; + + const modeSelect = form.querySelector("[name='TimeModeID']"); + const exactDateTimeModeId = timeEditor.dataset.exactDatetimeModeId || ""; + const exactDateModeId = timeEditor.dataset.exactDateModeId || ""; + const timeFields = [...timeEditor.querySelectorAll("[data-time-part-field]")]; + const dateInputs = { + StartDateTime: timeEditor.querySelector("[data-date-part-for='StartDateTime']"), + EndDateTime: timeEditor.querySelector("[data-date-part-for='EndDateTime']") + }; + const timeInputs = { + StartDateTime: timeEditor.querySelector("[data-time-part-for='StartDateTime']"), + EndDateTime: timeEditor.querySelector("[data-time-part-for='EndDateTime']") + }; + const hiddenInputs = { + StartDateTime: form.querySelector("input[type='hidden'][name='StartDateTime']"), + EndDateTime: form.querySelector("input[type='hidden'][name='EndDateTime']") + }; + + const isExactDateTime = () => modeSelect?.value === exactDateTimeModeId; + const isExactDate = () => modeSelect?.value === exactDateModeId; + + const setTimeVisibility = () => { + const showTime = isExactDateTime(); + timeFields.forEach((field) => { + field.hidden = !showTime; + field.classList.toggle("d-none", !showTime); + }); + }; + + const writeCombinedDateTime = (fieldName) => { + const hidden = hiddenInputs[fieldName]; + const date = dateInputs[fieldName]?.value || ""; + const time = timeInputs[fieldName]?.value || ""; + if (!hidden) { + return; + } + + if (!date) { + hidden.value = ""; + return; + } + + if (isExactDateTime()) { + hidden.value = `${date}T${time || "00:00"}`; + return; + } + + if (isExactDate()) { + hidden.value = date; + return; + } + + const existingTime = hidden.value.match(/^\\d{4}-\\d{2}-\\d{2}T(\\d{2}:\\d{2})/)?.[1]; + hidden.value = existingTime ? `${date}T${existingTime}` : date; + }; + + const syncPostedDateTimes = () => { + writeCombinedDateTime("StartDateTime"); + writeCombinedDateTime("EndDateTime"); + }; + + modeSelect?.addEventListener("change", setTimeVisibility); + form.addEventListener("submit", syncPostedDateTimes); + setTimeVisibility(); + } sections.forEach((section) => { if (section.dataset.enhanced === "true") { @@ -494,10 +566,10 @@ button.type = "button"; button.className = "inspector-accordion-toggle"; button.setAttribute("aria-expanded", "false"); - const count = section.querySelectorAll(".asset-event-item, .thread-event-item, .warning-mini-card, .dependency-card, .metric-slider").length; - button.innerHTML = `${title}${count || ""}`; section.append(button, body); + const count = body.querySelectorAll(".asset-event-item, .thread-event-item, .warning-mini-card, .dependency-card, .metric-slider").length; + button.innerHTML = `${title}${count || ""}`; const stored = localStorage.getItem(`${storagePrefix}${storageKey}`); const isOpen = stored === null ? defaultOpen.has(storageKey) : stored === "open";