PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml

248 lines
16 KiB
Plaintext

@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review relationships";
}
<section class="onboarding-shell" aria-labelledby="story-relationship-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Relationships" })" />
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 5 of 5</p>
<h1 id="story-relationship-title">Review Relationships</h1>
<p>Approve the character relationships PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
<section class="story-review-chapter-list" aria-label="Relationship review">
@if (Model.RelationshipReview.Candidates.Count == 0)
{
<div class="story-review-note">
<strong>No relationship decisions are waiting.</strong>
<p class="mb-0">Relationships are already up to date, or there were no relationship suggestions ready to import from the stored scene analysis.</p>
</div>
<div class="story-future-stage-grid">
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
@FutureStage("Timeline Events")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
<form asp-action="ImportStoryIntelligenceRelationships" method="post">
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Continue</button>
</form>
</div>
}
else
{
<div class="story-review-card-actions">
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="collapse">Collapse all</button>
</div>
<form asp-action="ImportStoryIntelligenceRelationships" method="post" data-relationship-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.RelationshipReview.Candidates.Count; i++)
{
var candidate = Model.RelationshipReview.Candidates[i];
<details class="story-character-card" open data-relationship-card>
<summary>
<span>
<strong>@candidate.CharacterAName</strong>
<small>@candidate.RelationshipType / @candidate.CharacterBName - @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span>
<em>@candidate.Confidence</em>
</summary>
<input type="hidden" name="Relationships[@i].Key" value="@candidate.Key" />
<div class="story-character-card__body" data-relationship-key="@candidate.Key">
<dl>
<div><dt>Character A</dt><dd>@candidate.CharacterAName</dd></div>
<div><dt>Character B</dt><dd>@candidate.CharacterBName</dd></div>
<div><dt>First seen</dt><dd>@Display(candidate.FirstAppearance)</dd></div>
<div><dt>Last seen</dt><dd>@Display(candidate.LastAppearance)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingRelationshipLabel ?? "None")</dd></div>
</dl>
@if (!string.IsNullOrWhiteSpace(candidate.ExampleScene))
{
<div class="story-review-note">
<strong>Example scene</strong>
<p>@candidate.ExampleScene</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
<div class="story-review-note">
<strong>Supporting context</strong>
<p>@candidate.ExampleContext</p>
</div>
}
@if (candidate.IsExistingMatch)
{
<div class="story-review-note">
<strong>Possible existing relationship</strong>
<p>@candidate.CharacterAName and @candidate.CharacterBName may already have a relationship in PlotDirector. Choose whether to link them or create a separate relationship.</p>
</div>
}
<fieldset class="story-character-actions">
<legend>Decision</legend>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.CreateNew" checked data-relationship-action />
Create new relationship
</label>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.LinkExisting" data-relationship-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
Link existing relationship
</label>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Alias" data-relationship-action />
Merge with another relationship
</label>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Ignore" data-relationship-action />
Ignore
</label>
</fieldset>
<div data-relationship-type-panel>
<label class="form-label" for="relationship-type-@i">Relationship type</label>
<select id="relationship-type-@i" class="form-select" name="Relationships[@i].RelationshipTypeID">
@foreach (var option in Model.RelationshipReview.RelationshipTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == candidate.RelationshipTypeID.ToString())">@option.Text</option>
}
</select>
<div class="story-review-note mt-2">
<strong>Story Intelligence observation</strong>
<p>@candidate.RelationshipType</p>
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
<strong>Evidence</strong>
<p>@candidate.ExampleContext</p>
}
</div>
</div>
<div data-relationship-alias-panel hidden>
<label class="form-label" for="relationship-alias-target-@i">Merge target</label>
<select id="relationship-alias-target-@i" class="form-select" name="Relationships[@i].AliasTargetKey" data-relationship-alias-target>
<option value="">Choose relationship...</option>
@foreach (var target in Model.RelationshipReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.CharacterAName / @target.RelationshipType / @target.CharacterBName@(target.IsExistingMatch ? $" -> {target.ExistingRelationshipLabel}" : string.Empty)</option>
}
</select>
</div>
</div>
</details>
}
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
<button class="btn btn-primary" type="submit">Create relationships</button>
</div>
</form>
}
</section>
</div>
</section>
@section Scripts {
<script>
(() => {
const form = document.querySelector("[data-relationship-review-form]");
if (!form) return;
const cards = () => Array.from(form.querySelectorAll("[data-relationship-card]"));
const setAction = (card, action) => {
const input = card.querySelector(`[data-relationship-action][value="${action}"]`);
if (input) {
input.checked = true;
updateCard(card);
}
};
const actionFor = (card) => card.querySelector("[data-relationship-action]:checked")?.value || "@StoryIntelligenceRelationshipImportActions.CreateNew";
const isCanonicalTarget = (card) => {
const action = actionFor(card);
return action !== "@StoryIntelligenceRelationshipImportActions.Ignore" && action !== "@StoryIntelligenceRelationshipImportActions.Alias";
};
const updateAliasTargets = () => {
const allCards = cards();
for (const card of allCards) {
const body = card.querySelector("[data-relationship-key]");
const ownKey = body?.getAttribute("data-relationship-key") || "";
const select = card.querySelector("[data-relationship-alias-target]");
if (!select) continue;
const previous = select.value;
for (const option of Array.from(select.options)) {
if (!option.value) continue;
const targetCard = allCards.find(item => item.querySelector("[data-relationship-key]")?.getAttribute("data-relationship-key") === option.value);
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
}
if (select.selectedOptions[0]?.disabled) {
select.value = "";
} else {
select.value = previous;
}
}
};
const updateCard = (card) => {
const action = actionFor(card);
const typePanel = card.querySelector("[data-relationship-type-panel]");
const aliasPanel = card.querySelector("[data-relationship-alias-panel]");
if (typePanel) typePanel.hidden = action === "@StoryIntelligenceRelationshipImportActions.Ignore" || action === "@StoryIntelligenceRelationshipImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceRelationshipImportActions.Alias";
updateAliasTargets();
};
document.querySelectorAll("[data-relationship-bulk]").forEach(button => {
button.addEventListener("click", () => {
const action = button.getAttribute("data-relationship-bulk");
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceRelationshipImportActions.CreateNew"));
if (action === "link-existing") cards().forEach(card => {
if (card.querySelector(`[data-relationship-action][value="@StoryIntelligenceRelationshipImportActions.LinkExisting"]:not(:disabled)`)) {
setAction(card, "@StoryIntelligenceRelationshipImportActions.LinkExisting");
}
});
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceRelationshipImportActions.Ignore"));
if (action === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false);
});
});
form.querySelectorAll("[data-relationship-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-relationship-card]")));
});
cards().forEach(updateCard);
})();
</script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
}