Phase 17Z - Legacy Retirement and Cleanup

This commit is contained in:
Nick Beckley 2026-06-30 19:32:13 +01:00
parent 0020ad37d7
commit d334661178
12 changed files with 6 additions and 2881 deletions

View File

@ -1668,32 +1668,6 @@ public sealed class ScenesController(
return RedirectToAction(nameof(Edit), new { id = sceneId });
}
[HttpGet]
public async Task<IActionResult> OccupancyEditor(int sceneId, int? floorPlanId)
{
var model = await scenes.GetOccupancyEditorAsync(sceneId, floorPlanId);
return model is null ? NotFound() : PartialView("_SceneOccupancy", model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveOccupancy(SceneOccupancySaveViewModel model)
{
try
{
await scenes.SaveOccupancyAsync(model);
var refreshed = await scenes.GetOccupancyEditorAsync(model.SceneID, model.FloorPlanID);
return refreshed is null
? NotFound()
: PartialView("_SceneOccupancy", refreshed);
}
catch (InvalidOperationException ex)
{
Response.StatusCode = StatusCodes.Status400BadRequest;
return Json(new { ok = false, message = ex.Message });
}
}
private string? GetLocalReturnUrl(string? returnUrl = null)
{
if (string.IsNullOrWhiteSpace(returnUrl)

View File

@ -122,8 +122,6 @@ public interface ISceneService
Task<SceneEditViewModel?> GetEditAsync(int sceneId);
Task<int> SaveAsync(SceneEditViewModel model);
Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId);
Task<SceneOccupancyEditorViewModel?> GetOccupancyEditorAsync(int sceneId, int? floorPlanId = null);
Task SaveOccupancyAsync(SceneOccupancySaveViewModel model);
Task ArchiveAsync(int sceneId);
Task MoveAsync(int sceneId, string direction);
Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position);
@ -1464,8 +1462,6 @@ public sealed class SceneService(
IAssetRepository assets,
ICharacterRepository characters,
ILocationRepository locations,
IFloorPlanRepository floorPlans,
ISceneFloorPlanOccupancyRepository sceneOccupancy,
IWarningRepository warnings,
ISceneDependencyRepository sceneDependencies,
IWriterWorkspaceRepository writerWorkspace,
@ -1842,172 +1838,10 @@ public sealed class SceneService(
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID
};
model.Occupancy = model.SceneID == 0
? new SceneOccupancyEditorViewModel()
: await BuildOccupancyEditorAsync(model, projectAssets);
model.LocationConsistencyMessages = BuildLocationConsistencyMessages(model);
return model;
}
public async Task<SceneOccupancyEditorViewModel?> GetOccupancyEditorAsync(int sceneId, int? floorPlanId = null)
{
var sceneModel = await GetEditAsync(sceneId);
if (sceneModel is null)
{
return null;
}
if (floorPlanId.HasValue)
{
sceneModel.FloorPlanID = floorPlanId;
}
var projectAssets = sceneModel.Project is null ? [] : await assets.ListAssetsAsync(sceneModel.Project.ProjectID);
return await BuildOccupancyEditorAsync(sceneModel, projectAssets);
}
public async Task SaveOccupancyAsync(SceneOccupancySaveViewModel model)
{
var scene = await scenes.GetAsync(model.SceneID) ?? throw new InvalidOperationException("Scene not found.");
var context = await GetContextAsync(scene.ChapterID) ?? throw new InvalidOperationException("Scene context not found.");
if (model.FloorPlanID.HasValue)
{
var floorPlan = await floorPlans.GetAsync(model.FloorPlanID.Value);
if (floorPlan is null || floorPlan.ProjectID != context.Project.ProjectID)
{
throw new InvalidOperationException("Floor plan not found.");
}
if (model.InitialFloorPlanFloorID.HasValue)
{
var floors = await floorPlans.ListFloorsByPlanAsync(model.FloorPlanID.Value);
if (!floors.Any(x => x.FloorPlanFloorID == model.InitialFloorPlanFloorID.Value))
{
throw new InvalidOperationException("Initial floor does not belong to the selected floor plan.");
}
}
}
else
{
model.InitialFloorPlanFloorID = null;
}
var allowedLocations = model.FloorPlanID.HasValue
? (await floorPlans.ListBlocksByPlanAsync(model.FloorPlanID.Value)).Select(x => x.LocationID).ToHashSet()
: [];
var sceneCharacterIds = (await characters.ListSceneCharactersAsync(model.SceneID)).Select(x => x.CharacterID).ToHashSet();
var sceneAssetIds = (await assets.ListEventsBySceneAsync(model.SceneID)).Select(x => x.StoryAssetID).ToHashSet();
var rows = new List<SceneFloorPlanOccupancy>();
foreach (var character in model.Characters.Where(x => sceneCharacterIds.Contains(x.CharacterID)))
{
if (character.LocationID.HasValue && !allowedLocations.Contains(character.LocationID.Value))
{
throw new InvalidOperationException("Character location must be on the selected floor plan.");
}
rows.Add(new SceneFloorPlanOccupancy
{
CharacterID = character.CharacterID,
LocationID = character.LocationID ?? 0
});
}
foreach (var asset in model.Assets.Where(x => sceneAssetIds.Contains(x.StoryAssetID)))
{
if (asset.LocationID.HasValue && !allowedLocations.Contains(asset.LocationID.Value))
{
throw new InvalidOperationException("Asset location must be on the selected floor plan.");
}
rows.Add(new SceneFloorPlanOccupancy
{
StoryAssetID = asset.StoryAssetID,
LocationID = asset.LocationID ?? 0
});
}
await sceneOccupancy.SaveAsync(model.SceneID, model.FloorPlanID, model.InitialFloorPlanFloorID, rows);
await activity.RecordAsync(context.Project.ProjectID, "Updated", "Scene Occupancy", model.SceneID, scene.SceneTitle);
}
private async Task<SceneOccupancyEditorViewModel> BuildOccupancyEditorAsync(SceneEditViewModel model, IReadOnlyList<StoryAsset> projectAssets)
{
var floorPlanList = model.Project is null ? [] : await floorPlans.ListByProjectAsync(model.Project.ProjectID);
var selectedFloorPlanId = model.FloorPlanID ?? floorPlanList.FirstOrDefault()?.FloorPlanID;
var existingRows = model.SceneID == 0 ? [] : await sceneOccupancy.ListBySceneAsync(model.SceneID);
var floors = new List<SceneOccupancyFloorOption>();
var locationsOnPlans = new List<SceneOccupancyLocationOption>();
foreach (var plan in floorPlanList)
{
var planFloors = await floorPlans.ListFloorsByPlanAsync(plan.FloorPlanID);
var blocks = await floorPlans.ListBlocksByPlanAsync(plan.FloorPlanID);
var floorsById = planFloors.ToDictionary(x => x.FloorPlanFloorID);
floors.AddRange(planFloors.Select(floor => new SceneOccupancyFloorOption
{
FloorPlanID = floor.FloorPlanID,
FloorPlanFloorID = floor.FloorPlanFloorID,
Name = floor.Name,
SortOrder = floor.SortOrder
}));
locationsOnPlans.AddRange(blocks
.GroupBy(x => new { x.LocationID, x.FloorPlanFloorID })
.Select(group =>
{
floorsById.TryGetValue(group.Key.FloorPlanFloorID, out var floor);
var first = group.First();
return new SceneOccupancyLocationOption
{
FloorPlanID = plan.FloorPlanID,
FloorPlanFloorID = group.Key.FloorPlanFloorID,
FloorName = floor?.Name ?? "Floor",
FloorSortOrder = floor?.SortOrder ?? 0,
LocationID = group.Key.LocationID,
LocationName = first.LocationName,
LocationPath = first.LocationPath
};
}));
}
var existingByCharacter = existingRows.Where(x => x.CharacterID.HasValue).ToDictionary(x => x.CharacterID!.Value, x => x.LocationID);
var existingByAsset = existingRows.Where(x => x.StoryAssetID.HasValue).ToDictionary(x => x.StoryAssetID!.Value, x => x.LocationID);
var assetNames = projectAssets.ToDictionary(x => x.StoryAssetID, x => x.AssetName);
return new SceneOccupancyEditorViewModel
{
SceneID = model.SceneID,
FloorPlanID = selectedFloorPlanId,
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID,
FloorPlanOptions = floorPlanList.OrderBy(x => x.Name).Select(x => new SelectListItem(x.Name, x.FloorPlanID.ToString())).ToList(),
Floors = floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).ToList(),
Locations = locationsOnPlans.OrderBy(x => x.FloorSortOrder).ThenBy(x => x.FloorName).ThenBy(x => x.LocationPath).ToList(),
Characters = model.SceneCharacters
.GroupBy(x => x.CharacterID)
.Select(group => new SceneCharacterOccupancyRowViewModel
{
CharacterID = group.Key,
CharacterName = group.First().CharacterName,
LocationID = existingByCharacter.GetValueOrDefault(group.Key)
})
.OrderBy(x => x.CharacterName)
.ToList(),
Assets = model.AssetEvents
.GroupBy(x => x.StoryAssetID)
.Select(group => new SceneAssetOccupancyRowViewModel
{
StoryAssetID = group.Key,
AssetName = assetNames.GetValueOrDefault(group.Key, group.First().AssetName),
LocationID = existingByAsset.GetValueOrDefault(group.Key)
})
.OrderBy(x => x.AssetName)
.ToList()
};
}
private async Task<IReadOnlyList<ContinuityWarning>> BuildAgeContinuityWarningsAsync(SceneEditViewModel model)
{
if (model.SceneID == 0 || model.Project is null || model.Book is null)

View File

@ -398,8 +398,6 @@ public sealed class SceneEditViewModel
public IReadOnlyList<SelectListItem> SceneAttachmentTypeOptions { get; set; } = [];
public StorageUsage? StorageUsage { get; set; }
public StoryStateViewModel StoryState { get; set; } = new();
public SceneOccupancyEditorViewModel Occupancy { get; set; } = new();
public int? ReturnProjectID { get; set; }
public int? ReturnBookID { get; set; }
public bool ReturnToTimeline { get; set; }
@ -559,60 +557,6 @@ public sealed class SceneCharacterParticipationInputViewModel
public string? EmotionalState { get; set; }
}
public sealed class SceneOccupancyEditorViewModel
{
public int SceneID { get; set; }
public int? FloorPlanID { get; set; }
public int? InitialFloorPlanFloorID { get; set; }
public IReadOnlyList<SelectListItem> FloorPlanOptions { get; set; } = [];
public IReadOnlyList<SceneOccupancyFloorOption> Floors { get; set; } = [];
public IReadOnlyList<SceneOccupancyLocationOption> Locations { get; set; } = [];
public IReadOnlyList<SceneCharacterOccupancyRowViewModel> Characters { get; set; } = [];
public IReadOnlyList<SceneAssetOccupancyRowViewModel> Assets { get; set; } = [];
}
public sealed class SceneOccupancyFloorOption
{
public int FloorPlanFloorID { get; set; }
public int FloorPlanID { get; set; }
public string Name { get; set; } = string.Empty;
public int SortOrder { get; set; }
}
public sealed class SceneOccupancyLocationOption
{
public int LocationID { get; set; }
public int FloorPlanID { get; set; }
public int FloorPlanFloorID { get; set; }
public string FloorName { get; set; } = string.Empty;
public int FloorSortOrder { get; set; }
public string LocationName { get; set; } = string.Empty;
public string LocationPath { get; set; } = string.Empty;
}
public sealed class SceneCharacterOccupancyRowViewModel
{
public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int? LocationID { get; set; }
}
public sealed class SceneAssetOccupancyRowViewModel
{
public int StoryAssetID { get; set; }
public string AssetName { get; set; } = string.Empty;
public int? LocationID { get; set; }
}
public sealed class SceneOccupancySaveViewModel
{
public int SceneID { get; set; }
public int? FloorPlanID { get; set; }
public int? InitialFloorPlanFloorID { get; set; }
public List<SceneCharacterOccupancyRowViewModel> Characters { get; set; } = [];
public List<SceneAssetOccupancyRowViewModel> Assets { get; set; } = [];
}
public sealed class StoryStateViewModel
{
public List<CharacterStoryStateViewModel> Characters { get; set; } = [];

View File

@ -54,16 +54,6 @@
z-index: auto;
}
.theme-audit-inspector-sample {
display: grid;
gap: .75rem;
}
.theme-audit-inspector-sample .inspector-section,
.theme-audit-inspector-sample .warning-mini-card,
.theme-audit-inspector-sample .thread-event-item {
margin: 0;
}
</style>
<div class="page-heading compact">
@ -329,38 +319,6 @@
</div>
</section>
<section class="theme-audit-section">
<h2>PlotDirector-Specific Components</h2>
<div class="scene-inspector-root theme-audit-inspector-sample">
<div class="inspector-quick-nav">
<input class="form-control form-control-sm inspector-search" type="search" placeholder="Find in scene..." />
<div class="inspector-nav-links">
<a class="active" href="#">Overview</a>
<a href="#">Characters</a>
<a href="#">Warnings</a>
</div>
</div>
<section class="inspector-section is-open">
<button class="inspector-accordion-toggle" type="button" aria-expanded="true"><span>Scene Inspector Card</span><span class="accordion-count">3</span></button>
<div class="inspector-accordion-body">
<p class="inspector-subheading">Compact scene metadata</p>
<div class="overview-chip-grid">
<span class="overview-chip">Draft</span>
<span class="overview-chip attention">1 warning</span>
<span class="purpose-chip">Reveal</span>
</div>
</div>
</section>
<article class="warning-mini-card">
<span class="severity-pill warning">Warning</span>
<p>Sample compact continuity warning card.</p>
</article>
<article class="thread-event-item">
<span class="thread-event-marker"></span>
<div><strong>Thread event</strong><p class="mb-0">A sample plot thread note.</p></div>
</article>
</div>
</section>
</section>
<section class="theme-audit-preview" data-theme="dark" data-bs-theme="dark" aria-label="Dark theme preview">
@ -613,38 +571,6 @@
</div>
</section>
<section class="theme-audit-section">
<h2>PlotDirector-Specific Components</h2>
<div class="scene-inspector-root theme-audit-inspector-sample">
<div class="inspector-quick-nav">
<input class="form-control form-control-sm inspector-search" type="search" placeholder="Find in scene..." />
<div class="inspector-nav-links">
<a class="active" href="#">Overview</a>
<a href="#">Characters</a>
<a href="#">Warnings</a>
</div>
</div>
<section class="inspector-section is-open">
<button class="inspector-accordion-toggle" type="button" aria-expanded="true"><span>Scene Inspector Card</span><span class="accordion-count">3</span></button>
<div class="inspector-accordion-body">
<p class="inspector-subheading">Compact scene metadata</p>
<div class="overview-chip-grid">
<span class="overview-chip">Draft</span>
<span class="overview-chip attention">1 warning</span>
<span class="purpose-chip">Reveal</span>
</div>
</div>
</section>
<article class="warning-mini-card">
<span class="severity-pill warning">Warning</span>
<p>Sample compact continuity warning card.</p>
</article>
<article class="thread-event-item">
<span class="thread-event-marker"></span>
<div><strong>Thread event</strong><p class="mb-0">A sample plot thread note.</p></div>
</article>
</div>
</section>
</section>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,98 +0,0 @@
@model SceneOccupancyEditorViewModel
@{
var selectedPlanId = Model.FloorPlanID;
var locationOptions = Model.Locations
.Where(x => !selectedPlanId.HasValue || x.FloorPlanID == selectedPlanId.Value)
.OrderBy(x => x.FloorSortOrder)
.ThenBy(x => x.FloorName)
.ThenBy(x => x.LocationPath)
.ToList();
}
<form asp-controller="Scenes" asp-action="SaveOccupancy" method="post" class="scene-occupancy-form" data-scene-occupancy-form data-editor-url="@Url.Action("OccupancyEditor", "Scenes")">
<input type="hidden" name="SceneID" value="@Model.SceneID" />
<div class="scene-occupancy-status" data-scene-occupancy-status>Saved</div>
<div class="row g-2">
<div class="col-sm-6">
<label class="form-label" for="OccupancyFloorPlanID">Floor Plan</label>
<select class="form-select form-select-sm" id="OccupancyFloorPlanID" name="FloorPlanID" data-occupancy-plan-select>
<option value="">No floor plan</option>
@foreach (var option in Model.FloorPlanOptions)
{
<option value="@option.Value" selected="@(option.Value == Model.FloorPlanID?.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-sm-6">
<label class="form-label" for="OccupancyInitialFloorID">Initial Floor</label>
<select class="form-select form-select-sm" id="OccupancyInitialFloorID" name="InitialFloorPlanFloorID" data-occupancy-floor-select>
<option value="">Use first floor</option>
@foreach (var floor in Model.Floors.Where(x => !selectedPlanId.HasValue || x.FloorPlanID == selectedPlanId.Value))
{
<option value="@floor.FloorPlanFloorID" selected="@(floor.FloorPlanFloorID == Model.InitialFloorPlanFloorID)">@floor.Name</option>
}
</select>
</div>
</div>
@if (!Model.FloorPlanOptions.Any())
{
<p class="muted mt-2 mb-0">No floor plans exist for this project yet.</p>
}
else
{
<div class="scene-occupancy-grid mt-3">
<section>
<h4>Characters</h4>
@if (!Model.Characters.Any())
{
<p class="muted">No characters appear in this scene yet.</p>
}
else
{
@for (var i = 0; i < Model.Characters.Count; i++)
{
<div class="scene-occupancy-row">
<input type="hidden" name="Characters[@i].CharacterID" value="@Model.Characters.ElementAt(i).CharacterID" />
<span>@Model.Characters.ElementAt(i).CharacterName</span>
<select class="form-select form-select-sm" name="Characters[@i].LocationID">
<option value="">Not placed</option>
@foreach (var location in locationOptions)
{
<option value="@location.LocationID" selected="@(location.LocationID == Model.Characters.ElementAt(i).LocationID)">@location.FloorName &gt; @location.LocationPath</option>
}
</select>
</div>
}
}
</section>
<section>
<h4>Assets</h4>
@if (!Model.Assets.Any())
{
<p class="muted">No story assets appear in this scene yet.</p>
}
else
{
@for (var i = 0; i < Model.Assets.Count; i++)
{
<div class="scene-occupancy-row">
<input type="hidden" name="Assets[@i].StoryAssetID" value="@Model.Assets.ElementAt(i).StoryAssetID" />
<span>@Model.Assets.ElementAt(i).AssetName</span>
<select class="form-select form-select-sm" name="Assets[@i].LocationID">
<option value="">Not placed</option>
@foreach (var location in locationOptions)
{
<option value="@location.LocationID" selected="@(location.LocationID == Model.Assets.ElementAt(i).LocationID)">@location.FloorName &gt; @location.LocationPath</option>
}
</select>
</div>
}
}
</section>
</div>
<button class="btn btn-primary btn-sm mt-3" type="submit">Save occupancy</button>
}
</form>

View File

@ -849,37 +849,10 @@ a:hover {
box-shadow: 0 4px 12px rgba(62, 45, 28, 0.12);
}
.scene-inspector-panel,
.scene-inspector-root {
.scene-inspector-panel {
background: rgba(255, 251, 243, 0.72);
}
.inspector-section {
background: var(--plotline-surface-elevated);
border-color: var(--plotline-border-soft);
border-radius: var(--plotline-radius);
box-shadow: var(--plotline-shadow-subtle);
}
.scene-overview-card {
background: linear-gradient(135deg, #fffaf1, #f2e2cf);
}
.inspector-quick-nav,
.inspector-accordion-toggle {
background: rgba(255, 251, 243, 0.88);
border-color: rgba(84, 68, 48, 0.14);
}
.inspector-accordion-toggle {
color: var(--plotline-accent-dark);
border-radius: var(--plotline-radius-sm);
}
.inspector-accordion-toggle:hover {
background: rgba(122, 77, 44, 0.08);
}
.form-control,
.form-select {
border-color: rgba(84, 68, 48, 0.2);
@ -1503,45 +1476,6 @@ a:focus-visible {
color: #211915;
}
.scene-inspector-root {
display: grid;
gap: 0.85rem;
}
.inspector-section {
padding: 0.95rem;
}
.inspector-section h3 {
color: #211915;
font-size: 1.02rem;
}
.scene-overview-card {
border: 1px solid rgba(122, 77, 44, 0.18);
background:
linear-gradient(135deg, rgba(255, 250, 241, 0.98), rgba(242, 226, 207, 0.82));
}
.inspector-quick-nav {
top: 0.35rem;
box-shadow: 0 10px 26px rgba(62, 45, 28, 0.08);
}
.inspector-nav-links {
max-height: 6.2rem;
overflow: auto;
}
.inspector-nav-links a {
background: rgba(255, 253, 248, 0.84);
}
.inspector-accordion-toggle {
min-height: 2.25rem;
padding-inline: 0.2rem;
}
.writer-mini-card {
border-color: rgba(84, 68, 48, 0.13);
background: rgba(255, 253, 248, 0.78);
@ -1892,10 +1826,6 @@ a:focus-visible {
[data-theme="dark"] .writer-mini-card,
[data-theme="dark"] .timeline-setting-card,
[data-theme="dark"] .timeline-setting-card:has(input:checked),
[data-theme="dark"] .scene-overview-card,
[data-theme="dark"] .inspector-section,
[data-theme="dark"] .inspector-quick-nav,
[data-theme="dark"] .inspector-accordion-toggle,
[data-theme="dark"] .thread-event-item,
[data-theme="dark"] .asset-event-item,
[data-theme="dark"] .story-asset-event-item {
@ -1989,25 +1919,6 @@ a:focus-visible {
background: var(--plotline-accent-dark);
}
[data-theme="dark"] .scene-inspector-root {
color: var(--app-text);
background: transparent;
}
[data-theme="dark"] .inspector-nav-links a {
color: #ead8c4;
background: rgba(231, 214, 190, 0.08);
border-color: rgba(231, 214, 190, 0.28);
}
[data-theme="dark"] .inspector-nav-links a:hover,
[data-theme="dark"] .inspector-nav-links a:focus,
[data-theme="dark"] .inspector-nav-links a.active {
color: #fff4e8;
background: rgba(241, 195, 143, 0.16);
border-color: rgba(241, 195, 143, 0.52);
}
[data-theme="dark"] .thread-event-item,
[data-theme="dark"] .asset-event-item,
[data-theme="dark"] .story-asset-event-item {
@ -5108,7 +5019,4 @@ a:focus-visible {
max-width: 260px;
}
.inspector-nav-links {
max-height: 8.5rem;
}
}

File diff suppressed because one or more lines are too long

View File

@ -1842,27 +1842,6 @@ body.timeline-inspector-resizing {
font-size: 1.25rem;
}
.scene-inspector-form {
display: grid;
gap: 14px;
}
.inspector-section {
border-top: 1px solid var(--plotline-line);
padding-top: 12px;
}
.inspector-section:first-of-type {
border-top: 0;
padding-top: 0;
}
.inspector-section h3 {
margin: 0 0 10px;
font-size: 0.95rem;
color: var(--plotline-accent-dark);
}
.location-helper {
border: 1px solid rgba(138, 111, 61, 0.22);
border-radius: 8px;
@ -2123,36 +2102,9 @@ body.timeline-inspector-resizing {
min-width: 0;
}
.scene-inspector-root {
display: grid;
gap: 12px;
}
.scene-overview-card {
display: grid;
gap: 10px;
border: 1px solid rgba(47, 111, 99, 0.18);
border-radius: 10px;
padding: 12px;
background: linear-gradient(180deg, rgba(47, 111, 99, 0.08), rgba(255, 255, 255, 0.88));
}
.scene-overview-card h3 {
margin: 0;
font-size: 1.05rem;
}
.scene-overview-card .muted {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: 0;
}
.overview-chip-grid,
.context-nav,
.breadcrumb-trail,
.inspector-nav-links {
.breadcrumb-trail {
display: flex;
flex-wrap: wrap;
gap: 7px;
@ -2189,97 +2141,6 @@ body.timeline-inspector-resizing {
color: var(--plotline-muted);
}
.inspector-quick-nav {
position: sticky;
top: 0;
z-index: 5;
display: grid;
gap: 8px;
border: 1px solid var(--plotline-line);
border-radius: 10px;
padding: 10px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 10px 24px rgba(55, 48, 39, 0.08);
backdrop-filter: blur(8px);
}
.inspector-nav-links a {
border: 1px solid var(--plotline-line);
border-radius: 999px;
padding: 4px 8px;
color: var(--plotline-muted);
font-size: 0.76rem;
font-weight: 800;
text-decoration: none;
}
.inspector-nav-links a:hover,
.inspector-nav-links a.active {
color: var(--plotline-accent-dark);
border-color: rgba(47, 111, 99, 0.35);
background: rgba(47, 111, 99, 0.08);
}
.inspector-accordion-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
border: 0;
padding: 0;
color: var(--plotline-ink);
font-size: 0.95rem;
font-weight: 900;
text-align: left;
background: transparent;
}
.inspector-accordion-toggle::before {
content: "+";
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
margin-right: 7px;
color: var(--plotline-accent-dark);
background: rgba(47, 111, 99, 0.09);
}
.inspector-section.is-open > .inspector-accordion-toggle::before {
content: "-";
}
.inspector-accordion-toggle span:first-child {
margin-right: auto;
}
.accordion-count {
color: var(--plotline-muted);
font-size: 0.75rem;
}
.inspector-subheading {
margin: 14px 0 8px;
color: var(--plotline-muted);
font-size: 0.88rem;
font-weight: 900;
}
.inspector-accordion-body {
display: none;
margin-top: 12px;
}
.inspector-section.is-open > .inspector-accordion-body {
display: block;
}
.inspector-section.is-filtered-out {
display: none;
}
.breadcrumb-trail {
margin-bottom: 12px;
}
@ -3628,18 +3489,6 @@ body.dragging-location [data-drag-type="location"] {
background: var(--plotline-accent);
}
.inspector-actions {
position: sticky;
bottom: -16px;
display: flex;
gap: 8px;
border-top: 1px solid var(--plotline-line);
margin: 2px -16px -16px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
}
.story-bible-heading {
align-items: end;
}
@ -4409,8 +4258,7 @@ body.dragging-location [data-drag-type="location"] {
.breadcrumb-trail,
.context-header,
.button-row,
.story-bible-nav,
.inspector-quick-nav {
.story-bible-nav {
display: none !important;
}
@ -6272,50 +6120,6 @@ body.scene-inspector-editor-resizing {
color: var(--muted-text, #5d6f65);
}
.scene-occupancy-form {
display: grid;
gap: 0.5rem;
}
.scene-occupancy-status {
justify-self: start;
border-radius: 999px;
padding: 0.2rem 0.55rem;
color: var(--plotline-muted);
background: var(--plotline-soft);
font-size: 0.78rem;
font-weight: 700;
}
.scene-occupancy-status[data-save-state="saving"] {
color: #7a4d08;
background: #fff4d6;
}
.scene-occupancy-status[data-save-state="failed"] {
color: #842029;
background: #f8d7da;
}
.scene-occupancy-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.scene-occupancy-grid h4 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.scene-occupancy-row {
display: grid;
grid-template-columns: minmax(8rem, 0.45fr) minmax(0, 0.55fr);
align-items: center;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.floor-plan-editor--occupancy .floor-plan-block {
cursor: default;
touch-action: auto;
@ -6595,12 +6399,6 @@ body.scene-inspector-editor-resizing {
border-color: rgba(212, 154, 98, .22);
}
[data-bs-theme="dark"] .scene-occupancy-status,
.dark .scene-occupancy-status {
color: #d9c8b8;
background: rgba(212, 154, 98, .12);
}
[data-bs-theme="dark"] .floor-plan-occupancy-token,
.dark .floor-plan-occupancy-token {
border-color: #1f2630;

File diff suppressed because one or more lines are too long

View File

@ -186,137 +186,6 @@
});
})();
(() => {
const inspectorRoots = document.querySelectorAll(".scene-inspector-root");
if (!inspectorRoots.length) {
return;
}
const currentReturnUrl = () => `${window.location.pathname}${window.location.search}${window.location.hash}`;
inspectorRoots.forEach((root) => {
root.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
let returnUrlInput = form.querySelector("input[name='ReturnUrl']");
if (!returnUrlInput) {
returnUrlInput = document.createElement("input");
returnUrlInput.type = "hidden";
returnUrlInput.name = "ReturnUrl";
form.appendChild(returnUrlInput);
}
returnUrlInput.value = currentReturnUrl();
}, true);
});
})();
(() => {
const roots = document.querySelectorAll(".scene-inspector-root");
if (!roots.length) {
return;
}
const setStatus = (form, text, state) => {
const status = form.querySelector("[data-scene-occupancy-status]");
if (!status) {
return;
}
status.textContent = text;
status.dataset.saveState = state || "";
};
const replaceOccupancyForm = (form, html) => {
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
const nextForm = wrapper.querySelector("[data-scene-occupancy-form]");
if (nextForm) {
form.replaceWith(nextForm);
const sceneForm = nextForm.closest(".scene-inspector-root")?.querySelector(".scene-inspector-form");
const floorPlanInput = sceneForm?.querySelector("[name='FloorPlanID']");
const initialFloorInput = sceneForm?.querySelector("[name='InitialFloorPlanFloorID']");
const nextFloorPlan = nextForm.querySelector("[name='FloorPlanID']");
const nextInitialFloor = nextForm.querySelector("[name='InitialFloorPlanFloorID']");
if (floorPlanInput && nextFloorPlan) {
floorPlanInput.value = nextFloorPlan.value;
}
if (initialFloorInput && nextInitialFloor) {
initialFloorInput.value = nextInitialFloor.value;
}
}
};
roots.forEach((root) => {
root.addEventListener("change", async (event) => {
const planSelect = event.target.closest?.("[data-occupancy-plan-select]");
if (!planSelect) {
return;
}
const form = planSelect.closest("[data-scene-occupancy-form]");
const sceneId = form?.querySelector("[name='SceneID']")?.value;
const editorUrl = form?.dataset.editorUrl;
if (!form || !sceneId || !editorUrl) {
return;
}
setStatus(form, "Loading...", "saving");
const url = new URL(editorUrl, window.location.origin);
url.searchParams.set("sceneId", sceneId);
if (planSelect.value) {
url.searchParams.set("floorPlanId", planSelect.value);
}
try {
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
if (!response.ok) {
throw new Error("The occupancy editor could not be refreshed.");
}
replaceOccupancyForm(form, await response.text());
} catch (error) {
setStatus(form, error.message || "Load failed", "failed");
}
});
root.addEventListener("submit", async (event) => {
const form = event.target.closest?.("[data-scene-occupancy-form]");
if (!form) {
return;
}
event.preventDefault();
setStatus(form, "Saving...", "saving");
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { "X-Requested-With": "XMLHttpRequest" }
});
if (!response.ok) {
let message = "Save failed";
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const payload = await response.json();
message = payload.message || message;
}
throw new Error(message);
}
replaceOccupancyForm(form, await response.text());
} catch (error) {
setStatus(form, error.message || "Save failed", "failed");
}
});
});
})();
(() => {
const modalElement = document.getElementById("plotlineConfirmModal");
const modal = modalElement && window.bootstrap ? new bootstrap.Modal(modalElement) : null;
@ -737,184 +606,6 @@
}
})();
(() => {
const roots = document.querySelectorAll(".scene-inspector-root");
if (!roots.length) {
return;
}
const slugify = (value) => value
.toLowerCase()
.replace(/&/g, "and")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
roots.forEach((root) => {
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") {
return;
}
const heading = section.querySelector("h3, h2");
const title = section.dataset.sectionTitle || heading?.textContent?.trim() || "Overview";
const slug = slugify(title);
const sectionId = section.dataset.sectionId || `inspector-${slug}`;
const storageKey = sectionId.replace(/^inspector-/, "");
section.id = sectionId;
section.dataset.searchText = `${title} ${section.textContent}`.toLowerCase();
section.dataset.enhanced = "true";
const body = document.createElement("div");
body.className = "inspector-accordion-body";
while (section.firstChild) {
body.appendChild(section.firstChild);
}
const button = document.createElement("button");
button.type = "button";
button.className = "inspector-accordion-toggle";
button.setAttribute("aria-expanded", "false");
section.append(button, body);
const count = body.querySelectorAll(".asset-event-item, .thread-event-item, .warning-mini-card, .dependency-card, .metric-slider").length;
button.innerHTML = `<span>${title}</span><span class="accordion-count">${count || ""}</span>`;
const stored = localStorage.getItem(`${storagePrefix}${storageKey}`);
const isOpen = stored === null ? defaultOpen.has(storageKey) : stored === "open";
section.classList.toggle("is-open", isOpen);
button.setAttribute("aria-expanded", String(isOpen));
button.addEventListener("click", () => {
const nextOpen = !section.classList.contains("is-open");
section.classList.toggle("is-open", nextOpen);
button.setAttribute("aria-expanded", String(nextOpen));
localStorage.setItem(`${storagePrefix}${storageKey}`, nextOpen ? "open" : "closed");
});
});
root.querySelectorAll(".inspector-nav-links a").forEach((link) => {
link.addEventListener("click", (event) => {
const target = root.querySelector(link.getAttribute("href"));
if (!target) {
return;
}
event.preventDefault();
target.classList.add("is-open");
target.querySelector(".inspector-accordion-toggle")?.setAttribute("aria-expanded", "true");
target.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
const search = root.querySelector("[data-inspector-search]");
search?.addEventListener("input", () => {
const query = search.value.trim().toLowerCase();
sections.forEach((section) => {
const matches = !query || section.dataset.searchText.includes(query);
section.classList.toggle("is-filtered-out", !matches);
if (matches && query) {
section.classList.add("is-open");
section.querySelector(".inspector-accordion-toggle")?.setAttribute("aria-expanded", "true");
}
});
});
if ("IntersectionObserver" in window) {
const navLinks = [...root.querySelectorAll(".inspector-nav-links a")];
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
navLinks.forEach((link) => {
link.classList.toggle("active", link.getAttribute("href") === `#${entry.target.id}`);
});
});
}, { root: root.closest(".scene-inspector-panel") || null, rootMargin: "-10% 0px -70% 0px" });
sections.forEach((section) => observer.observe(section));
}
});
})();
(() => {
const chartRoots = document.querySelectorAll("[data-metric-shape-chart]");
if (!chartRoots.length) {

File diff suppressed because one or more lines are too long