Add writing context route and live display

This commit is contained in:
Nick Beckley 2026-08-17 14:34:08 +00:00
parent 90307757b4
commit bba75f3ac4
12 changed files with 1049 additions and 1 deletions

View File

@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
[Route("Writing")]
public sealed class WritingController(
IStoryContextService storyContext,
IRelationshipMapService relationshipMap,
IFloorPlanService floorPlans) : Controller
{
[HttpGet("Context")]
public async Task<IActionResult> Context(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
var model = new WritingContextPageViewModel
{
ProjectID = projectId,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
Display = sceneId.HasValue
? await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual context")
: new WritingContextDisplayViewModel { FollowStatus = "Connected, waiting for scene" }
};
return View(model);
}
[HttpGet("Context/Scene")]
public async Task<IActionResult> ContextScene(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
if (!sceneId.HasValue)
{
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", new WritingContextDisplayViewModel
{
FollowStatus = "Connected, waiting for scene"
});
}
var model = await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
return model?.HasScene == true
? PartialView("~/Views/Writing/_WritingContextContent.cshtml", model)
: NotFound();
}
private async Task<WritingContextDisplayViewModel?> BuildDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
{
if (!sceneId.HasValue)
{
return new WritingContextDisplayViewModel { FollowStatus = followStatus };
}
var context = await storyContext.GetForSceneAsync(sceneId.Value, projectId, bookId);
if (context?.Scene is null)
{
return null;
}
var position = context.Position;
var relationships = await relationshipMap.GetAsync(position, null, true);
var floorPlan = context.Scene.FloorPlanID.HasValue
? await floorPlans.GetEditorAsync(context.Scene.FloorPlanID.Value)
: null;
return new WritingContextDisplayViewModel
{
Context = context,
RelationshipMap = relationships,
FloorPlan = floorPlan,
FollowStatus = followStatus,
SceneOptions = BuildSceneOptions(context.Scene)
};
}
private static IReadOnlyList<SelectListItem> BuildSceneOptions(SceneEditViewModel scene)
{
var options = scene.SceneOptions
.Select(x => new SelectListItem(x.Text, x.Value, x.Value == scene.SceneID.ToString()))
.ToList();
if (!options.Any(x => x.Value == scene.SceneID.ToString()))
{
options.Insert(0, new SelectListItem(
$"{scene.Book?.BookDisplayTitle ?? "Book"} / Ch {scene.Chapter?.ChapterNumber:g} / Scene {scene.SceneNumber:g}: {scene.SceneTitle}",
scene.SceneID.ToString(),
true));
}
return options
.OrderByDescending(x => x.Selected)
.ThenBy(x => x.Text)
.ToList();
}
}

View File

