Add writing context route and live display
This commit is contained in:
parent
90307757b4
commit
bba75f3ac4
99
PlotLine/Controllers/WritingController.cs
Normal file
99
PlotLine/Controllers/WritingController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
30
PlotLine/Views/Writing/Context.cshtml
Normal file
30
PlotLine/Views/Writing/Context.cshtml
Normal 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>
|
||||
}
|
||||
74
PlotLine/Views/Writing/_WritingContextContent.cshtml
Normal file
74
PlotLine/Views/Writing/_WritingContextContent.cshtml
Normal 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>
|
||||
74
PlotLine/Views/Writing/_WritingContextOverview.cshtml
Normal file
74
PlotLine/Views/Writing/_WritingContextOverview.cshtml
Normal 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>
|
||||
53
PlotLine/Views/Writing/_WritingContextPeople.cshtml
Normal file
53
PlotLine/Views/Writing/_WritingContextPeople.cshtml
Normal 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>
|
||||
}
|
||||
95
PlotLine/Views/Writing/_WritingContextPlace.cshtml
Normal file
95
PlotLine/Views/Writing/_WritingContextPlace.cshtml
Normal 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>
|
||||
58
PlotLine/Views/Writing/_WritingContextRelationships.cshtml
Normal file
58
PlotLine/Views/Writing/_WritingContextRelationships.cshtml
Normal 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>
|
||||
96
PlotLine/Views/Writing/_WritingContextStory.cshtml
Normal file
96
PlotLine/Views/Writing/_WritingContextStory.cshtml
Normal 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>
|
||||
@ -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;
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
165
PlotLine/wwwroot/js/writing-context.js
Normal file
165
PlotLine/wwwroot/js/writing-context.js
Normal 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();
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user