2026-06-14 20:45:36 +01:00

709 lines
39 KiB
Plaintext

@model ContinuityExplorerViewModel
@{
ViewData["Title"] = $"Continuity Explorer - {Model.Project.ProjectName}";
ViewData["ProjectSection"] = "Continuity Explorer";
}
@functions {
private static string StateBadgeClass(string state) => state switch
{
"Explicit" => "bg-success",
"Inferred" => "bg-secondary",
_ => "bg-warning text-dark"
};
private static string WarningBadgeClass(string severity) => severity switch
{
"Critical" => "bg-danger",
"Warning" => "bg-warning text-dark",
_ => "bg-info"
};
private bool IsEntitySelected(string entityType) =>
Model.Filter.EntityTypes.Contains(entityType, StringComparer.OrdinalIgnoreCase);
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>Continuity Explorer</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading">
<div>
<p class="eyebrow">Continuity</p>
<h1>Continuity Explorer</h1>
<p class="lead-text">Investigate where characters, assets, and locations stand across the story.</p>
</div>
</div>
<section class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Filters</strong>
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#continuity-filter-panel" aria-expanded="true" aria-controls="continuity-filter-panel">Toggle</button>
</div>
<div class="collapse show" id="continuity-filter-panel">
<div class="card-body">
<form asp-action="ApplyFilters" method="post">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<div class="row g-3">
<div class="col-lg-3">
<label class="form-label">Entity types</label>
<div class="d-grid gap-1">
<label class="form-check">
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Characters" checked="@IsEntitySelected("Characters")" />
<span class="form-check-label">Characters</span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Assets" checked="@IsEntitySelected("Assets")" />
<span class="form-check-label">Assets</span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Locations" checked="@IsEntitySelected("Locations")" />
<span class="form-check-label">Locations</span>
</label>
</div>
</div>
<div class="col-lg-9">
<div class="row g-2">
<div class="col-md-4">
<label class="form-label" for="CharacterIDs">Characters</label>
<select class="form-select form-select-sm" id="CharacterIDs" name="CharacterIDs" asp-items="Model.CharacterOptions" multiple size="6"></select>
</div>
<div class="col-md-4">
<label class="form-label" for="AssetIDs">Assets</label>
<select class="form-select form-select-sm" id="AssetIDs" name="AssetIDs" asp-items="Model.AssetOptions" multiple size="6"></select>
</div>
<div class="col-md-4">
<label class="form-label" for="LocationIDs">Locations</label>
<select class="form-select form-select-sm" id="LocationIDs" name="LocationIDs" asp-items="Model.LocationOptions" multiple size="6"></select>
</div>
</div>
</div>
<div class="col-lg-4">
<label class="form-label">Scene range</label>
<div class="d-grid gap-1">
<label class="form-check">
<input class="form-check-input" type="radio" name="SceneRangeMode" value="EntireProject" checked="@(Model.Filter.SceneRangeMode == "EntireProject")" />
<span class="form-check-label">Entire Project</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Book" checked="@(Model.Filter.SceneRangeMode == "Book")" />
<span class="form-check-label">Book</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Chapter" checked="@(Model.Filter.SceneRangeMode == "Chapter")" />
<span class="form-check-label">Chapter</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Custom" checked="@(Model.Filter.SceneRangeMode == "Custom")" />
<span class="form-check-label">Custom Scene Range</span>
</label>
</div>
</div>
<div class="col-lg-8">
<div class="row g-2">
<div class="col-md-6">
<label class="form-label" for="BookID">Book</label>
<select class="form-select form-select-sm" id="BookID" name="BookID" asp-items="Model.BookOptions">
<option value="">Any book</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="ChapterID">Chapter</label>
<select class="form-select form-select-sm" id="ChapterID" name="ChapterID" asp-items="Model.ChapterOptions">
<option value="">Any chapter</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="StartSceneID">Start scene</label>
<select class="form-select form-select-sm" id="StartSceneID" name="StartSceneID" asp-items="Model.SceneOptions">
<option value="">First scene</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="EndSceneID">End scene</label>
<select class="form-select form-select-sm" id="EndSceneID" name="EndSceneID" asp-items="Model.SceneOptions">
<option value="">Last scene</option>
</select>
</div>
</div>
</div>
<div class="col-lg-4">
<label class="form-label">Display mode</label>
<div class="d-grid gap-1">
<label class="form-check">
<input class="form-check-input" type="radio" name="DisplayMode" value="StoryState" checked="@(Model.Filter.DisplayMode == "StoryState")" />
<span class="form-check-label">Story State</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="DisplayMode" value="Journey" checked="@(Model.Filter.DisplayMode == "Journey")" />
<span class="form-check-label">Journey</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="DisplayMode" value="LocationActivity" checked="@(Model.Filter.DisplayMode == "LocationActivity")" />
<span class="form-check-label">Location Activity</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="DisplayMode" value="ContinuityWarnings" checked="@(Model.Filter.DisplayMode == "ContinuityWarnings")" />
<span class="form-check-label">Continuity Warnings</span>
</label>
</div>
</div>
<div class="col-lg-8">
<label class="form-label" for="SelectedSceneID">Story State scene</label>
<select class="form-select form-select-sm" id="SelectedSceneID" name="SelectedSceneID" asp-items="Model.SceneOptions"></select>
<label class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="ShowAcknowledgedWarnings" value="true" checked="@Model.Filter.ShowAcknowledgedWarnings" />
<span class="form-check-label">Show acknowledged warnings</span>
</label>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Apply filters</button>
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Reset</a>
</div>
</form>
</div>
</div>
</section>
@if (Model.Filter.DisplayMode == "StoryState")
{
<section class="story-bible-section">
<h2>Story State</h2>
@if (!Model.StoryState.Characters.Any() && !Model.StoryState.Assets.Any() && !Model.StoryState.Locations.Any())
{
<p class="muted">No matching continuity information found.</p>
}
else
{
@if (Model.Filter.IncludeCharacters)
{
<h3>Characters</h3>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead><tr><th>Character</th><th>Current Location</th><th>State</th><th>Last Confirmed</th></tr></thead>
<tbody>
@foreach (var row in Model.StoryState.Characters)
{
<tr>
<td>@row.CharacterName</td>
<td>@row.CurrentLocation</td>
<td><span class="badge @StateBadgeClass(row.State)">@row.State</span></td>
<td>
@if (row.LastConfirmedSceneID.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@row.LastConfirmedSceneID">@row.LastConfirmed</a>
}
else
{
@row.LastConfirmed
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (Model.Filter.IncludeAssets)
{
<h3>Assets</h3>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead><tr><th>Asset</th><th>Holder / Location</th><th>State</th><th>Last Confirmed</th></tr></thead>
<tbody>
@foreach (var row in Model.StoryState.Assets)
{
<tr>
<td>@row.AssetName</td>
<td>@row.CurrentHolderOrLocation</td>
<td><span class="badge @StateBadgeClass(row.State)">@row.State</span></td>
<td>
@if (row.LastConfirmedSceneID.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@row.LastConfirmedSceneID">@row.LastConfirmed</a>
}
else
{
@row.LastConfirmed
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (Model.Filter.IncludeLocations)
{
<h3>Locations</h3>
@if (!Model.StoryState.Locations.Any())
{
<p class="muted">No matching location presence found.</p>
}
else
{
<div class="row g-3">
@foreach (var location in Model.StoryState.Locations)
{
<div class="col-lg-6">
<article class="story-bible-card h-100">
<h3>@location.LocationPath</h3>
<p><strong>Characters:</strong> @(location.CharactersPresent.Any() ? string.Join(", ", location.CharactersPresent) : "None")</p>
<p><strong>Assets:</strong> @(location.AssetsPresent.Any() ? string.Join(", ", location.AssetsPresent) : "None")</p>
</article>
</div>
}
</div>
}
}
}
</section>
}
else if (Model.Filter.DisplayMode == "Journey")
{
<section class="story-bible-section">
<h2>Journey</h2>
@if (!Model.CharacterJourney.Any() && !Model.AssetJourney.Any())
{
<p class="muted">No movement recorded.</p>
}
else
{
@foreach (var group in Model.CharacterJourney.GroupBy(x => new { x.CharacterID, x.CharacterName }))
{
<h3>@group.Key.CharacterName</h3>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead>
<tr>
@if (Model.ShowBookContext)
{
<th>Book</th>
}
@if (Model.ShowChapterContext)
{
<th>Chapter</th>
}
<th>Scene</th>
<th>Location</th>
</tr>
</thead>
<tbody>
@foreach (var row in group)
{
<tr>
@if (Model.ShowBookContext)
{
<td>@row.BookDisplayName</td>
}
@if (Model.ShowChapterContext)
{
<td>@row.ChapterDisplayName</td>
}
<td><a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@row.SceneID">@row.SceneDisplayName</a></td>
<td>@row.Location</td>
</tr>
}
</tbody>
</table>
</div>
}
@foreach (var group in Model.AssetJourney.GroupBy(x => new { x.StoryAssetID, x.AssetName }))
{
<h3>@group.Key.AssetName</h3>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead>
<tr>
@if (Model.ShowBookContext)
{
<th>Book</th>
}
@if (Model.ShowChapterContext)
{
<th>Chapter</th>
}
<th>Scene</th>
<th>Holder / Location</th>
</tr>
</thead>
<tbody>
@foreach (var row in group)
{
<tr>
@if (Model.ShowBookContext)
{
<td>@row.BookDisplayName</td>
}
@if (Model.ShowChapterContext)
{
<td>@row.ChapterDisplayName</td>
}
<td><a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@row.SceneID">@row.SceneDisplayName</a></td>
<td>@row.HolderOrLocation</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</section>
}
else if (Model.Filter.DisplayMode == "ContinuityWarnings")
{
<section class="story-bible-section">
<h2>Continuity Warnings</h2>
@if (!Model.ContinuityWarnings.Any())
{
<p class="muted">No continuity concerns detected.</p>
<p class="muted">Continuity warnings highlight potential issues but cannot replace author judgement.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Severity</th>
<th>Status</th>
<th>Type</th>
<th>Character</th>
<th>Knowledge</th>
<th>Established</th>
<th>Earlier Reference</th>
<th>Message</th>
<th>Scene</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var warning in Model.ContinuityWarnings)
{
<tr>
<td><span class="badge @WarningBadgeClass(warning.Severity)">@warning.Severity</span></td>
<td>
@if (warning.IsAcknowledged)
{
<span class="badge bg-secondary">Acknowledged</span>
}
else
{
<span class="badge bg-warning text-dark">Active</span>
}
</td>
<td>@warning.WarningTypeDisplayName</td>
<td>
@if (!string.IsNullOrWhiteSpace(warning.CharacterName))
{
@warning.CharacterName
}
else
{
<span class="muted">-</span>
}
</td>
<td>
@if (!string.IsNullOrWhiteSpace(warning.KnowledgeTitle))
{
@warning.KnowledgeTitle
}
else
{
<span class="muted">-</span>
}
</td>
<td>
@if (warning.EstablishedSceneId.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.EstablishedSceneId">@warning.EstablishedSceneDisplayName</a>
}
else
{
<span class="muted">-</span>
}
</td>
<td>
@if (warning.ReferencedSceneId.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.ReferencedSceneId">@warning.ReferencedSceneDisplayName</a>
}
else
{
<span class="muted">-</span>
}
</td>
<td>
<div>@warning.Message</div>
@if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes))
{
<small class="text-muted">Note: @warning.AcknowledgementNotes</small>
}
</td>
<td>
@if (warning.SceneID.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.SceneID">@warning.SceneDisplayName</a>
}
else
{
<span class="muted">Project-wide</span>
}
</td>
<td>
@if (warning.IsAcknowledged)
{
<form asp-action="RestoreWarning" method="post" class="d-inline">
<input type="hidden" name="WarningType" value="@warning.WarningType" />
<input type="hidden" name="SceneID" value="@warning.SceneID" />
<input type="hidden" name="CharacterID" value="@warning.CharacterID" />
<input type="hidden" name="AssetID" value="@warning.AssetID" />
<input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Filter.ProjectID" />
@foreach (var entityType in Model.Filter.EntityTypes)
{
<input type="hidden" name="ReturnFilter.EntityTypes" value="@entityType" />
}
@foreach (var characterId in Model.Filter.CharacterIDs)
{
<input type="hidden" name="ReturnFilter.CharacterIDs" value="@characterId" />
}
@foreach (var assetId in Model.Filter.AssetIDs)
{
<input type="hidden" name="ReturnFilter.AssetIDs" value="@assetId" />
}
@foreach (var locationId in Model.Filter.LocationIDs)
{
<input type="hidden" name="ReturnFilter.LocationIDs" value="@locationId" />
}
<input type="hidden" name="ReturnFilter.SceneRangeMode" value="@Model.Filter.SceneRangeMode" />
<input type="hidden" name="ReturnFilter.BookID" value="@Model.Filter.BookID" />
<input type="hidden" name="ReturnFilter.ChapterID" value="@Model.Filter.ChapterID" />
<input type="hidden" name="ReturnFilter.StartSceneID" value="@Model.Filter.StartSceneID" />
<input type="hidden" name="ReturnFilter.EndSceneID" value="@Model.Filter.EndSceneID" />
<input type="hidden" name="ReturnFilter.DisplayMode" value="@Model.Filter.DisplayMode" />
<input type="hidden" name="ReturnFilter.SelectedSceneID" value="@Model.Filter.SelectedSceneID" />
<input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.Filter.ShowAcknowledgedWarnings" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Restore</button>
</form>
}
else
{
<form asp-action="AcknowledgeWarning" method="post" class="d-flex gap-1 align-items-center">
<input type="hidden" name="WarningType" value="@warning.WarningType" />
<input type="hidden" name="SceneID" value="@warning.SceneID" />
<input type="hidden" name="CharacterID" value="@warning.CharacterID" />
<input type="hidden" name="AssetID" value="@warning.AssetID" />
<input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Filter.ProjectID" />
@foreach (var entityType in Model.Filter.EntityTypes)
{
<input type="hidden" name="ReturnFilter.EntityTypes" value="@entityType" />
}
@foreach (var characterId in Model.Filter.CharacterIDs)
{
<input type="hidden" name="ReturnFilter.CharacterIDs" value="@characterId" />
}
@foreach (var assetId in Model.Filter.AssetIDs)
{
<input type="hidden" name="ReturnFilter.AssetIDs" value="@assetId" />
}
@foreach (var locationId in Model.Filter.LocationIDs)
{
<input type="hidden" name="ReturnFilter.LocationIDs" value="@locationId" />
}
<input type="hidden" name="ReturnFilter.SceneRangeMode" value="@Model.Filter.SceneRangeMode" />
<input type="hidden" name="ReturnFilter.BookID" value="@Model.Filter.BookID" />
<input type="hidden" name="ReturnFilter.ChapterID" value="@Model.Filter.ChapterID" />
<input type="hidden" name="ReturnFilter.StartSceneID" value="@Model.Filter.StartSceneID" />
<input type="hidden" name="ReturnFilter.EndSceneID" value="@Model.Filter.EndSceneID" />
<input type="hidden" name="ReturnFilter.DisplayMode" value="@Model.Filter.DisplayMode" />
<input type="hidden" name="ReturnFilter.SelectedSceneID" value="@Model.Filter.SelectedSceneID" />
<input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.Filter.ShowAcknowledgedWarnings" />
<input class="form-control form-control-sm" type="text" name="Notes" maxlength="500" placeholder="Optional note" />
<button class="btn btn-outline-primary btn-sm" type="submit">Acknowledge</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
else
{
<section class="story-bible-section">
<h2>Location Activity</h2>
@if (!Model.LocationActivity.Any())
{
<p class="muted">No matching continuity information found.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Location</th>
@if (Model.ShowBookContext)
{
<th>Book</th>
}
@if (Model.ShowChapterContext)
{
<th>Chapter</th>
}
<th>Scene</th>
<th>Characters Present</th>
<th>Assets Present</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model.LocationActivity)
{
<tr>
<td>@row.LocationName</td>
@if (Model.ShowBookContext)
{
<td>@row.BookDisplayName</td>
}
@if (Model.ShowChapterContext)
{
<td>@row.ChapterDisplayName</td>
}
<td><a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@row.SceneID">@row.SceneDisplayName</a></td>
<td>
@if (!row.Characters.Any())
{
<span class="muted">None</span>
}
else
{
<div class="d-grid gap-1">
@foreach (var character in row.Characters)
{
<div>
<span>@character.EntityName</span>
<span class="badge @StateBadgeClass(character.StateType)">@character.StateType</span>
@if (!string.IsNullOrWhiteSpace(character.LastConfirmedSceneDisplayName))
{
<small class="text-muted d-block">
Last confirmed:
@if (character.LastConfirmedSceneID.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@character.LastConfirmedSceneID">@character.LastConfirmedSceneDisplayName</a>
}
else
{
@character.LastConfirmedSceneDisplayName
}
</small>
}
</div>
}
</div>
}
</td>
<td>
@if (!row.Assets.Any())
{
<span class="muted">None</span>
}
else
{
<div class="d-grid gap-1">
@foreach (var asset in row.Assets)
{
<div>
<span>@asset.EntityName</span>
<span class="badge @StateBadgeClass(asset.StateType)">@asset.StateType</span>
@if (!string.IsNullOrWhiteSpace(asset.Note))
{
<small class="text-muted"> @asset.Note</small>
}
@if (!string.IsNullOrWhiteSpace(asset.LastConfirmedSceneDisplayName))
{
<small class="text-muted d-block">
Last confirmed:
@if (asset.LastConfirmedSceneID.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@asset.LastConfirmedSceneID">@asset.LastConfirmedSceneDisplayName</a>
}
else
{
@asset.LastConfirmedSceneDisplayName
}
</small>
}
</div>
}
</div>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
<script>
(() => {
const setDisabled = (id, disabled) => {
const element = document.getElementById(id);
if (element) {
element.disabled = disabled;
}
};
const selectedRadioValue = name => {
const selected = document.querySelector(`input[name="${name}"]:checked`);
return selected ? selected.value : "";
};
const isEntityChecked = value => {
const checkbox = document.querySelector(`input[name="EntityTypes"][value="${value}"]`);
return checkbox ? checkbox.checked : false;
};
const updateContinuityFilterControls = () => {
setDisabled("CharacterIDs", !isEntityChecked("Characters"));
setDisabled("AssetIDs", !isEntityChecked("Assets"));
setDisabled("LocationIDs", !isEntityChecked("Locations"));
const sceneRangeMode = selectedRadioValue("SceneRangeMode");
setDisabled("BookID", sceneRangeMode === "EntireProject");
setDisabled("ChapterID", sceneRangeMode !== "Chapter");
setDisabled("StartSceneID", sceneRangeMode !== "Custom");
setDisabled("EndSceneID", sceneRangeMode !== "Custom");
setDisabled("SelectedSceneID", selectedRadioValue("DisplayMode") !== "StoryState");
};
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('input[name="EntityTypes"], input[name="SceneRangeMode"], input[name="DisplayMode"]')
.forEach(input => input.addEventListener("change", updateContinuityFilterControls));
updateContinuityFilterControls();
});
})();
</script>