@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models;
using PlotLine.Services;
namespace PlotLine.ViewModels;
@ -428,6 +429,25 @@ public sealed class SceneEditViewModel
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; set; } = [];
}
public sealed class WritingContextPageViewModel
{
public int? ProjectID { get; set; }
public int? BookID { get; set; }
public int? ChapterID { get; set; }
public int? SceneID { get; set; }
public WritingContextDisplayViewModel? Display { get; set; }
}
public sealed class WritingContextDisplayViewModel
{
public StoryContextViewModel? Context { get; set; }
public RelationshipMapViewModel? RelationshipMap { get; set; }
public FloorPlanEditorViewModel? FloorPlan { get; set; }
public string FollowStatus { get; set; } = "Connected, waiting for scene";
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
public bool HasScene => Context?.Scene is not null;
}
public sealed class SceneDetailsSaveViewModel
{
public int SceneID { get; set; }

View File

@ -0,0 +1,30 @@
@model WritingContextPageViewModel
@{
ViewData["Title"] = "Writing Context";
ViewData["ProjectSection"] = "Writer";
}
<main class="writing-context-page"
data-writing-context
data-context-url="@Url.Action("ContextScene", "Writing")"
data-scope-project-id="@Model.ProjectID"
data-scope-book-id="@Model.BookID">
<header class="writing-context-shell-header">
<div>
<p class="eyebrow">Writing Context</p>
<h1>Writing Context</h1>
</div>
<div class="writing-context-status" data-writing-context-status>
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span data-writing-context-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
</div>
</header>
<section data-writing-context-content>
<partial name="_WritingContextContent" model="Model.Display ?? new WritingContextDisplayViewModel()" />
</section>
</main>
@section Scripts {
<script src="~/js/writing-context.js" asp-append-version="true"></script>
}

View File

@ -0,0 +1,74 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context?.Scene;
var position = Model.Context?.Position;
}
<div class="writing-context-content"
data-writing-context-fragment
data-project-id="@position?.ProjectId"
data-book-id="@position?.BookId"
data-chapter-id="@position?.ChapterId"
data-scene-id="@position?.SceneId"
data-follow-status="@Model.FollowStatus">
@if (scene is null)
{
<section class="writing-context-empty">
<p class="eyebrow">Waiting</p>
<h2>Waiting for Word...</h2>
<p>Open a linked manuscript or choose a scene manually once context is available.</p>
</section>
}
else
{
<header class="writing-context-scene-header">
<div>
<p class="eyebrow">@scene.Project?.ProjectName</p>
<h2>@scene.SceneTitle</h2>
<p>
@scene.Book?.BookDisplayTitle
<span aria-hidden="true">/</span>
Chapter @scene.Chapter?.ChapterNumber:g
<span aria-hidden="true">/</span>
Scene @scene.SceneNumber:g
</p>
</div>
<div class="writing-context-scene-actions">
<label for="writing-context-scene-select">Jump to scene</label>
<select id="writing-context-scene-select" class="form-select form-select-sm" data-writing-context-scene-select>
@foreach (var option in Model.SceneOptions)
{
<option value="@option.Value" selected="@option.Selected">@option.Text</option>
}
</select>
<a class="btn btn-outline-secondary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open Scene</a>
</div>
</header>
<nav class="writing-context-tabs" aria-label="Writing context tabs">
<button type="button" class="active" data-writing-context-tab="overview">Overview</button>
<button type="button" data-writing-context-tab="people">People</button>
<button type="button" data-writing-context-tab="relationships">Relationships</button>
<button type="button" data-writing-context-tab="place">Place</button>
<button type="button" data-writing-context-tab="story">Story</button>
</nav>
<div class="writing-context-tab-panels">
<section class="writing-context-tab-panel active" data-writing-context-panel="overview">
<partial name="_WritingContextOverview" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-context-panel="people" hidden>
<partial name="_WritingContextPeople" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-context-panel="relationships" hidden>
<partial name="_WritingContextRelationships" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-context-panel="place" hidden>
<partial name="_WritingContextPlace" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-context-panel="story" hidden>
<partial name="_WritingContextStory" model="Model" />
</section>
</div>
}
</div>

View File

