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.Http;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
|
using PlotLine.Services;
|
||||||
|
|
||||||
namespace PlotLine.ViewModels;
|
namespace PlotLine.ViewModels;
|
||||||
|
|
||||||
@ -428,6 +429,25 @@ public sealed class SceneEditViewModel
|
|||||||
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; set; } = [];
|
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 sealed class SceneDetailsSaveViewModel
|
||||||
{
|
{
|
||||||
public int SceneID { get; set; }
|
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%;
|
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) {
|
@media (min-width: 768px) {
|
||||||
html {
|
html {
|
||||||
font-size: 16px;
|
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