244 lines
17 KiB
Plaintext
244 lines
17 KiB
Plaintext
@model StoryIntelligenceProgressViewModel
|
||
@{
|
||
ViewData["Title"] = "Review relationships";
|
||
const int previewLimit = 25;
|
||
var relationshipCandidates = Model.RelationshipReview.Candidates.Take(previewLimit).ToList();
|
||
var hiddenCandidateCount = Math.Max(0, Model.RelationshipReview.PendingCandidateCount - relationshipCandidates.Count);
|
||
}
|
||
|
||
<section class="onboarding-shell" aria-labelledby="story-relationship-title">
|
||
<div class="onboarding-panel onboarding-review-panel story-review-page">
|
||
<div class="onboarding-copy story-review-heading">
|
||
<p class="eyebrow">Story Intelligence</p>
|
||
<h1 id="story-relationship-title">Review Relationships</h1>
|
||
<p>Review meaningful relationship facts, states and developments scene by scene. Direction runs from Character A to Character B unless marked reciprocal. Accepted proposals appear in the existing scene relationship timeline.</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>
|
||
}
|
||
|
||
<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="onboarding-actions">
|
||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||
<form asp-action="ImportStoryIntelligenceRelationships" method="post" data-story-review-form>
|
||
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
||
<button class="btn btn-primary" type="submit" data-busy-label="Saving relationships...">Mark reviewed</button>
|
||
</form>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="story-review-card-actions">
|
||
@if (hiddenCandidateCount > 0)
|
||
{
|
||
<span class="story-review-page-status">Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.PendingCandidateCount.ToString("N0") pending</span>
|
||
}
|
||
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Accept all on this page</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 data-story-review-form>
|
||
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
||
<div class="story-character-card-grid">
|
||
@for (var i = 0; i < relationshipCandidates.Count; i++)
|
||
{
|
||
var candidate = relationshipCandidates[i];
|
||
@if (candidate.FirstChapterNumber.HasValue && (i == 0 || candidate.FirstChapterNumber != relationshipCandidates[i - 1].FirstChapterNumber))
|
||
{
|
||
<h2 class="h5 mt-3">Chapter @candidate.FirstChapterNumber.Value.ToString("0.##")</h2>
|
||
}
|
||
<details class="story-character-card" open data-relationship-card>
|
||
<summary class="story-review-card-header">
|
||
<span>
|
||
<small>@Display(candidate.FirstAppearance)</small>
|
||
<strong>@candidate.CharacterAName / @candidate.CharacterBName</strong>
|
||
<small>@candidate.ProposalKind — @candidate.RelationshipState</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>Direction</dt><dd>@candidate.CharacterAName @(candidate.IsReciprocal ? "↔" : "→") @candidate.CharacterBName</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>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.NeedsDirectionReview)
|
||
{
|
||
<div class="story-review-note">This stored analysis predates explicit direction. Check which character feels or acts towards the other and whether the state is mutual.</div>
|
||
}
|
||
<fieldset class="story-character-actions">
|
||
<legend>Decision for this scene proposal</legend>
|
||
<label><input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.CreateNew" checked data-relationship-action /> Accept development / fact</label>
|
||
<label><input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Ignore" data-relationship-action /> Ignore this proposal</label>
|
||
</fieldset>
|
||
|
||
<div data-relationship-type-panel>
|
||
<label class="form-label" for="relationship-type-@i">@(candidate.ProposalKind == "Factual" ? "Factual relationship type" : "Relationship track (Other allows changing states)")</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>
|
||
<label class="form-label mt-2" for="relationship-state-@i">State in this scene</label>
|
||
<select id="relationship-state-@i" class="form-select" name="Relationships[@i].RelationshipStateID">
|
||
@foreach (var option in Model.RelationshipReview.RelationshipStateOptions)
|
||
{
|
||
<option value="@option.Value" selected="@(option.Value == candidate.RelationshipStateID.ToString())">@option.Text</option>
|
||
}
|
||
</select>
|
||
<label class="form-label mt-2" for="relationship-direction-@i">Direction</label>
|
||
<select id="relationship-direction-@i" class="form-select" name="Relationships[@i].IsReciprocal">
|
||
<option value="false" selected="@(!candidate.IsReciprocal)">@candidate.CharacterAName → @candidate.CharacterBName</option>
|
||
<option value="true" selected="@candidate.IsReciprocal">Mutual / reciprocal</option>
|
||
</select>
|
||
<label class="mt-2"><input type="checkbox" name="Relationships[@i].ReverseDirection" value="true" /> Reverse A and B (also reverses who knows)</label>
|
||
<label class="form-label mt-2" for="relationship-known-@i">Does @candidate.CharacterBName know this feeling / state?</label>
|
||
<select id="relationship-known-@i" class="form-select" name="Relationships[@i].IsKnownToOtherCharacter">
|
||
<option value="false" selected="@(!candidate.IsKnownToOtherCharacter)">No / not established</option>
|
||
<option value="true" selected="@candidate.IsKnownToOtherCharacter">Yes</option>
|
||
</select>
|
||
<label class="form-label mt-2" for="relationship-intensity-@i">Emotional intensity (1–10; blank if unknown)</label>
|
||
<input id="relationship-intensity-@i" class="form-control" type="number" min="1" max="10" name="Relationships[@i].Intensity" value="@candidate.Intensity" />
|
||
<label class="form-label mt-2" for="relationship-development-@i">Development / factual discovery</label>
|
||
<textarea id="relationship-development-@i" class="form-control" name="Relationships[@i].Development" rows="3">@candidate.Development</textarea>
|
||
<p class="text-muted">Acceptance reuses a matching factual or directional relationship and adds this scene event. Existing history is preserved.</p>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
}
|
||
</div>
|
||
|
||
<div class="onboarding-actions">
|
||
@if (hiddenCandidateCount > 0)
|
||
{
|
||
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more relationship decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
|
||
}
|
||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||
@if (Model.RelationshipReview.HasPreviousPage)
|
||
{
|
||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.RelationshipReview.PageNumber - 1)">Previous</a>
|
||
}
|
||
@if (Model.RelationshipReview.HasNextPage)
|
||
{
|
||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.RelationshipReview.PageNumber + 1)">Next</a>
|
||
}
|
||
<button class="btn btn-primary" type="submit" data-busy-label="Saving relationships...">Save relationship decisions</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>
|
||
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
|
||
}
|
||
|
||
@functions {
|
||
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
|
||
}
|