@ -0,0 +1,74 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
var pov = scene.CharacterOptions.FirstOrDefault(x => x.Value == scene.POVCharacterID?.ToString())?.Text
?? scene.SceneCharacters.FirstOrDefault(x => x.CharacterID == scene.POVCharacterID)?.CharacterName
?? "No POV set";
var primaryLocation = scene.LocationOptions.FirstOrDefault(x => x.Value == scene.PrimaryLocationID?.ToString())?.Text?.Trim()
?? "No primary location";
var purposeLabels = scene.ScenePurposeTypes
.Where(x => scene.SelectedPurposeTypeIds.Contains(x.ScenePurposeTypeID))
.OrderBy(x => x.SortOrder)
.Select(x => x.PurposeName)
.ToList();
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid writing-context-grid--overview">
<article class="writing-context-card writing-context-card--hero">
<p class="eyebrow">Current Scene</p>
<h3>@scene.SceneTitle</h3>
<p>@TextOr(scene.Summary, "No scene summary has been written yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Where and When</p>
<dl class="writing-context-facts">
<dt>POV</dt><dd>@pov</dd>
<dt>Date</dt><dd>@(scene.StartDateTime?.ToString("d MMM yyyy") ?? scene.RelativeTimeText ?? "Unknown")</dd>
<dt>Time</dt><dd>@(scene.StartDateTime?.ToString("HH:mm") ?? scene.TimeModeName ?? "Unknown")</dd>
<dt>Location</dt><dd>@primaryLocation</dd>
<dt>Status</dt><dd>@(scene.RevisionStatuses.FirstOrDefault(x => x.Value == scene.RevisionStatusID.ToString())?.Text ?? scene.RevisionStatusID.ToString())</dd>
</dl>
</article>
<article class="writing-context-card">
<p class="eyebrow">Purpose</p>
@if (purposeLabels.Any())
{
<div class="writing-context-chip-row">
@foreach (var purpose in purposeLabels)
{
<span>@purpose</span>
}
</div>
}
else
{
<p>No scene purpose selected.</p>
}
<p>@TextOr(scene.ScenePurposeNotes, "No purpose notes yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Outcome</p>
<p>@TextOr(scene.SceneOutcomeNotes, "No outcome notes yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Continuity</p>
@if (scene.Warnings.Any() || scene.AgeContinuityWarnings.Any())
{
<ul class="writing-context-list">
@foreach (var warning in scene.Warnings.Concat(scene.AgeContinuityWarnings).Take(4))
{
<li><strong>@warning.SeverityName</strong> @warning.Message</li>
}
</ul>
}
else
{
<p>No continuity warnings.</p>
}
</article>
</div>

View File

@ -0,0 +1,53 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
var povCharacterId = scene.POVCharacterID;
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
@if (!scene.SceneCharacters.Any())
{
<section class="writing-context-empty">
<h3>No characters recorded for this scene</h3>
<p>When scene participants are added, their current state will appear here.</p>
</section>
}
else
{
<div class="writing-context-people-grid">
@foreach (var character in scene.SceneCharacters.OrderByDescending(x => x.CharacterID == povCharacterId).ThenBy(x => x.CharacterName))
{
var knowledge = scene.CharacterKnowledge.Where(x => x.CharacterID == character.CharacterID).ToList();
var attributes = scene.CharacterAttributeEvents.Where(x => x.CharacterID == character.CharacterID).ToList();
<article class="writing-context-card writing-context-person-card">
<div class="writing-context-person-heading">
<partial name="~/Views/Shared/_Avatar.cshtml" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.CharacterImagePath, ThumbnailPath = character.CharacterThumbnailPath, Size = "md" })" />
<div>
<h3>@character.CharacterName</h3>
<p>@(character.CharacterID == povCharacterId ? "POV" : character.RoleInSceneTypeName ?? character.PresenceTypeName ?? "In scene")</p>
</div>
</div>
<dl class="writing-context-facts">
<dt>Location</dt><dd>@TextOr(character.LocationPath ?? character.LocationName, "Unknown")</dd>
<dt>Emotional state</dt><dd>@TextOr(character.EmotionalState, "Not recorded")</dd>
<dt>Physical condition</dt><dd>@TextOr(character.PhysicalCondition, "Not recorded")</dd>
<dt>Outfit</dt><dd>@TextOr(character.OutfitDescription ?? character.AppearanceNotes, "Not recorded")</dd>
<dt>Movement</dt><dd>@TextOr(string.Join(" -> ", new[] { character.EntryLocationName, character.ExitLocationName }.Where(x => !string.IsNullOrWhiteSpace(x))), "No entry or exit recorded")</dd>
</dl>
@if (knowledge.Any() || attributes.Any())
{
<div class="writing-context-mini-section">
@foreach (var item in knowledge.Take(3))
{
<p><strong>@item.KnowledgeStateName:</strong> @TextOr(item.Description, item.AssetName ?? item.ThreadTitle ?? "Knowledge change recorded.")</p>
}
@foreach (var item in attributes.Take(3))
{
<p><strong>@item.AttributeName:</strong> @item.AttributeValue</p>
}
</div>
}
</article>
}
</div>
}

View File

@ -0,0 +1,95 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
var floorPlan = Model.FloorPlan;
var floorId = scene.InitialFloorPlanFloorID
?? floorPlan?.OccupancyScenes.FirstOrDefault(x => x.SceneID == scene.SceneID)?.InitialFloorPlanFloorID
?? floorPlan?.Floors.OrderBy(x => x.SortOrder).FirstOrDefault()?.FloorPlanFloorID;
var floor = floorPlan?.Floors.FirstOrDefault(x => x.FloorPlanFloorID == floorId);
var blocks = floor is null ? [] : floorPlan!.Blocks.Where(x => x.FloorPlanFloorID == floor.FloorPlanFloorID).ToList();
var tokens = floor is null ? [] : floorPlan!.OccupancyTokens.Where(x => x.SceneID == scene.SceneID && x.FloorPlanFloorID == floor.FloorPlanFloorID).ToList();
var tokensByLocation = tokens.GroupBy(x => x.LocationID).ToDictionary(x => x.Key, x => x.ToList());
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid writing-context-grid--place">
<article class="writing-context-card">
<p class="eyebrow">Location</p>
<h3>@TextOr(scene.LocationOptions.FirstOrDefault(x => x.Value == scene.PrimaryLocationID?.ToString())?.Text, "No primary location")</h3>
<dl class="writing-context-facts">
<dt>Floor plan</dt><dd>@(floorPlan?.FloorPlan.Name ?? "No floor plan linked to this scene")</dd>
<dt>Floor</dt><dd>@(floor?.Name ?? "No floor selected")</dd>
</dl>
</article>
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Occupancy</p>
@if (floorPlan is null)
{
<p>No floor plan linked to this scene.</p>
}
else if (floor is null || !blocks.Any())
{
<p>No placed rooms are available for this floor.</p>
}
else
{
<div class="writing-context-floor"
style="--floor-cols:@floor.GridWidth;--floor-rows:@floor.GridHeight;"
aria-label="@floor.Name floor plan">
@foreach (var block in blocks)
{
var locationTokens = block.LocationID.HasValue && tokensByLocation.TryGetValue(block.LocationID.Value, out var blockTokens)
? blockTokens
: [];
<div class="writing-context-floor-block"
style="grid-column:@(block.X + 1) / span @block.WidthCells;grid-row:@(block.Y + 1) / span @block.HeightCells;">
<strong>@block.DisplayLabel</strong>
@if (locationTokens.Any())
{
<span>@string.Join(", ", locationTokens.Select(x => x.DisplayName).Take(3))@(locationTokens.Count > 3 ? $" +{locationTokens.Count - 3}" : string.Empty)</span>
}
</div>
}
</div>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Characters</p>
@if (!scene.SceneCharacters.Any(x => !string.IsNullOrWhiteSpace(x.LocationName) || !string.IsNullOrWhiteSpace(x.LocationPath)))
{
<p>No character locations recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var character in scene.SceneCharacters.Where(x => !string.IsNullOrWhiteSpace(x.LocationName) || !string.IsNullOrWhiteSpace(x.LocationPath)))
{
<li><strong>@character.CharacterName</strong> @TextOr(character.LocationPath ?? character.LocationName, "Unknown")</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Assets</p>
@if (!scene.SceneAssetLocations.Any() && !scene.AssetCustodyEvents.Any())
{
<p>No asset locations or custody changes recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var asset in scene.SceneAssetLocations.Take(6))
{
<li><strong>@asset.AssetName</strong> @asset.LocationPath</li>
}
@foreach (var custody in scene.AssetCustodyEvents.Take(6))
{
<li><strong>@custody.AssetName</strong> @TextOr(custody.CustodianSummary, custody.CustodyEventTypeName)</li>
}
</ul>
}
</article>
</div>

View File

@ -0,0 +1,58 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
var sceneCharacterIds = scene.SceneCharacters.Select(x => x.CharacterID).ToHashSet();
var relationshipEvents = scene.RelationshipEvents;
var relationships = (Model.RelationshipMap?.Relationships ?? [])
.Where(x => !sceneCharacterIds.Any() || sceneCharacterIds.Contains(x.CharacterAID) || sceneCharacterIds.Contains(x.CharacterBID))
.ToList();
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card">
<p class="eyebrow">Changes In This Scene</p>
@if (!relationshipEvents.Any())
{
<p>No relationship changes recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in relationshipEvents)
{
<li>
<strong>@item.CharacterAName - @item.CharacterBName</strong>
<span>@item.RelationshipStateName@(item.Intensity.HasValue ? $" / intensity {item.Intensity}" : string.Empty)</span>
<p>@TextOr(item.Description, item.RelationshipTypeName)</p>
</li>
}
</ul>
}
</article>
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">State At This Point</p>
@if (!relationships.Any())
{
<p>No visible relationships at this point.</p>
}
else
{
<div class="writing-context-relationship-list">
@foreach (var relationship in relationships.Take(12))
{
<div>
<strong>@relationship.CharacterAName - @relationship.CharacterBName</strong>
<span>@relationship.CurrentStateName</span>
<span class="status-pill @relationship.CssClass">@relationship.RelationshipCategoryName</span>
@if (relationship.CurrentIntensity.HasValue)
{
<small>Intensity @relationship.CurrentIntensity</small>
}
</div>
}
</div>
}
</article>
</div>

View File

@ -0,0 +1,96 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card">
<p class="eyebrow">Threads</p>
@if (!scene.ThreadEvents.Any())
{
<p>No story thread events recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in scene.ThreadEvents)
{
<li><strong>@item.PlotLineName / @item.ThreadTitle</strong> @TextOr(item.EventTitle, item.EventTypeName)</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Knowledge</p>
@if (!scene.CharacterKnowledge.Any())
{
<p>No knowledge changes recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in scene.CharacterKnowledge)
{
<li><strong>@item.CharacterName</strong> @item.KnowledgeStateName: @TextOr(item.Description, item.AssetName ?? item.ThreadTitle ?? "Knowledge change recorded.")</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Dependencies</p>
@if (!scene.DependenciesThisSceneNeeds.Any() && !scene.DependenciesNeedingThisScene.Any())
{
<p>No dependencies recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in scene.DependenciesThisSceneNeeds.Concat(scene.DependenciesNeedingThisScene).Take(8))
{
<li><strong>@item.SceneDependencyTypeName</strong> @TextOr(item.Description, "Scene dependency recorded.")</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Assets</p>
@if (!scene.AssetEvents.Any() && !scene.AssetCustodyEvents.Any())
{
<p>No important asset events recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in scene.AssetEvents.Take(6))
{
<li><strong>@item.AssetName</strong> @TextOr(item.EventTitle, item.AssetEventTypeName)</li>
}
@foreach (var item in scene.AssetCustodyEvents.Take(6))
{
<li><strong>@item.AssetName</strong> @TextOr(item.CustodianSummary, item.CustodyEventTypeName)</li>
}
</ul>
}
</article>
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Continuity</p>
@if (!scene.Warnings.Any() && !scene.AgeContinuityWarnings.Any())
{
<p>No continuity warnings.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var warning in scene.Warnings.Concat(scene.AgeContinuityWarnings))
{
<li><strong>@warning.SeverityName</strong> @warning.Message</li>
}
</ul>
}
</article>
</div>

View File

@ -3,6 +3,290 @@ html {
min-height: 100%;
}
.writing-context-page {
min-height: calc(100vh - 8rem);
color: var(--plotline-ink);
}
.writing-context-shell-header,
.writing-context-scene-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 1rem;
}
.writing-context-shell-header h1,
.writing-context-scene-header h2 {
margin: 0;
}
.writing-context-status {
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .4rem .65rem;
border: 1px solid var(--plotline-line);
border-radius: 999px;
background: var(--plotline-panel);
color: var(--plotline-muted);
font-size: .9rem;
white-space: nowrap;
}
.writing-context-status-dot {
width: .55rem;
height: .55rem;
border-radius: 50%;
background: #2f8f63;
box-shadow: 0 0 0 .18rem rgba(47, 143, 99, .15);
}
.writing-context-empty {
min-height: 42vh;
display: grid;
align-content: center;
justify-items: start;
padding: 3rem;
border: 1px solid var(--plotline-line);
border-radius: .5rem;
background: var(--plotline-panel);
}
.writing-context-scene-header {
padding: 1rem 0 1.2rem;
border-bottom: 1px solid var(--plotline-line);
}
.writing-context-scene-header p {
margin: .35rem 0 0;
color: var(--plotline-muted);
}
.writing-context-scene-actions {
display: grid;
grid-template-columns: minmax(18rem, 28rem) auto;
gap: .5rem;
align-items: end;
}
.writing-context-scene-actions label {
grid-column: 1 / -1;
margin: 0;
color: var(--plotline-muted);
font-size: .82rem;
font-weight: 700;
}
.writing-context-tabs {
display: flex;
flex-wrap: wrap;
gap: .35rem;
margin: 1rem 0;
}
.writing-context-tabs button {
border: 1px solid var(--plotline-line);
border-radius: .4rem;
background: var(--plotline-panel);
color: var(--plotline-ink);
padding: .55rem .85rem;
font-weight: 700;
}
.writing-context-tabs button.active {
background: var(--plotline-accent);
border-color: var(--plotline-accent);
color: #fff;
}
.writing-context-tab-panel[hidden] {
display: none;
}
.writing-context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.writing-context-grid--overview {
grid-template-columns: minmax(0, 1.35fr) minmax(20rem, .65fr);
}
.writing-context-grid--place {
grid-template-columns: minmax(18rem, .7fr) minmax(0, 1.3fr);
}
.writing-context-card {
min-width: 0;
padding: 1rem;
border: 1px solid var(--plotline-line);
border-radius: .5rem;
background: var(--plotline-panel);
}
.writing-context-card--hero,
.writing-context-card--wide {
grid-column: span 2;
}
.writing-context-card h3 {
margin: 0 0 .65rem;
font-size: 1.2rem;
}
.writing-context-facts {
display: grid;
grid-template-columns: minmax(7rem, .45fr) minmax(0, 1fr);
gap: .45rem .75rem;
margin: 0;
}
.writing-context-facts dt {
color: var(--plotline-muted);
font-weight: 700;
}
.writing-context-facts dd {
margin: 0;
}
.writing-context-chip-row {
display: flex;
flex-wrap: wrap;
gap: .4rem;
margin-bottom: .65rem;
}
.writing-context-chip-row span,
.writing-context-relationship-list span {
display: inline-flex;
align-items: center;
border: 1px solid var(--plotline-line);
border-radius: 999px;
padding: .2rem .55rem;
background: var(--plotline-paper);
color: var(--plotline-muted);
font-size: .82rem;
}
.writing-context-list {
display: grid;
gap: .7rem;
padding: 0;
margin: 0;
list-style: none;
}
.writing-context-list li {
min-width: 0;
}
.writing-context-list p {
margin: .2rem 0 0;
color: var(--plotline-muted);
}
.writing-context-people-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1rem;
}
.writing-context-person-heading {
display: flex;
align-items: center;
gap: .75rem;
margin-bottom: .9rem;
}
.writing-context-person-heading h3,
.writing-context-person-heading p {
margin: 0;
}
.writing-context-person-heading p,
.writing-context-mini-section {
color: var(--plotline-muted);
}
.writing-context-mini-section {
margin-top: .8rem;
padding-top: .8rem;
border-top: 1px solid var(--plotline-line);
}
.writing-context-mini-section p {
margin: 0 0 .35rem;
}
.writing-context-relationship-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: .7rem;
}
.writing-context-relationship-list > div {
display: grid;
gap: .35rem;
padding: .75rem;
border: 1px solid var(--plotline-line);
border-radius: .4rem;
background: var(--plotline-paper);
}
.writing-context-floor {
display: grid;
grid-template-columns: repeat(var(--floor-cols), minmax(0, 1fr));
grid-template-rows: repeat(var(--floor-rows), minmax(1.2rem, 1fr));
gap: .18rem;
min-height: 34rem;
padding: .5rem;
border: 1px solid var(--plotline-line);
border-radius: .45rem;
background: color-mix(in srgb, var(--plotline-paper) 88%, var(--plotline-line));
overflow: auto;
}
.writing-context-floor-block {
display: grid;
align-content: start;
gap: .2rem;
min-width: 0;
min-height: 2.2rem;
padding: .35rem;
border: 1px solid color-mix(in srgb, var(--plotline-accent) 35%, var(--plotline-line));
border-radius: .3rem;
background: color-mix(in srgb, var(--plotline-panel) 92%, var(--plotline-accent));
font-size: .82rem;
}
.writing-context-floor-block span {
color: var(--plotline-muted);
overflow-wrap: anywhere;
}
@media (max-width: 900px) {
.writing-context-shell-header,
.writing-context-scene-header {
display: grid;
}
.writing-context-scene-actions,
.writing-context-grid,
.writing-context-grid--overview,
.writing-context-grid--place {
grid-template-columns: 1fr;
}
.writing-context-card--hero,
.writing-context-card--wide {
grid-column: auto;
}
}
@media (min-width: 768px) {
html {
font-size: 16px;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,165 @@
(() => {
const root = document.querySelector("[data-writing-context]");
if (!root) {
return;
}
const contentHost = root.querySelector("[data-writing-context-content]");
const statusText = root.querySelector("[data-writing-context-status-text]");
const endpoint = root.dataset.contextUrl || "/Writing/Context/Scene";
const tabStorageKey = "plotdirector.writingContext.activeTab";
const scopedProjectId = Number.parseInt(root.dataset.scopeProjectId || "", 10);
const scopedBookId = Number.parseInt(root.dataset.scopeBookId || "", 10);
let currentSceneId = Number.parseInt(contentHost?.querySelector("[data-writing-context-fragment]")?.dataset.sceneId || "", 10);
let loadingSceneId = null;
const setStatus = (value) => {
if (statusText && value) {
statusText.textContent = value;
}
};
const activeTab = () => {
const active = contentHost?.querySelector("[data-writing-context-tab].active")?.dataset.writingContextTab;
if (active) {
return active;
}
try {
return localStorage.getItem(tabStorageKey) || "overview";
} catch {
return "overview";
}
};
const activateTab = (tabName) => {
const nextTab = tabName || "overview";
contentHost?.querySelectorAll("[data-writing-context-tab]").forEach((button) => {
const active = button.dataset.writingContextTab === nextTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
contentHost?.querySelectorAll("[data-writing-context-panel]").forEach((panel) => {
const active = panel.dataset.writingContextPanel === nextTab;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
try {
localStorage.setItem(tabStorageKey, nextTab);
} catch {
// Remembering the tab is a convenience only.
}
};
const wireFragment = () => {
activateTab(activeTab());
const fragment = contentHost?.querySelector("[data-writing-context-fragment]");
const fragmentStatus = fragment?.dataset.followStatus;
if (fragmentStatus) {
setStatus(fragmentStatus);
}
const fragmentSceneId = Number.parseInt(fragment?.dataset.sceneId || "", 10);
if (Number.isInteger(fragmentSceneId) && fragmentSceneId > 0) {
currentSceneId = fragmentSceneId;
}
contentHost?.querySelectorAll("[data-writing-context-tab]").forEach((button) => {
button.addEventListener("click", () => activateTab(button.dataset.writingContextTab));
});
contentHost?.querySelector("[data-writing-context-scene-select]")?.addEventListener("change", (event) => {
const sceneId = Number.parseInt(event.target.value || "", 10);
if (!Number.isInteger(sceneId) || sceneId <= 0) {
return;
}
const fragment = contentHost?.querySelector("[data-writing-context-fragment]");
window.PlotDirectorStoryPosition?.publishChanged({
source: "WritingContext",
projectId: fragment?.dataset.projectId,
bookId: fragment?.dataset.bookId,
chapterId: fragment?.dataset.chapterId,
sceneId
}, "WritingContext");
});
};
const inScope = (position) => {
if (Number.isInteger(scopedProjectId) && scopedProjectId > 0 && position.projectId !== scopedProjectId) {
return false;
}
if (Number.isInteger(scopedBookId) && scopedBookId > 0 && position.bookId && position.bookId !== scopedBookId) {
return false;
}
return true;
};
const loadPosition = async (position) => {
if (!position || !Number.isInteger(position.sceneId) || position.sceneId <= 0 || !inScope(position)) {
return;
}
if (position.sceneId === currentSceneId || position.sceneId === loadingSceneId) {
return;
}
const tab = activeTab();
loadingSceneId = position.sceneId;
setStatus("Updating");
const url = new URL(endpoint, window.location.origin);
if (position.projectId) url.searchParams.set("projectId", position.projectId);
if (position.bookId) url.searchParams.set("bookId", position.bookId);
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
url.searchParams.set("sceneId", position.sceneId);
try {
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
if (!response.ok) {
throw new Error(`Context request failed: ${response.status}`);
}
contentHost.innerHTML = await response.text();
currentSceneId = position.sceneId;
wireFragment();
activateTab(tab);
} catch (error) {
console.debug("Writing Context could not update.", error);
setStatus("Connected, waiting for scene");
} finally {
loadingSceneId = null;
}
};
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
void loadPosition(event.detail);
});
if (window.signalR) {
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
connection.on("storyPositionChanged", (payload) => {
window.PlotDirectorStoryPosition?.publishChanged(payload, payload?.source || payload?.Source || "WordCompanion");
});
connection.start()
.then(() => {
if (!currentSceneId) {
setStatus("Connected, waiting for scene");
}
})
.catch((error) => {
console.debug("Writing Context follow connection unavailable.", error);
setStatus("Word not connected");
});
} else {
setStatus("Word not connected");
}
wireFragment();
})();