329 lines
24 KiB
Plaintext
329 lines
24 KiB
Plaintext
@model StoryIntelligenceProgressViewModel
|
|
@{
|
|
ViewData["Title"] = "Review locations";
|
|
var locationCandidates = Model.LocationReview.Candidates.ToList();
|
|
}
|
|
|
|
<section class="onboarding-shell" aria-labelledby="story-location-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-location-title">Review Locations <help-icon key="storyIntelligence.locations" /></h1>
|
|
<p>Explore important story settings first. Resolve uncertain identities and use the full list to inspect or organise every detected place.</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>
|
|
}
|
|
|
|
<nav class="onboarding-actions mb-3" aria-label="Location filters">
|
|
<a class="btn @(Model.LocationReview.Scope == "significant" ? "btn-primary" : "btn-outline-secondary")" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="significant">Significant locations (@Model.LocationReview.SignificantCount)</a>
|
|
<a class="btn @(Model.LocationReview.Scope == "all" ? "btn-primary" : "btn-outline-secondary")" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="all">All detected locations (@Model.LocationReview.TotalCandidateCount)</a>
|
|
<a class="btn @(Model.LocationReview.Scope == "uncertain" ? "btn-primary" : "btn-outline-secondary")" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="uncertain">Need review (@Model.LocationReview.UncertainCount)</a>
|
|
<help-icon key="storyIntelligence.locations.filters" mode="inline" />
|
|
</nav>
|
|
<p class="text-muted">Parent locations are included for context. Confidence describes identification; significance describes story importance.</p>
|
|
<section class="story-review-chapter-list" aria-label="Location review">
|
|
@if (Model.LocationReview.Candidates.Count == 0)
|
|
{
|
|
<div class="story-review-note">
|
|
<strong>No locations match this filter.</strong>
|
|
<p class="mb-0">Choose All detected locations to explore the complete list.</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>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="story-review-card-actions">
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Use exact matches</button>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
|
|
<span class="story-review-page-status">
|
|
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
|
|
· Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.FilteredCount.ToString("N0") locations
|
|
</span>
|
|
</div>
|
|
|
|
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
|
|
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
|
<input type="hidden" name="Scope" value="@Model.LocationReview.Scope" />
|
|
<input type="hidden" name="PageNumber" value="@Model.LocationReview.PageNumber" />
|
|
<div class="story-character-card-grid">
|
|
@for (var i = 0; i < locationCandidates.Count; i++)
|
|
{
|
|
var candidate = locationCandidates[i];
|
|
var status = candidate.IsExistingMatch ? "existing" : "new";
|
|
var proposedParentLabel = candidate.ProposedParentLocationName ?? candidate.ParentLocationHint;
|
|
var proposedParentValue = candidate.ProposedParentLocationID.HasValue
|
|
? $"existing:{candidate.ProposedParentLocationID.Value}"
|
|
: !string.IsNullOrWhiteSpace(candidate.ProposedParentCandidateKey)
|
|
? $"candidate:{candidate.ProposedParentCandidateKey}"
|
|
: string.Empty;
|
|
var searchText = string.Join(" ", new[] { candidate.LocationName, candidate.ExistingLocationName, candidate.Category, candidate.ParentLocationHint, candidate.ProposedParentLocationName });
|
|
<details class="story-character-card" style="margin-inline-start: @(Math.Min(candidate.HierarchyDepth, 4) * 16)px" data-location-card data-location-status="@status" data-location-existing-match="@candidate.IsExistingMatch.ToString().ToLowerInvariant()" data-location-search-text="@searchText">
|
|
<summary class="story-review-card-header">
|
|
<span>
|
|
@if (!string.IsNullOrWhiteSpace(proposedParentLabel)) { <small>Within @proposedParentLabel</small> }
|
|
<strong>@candidate.LocationName</strong>
|
|
<small>@(candidate.Significance?.Classification ?? "Not yet assessed") · @(candidate.IsPending && candidate.NeedsReview ? "Identity needs review" : "")</small>
|
|
<small>@candidate.Category · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
|
|
</span>
|
|
<em>Confidence: @candidate.Confidence</em>
|
|
</summary>
|
|
|
|
|
|
|
|
<div class="story-character-card__body" data-location-key="@candidate.Key" data-location-name="@candidate.LocationName">
|
|
@if (candidate.Significance is { } significance)
|
|
{
|
|
<div class="story-review-note">
|
|
<strong>@significance.Classification · significance @significance.Score/100</strong>
|
|
<ul>@foreach (var reason in significance.Reasons) { <li>@reason</li> }</ul>
|
|
@if (significance.Characters.Any()) { <p>Characters: @string.Join(", ", significance.Characters)</p> }
|
|
@if (significance.PlotLines.Any()) { <p>Plot lines: @string.Join(", ", significance.PlotLines)</p> }
|
|
@foreach (var scene in significance.KeyScenes) {
|
|
<a asp-controller="Scenes" asp-action="Details" asp-route-id="@scene.SceneID">@scene.SceneLabel</a><br />
|
|
}
|
|
</div>
|
|
}
|
|
<dl>
|
|
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
|
|
<div><dt>Proposed parent</dt><dd>@Display(proposedParentLabel)</dd></div>
|
|
<div><dt>Existing match</dt><dd>@(candidate.ExistingLocationName ?? "None")</dd></div>
|
|
</dl>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
|
|
{
|
|
<div class="story-review-note">
|
|
<strong>Scene context</strong>
|
|
<p>@candidate.ExampleContext</p>
|
|
</div>
|
|
}
|
|
|
|
@if (candidate.EvidenceSummaries.Any())
|
|
{
|
|
<details class="story-review-note">
|
|
<summary>Evidence from @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</summary>
|
|
<ul class="mb-0">
|
|
@foreach (var evidence in candidate.EvidenceSummaries)
|
|
{
|
|
<li>@evidence</li>
|
|
}
|
|
</ul>
|
|
</details>
|
|
}
|
|
|
|
@if (candidate.IsPending)
|
|
{
|
|
<input type="hidden" name="Locations.Index" value="@i" />
|
|
<input type="hidden" name="Locations[@i].Key" value="@candidate.Key" />
|
|
@if (candidate.IsExistingMatch)
|
|
{
|
|
<div class="story-review-note">
|
|
<strong>Existing location matched</strong>
|
|
<p>@candidate.LocationName is linked to @candidate.ExistingLocationName. Review only the parent location if needed.</p>
|
|
</div>
|
|
}
|
|
|
|
@if (candidate.IsExistingMatch)
|
|
{
|
|
<input type="hidden" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.LinkExisting" data-location-action />
|
|
<input type="hidden" name="Locations[@i].ExistingLocationID" value="@candidate.ExistingLocationID" />
|
|
}
|
|
else
|
|
{
|
|
<fieldset class="story-character-actions">
|
|
<legend>Decision <help-icon key="storyIntelligence.locations.decisions" mode="inline" /></legend>
|
|
<label>
|
|
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.CreateNew" checked="@(candidate.DefaultAction == StoryIntelligenceLocationImportActions.CreateNew)" data-location-action />
|
|
Create new location
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.LinkExisting" checked="@(candidate.DefaultAction == StoryIntelligenceLocationImportActions.LinkExisting)" data-location-action />
|
|
Same location as
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Ignore" data-location-action />
|
|
Ignore
|
|
</label>
|
|
</fieldset>
|
|
|
|
<div data-location-import-name-panel>
|
|
<label class="form-label" for="location-import-name-@i">Import name <help-icon key="storyIntelligence.review.importName" mode="inline" /></label>
|
|
<input id="location-import-name-@i" class="form-control" name="Locations[@i].ImportName" value="@candidate.ImportName" />
|
|
</div>
|
|
}
|
|
|
|
<div data-location-parent-panel>
|
|
<label class="form-label" for="location-parent-@i">Parent location <help-icon key="storyIntelligence.locations.parent" mode="inline" /></label>
|
|
<select id="location-parent-@i" class="form-select" name="Locations[@i].ParentSelection">
|
|
@if (!string.IsNullOrWhiteSpace(proposedParentLabel))
|
|
{
|
|
<option value="@proposedParentValue" selected>Use proposed: @proposedParentLabel</option>
|
|
}
|
|
@if (string.IsNullOrWhiteSpace(proposedParentLabel))
|
|
{
|
|
<option value="__none__" selected>Not determined</option>
|
|
}
|
|
else
|
|
{
|
|
<option value="__none__">Not determined</option>
|
|
}
|
|
@foreach (var group in Model.LocationReview.LocationTargetOptions
|
|
.Where(target => !IsSelfTarget(target, candidate))
|
|
.GroupBy(target => target.GroupName))
|
|
{
|
|
<optgroup label="@group.Key">
|
|
@foreach (var target in group)
|
|
{
|
|
if (string.Equals(target.Value, proposedParentValue, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
<option value="@target.Value">@TargetLabel(target)</option>
|
|
}
|
|
</optgroup>
|
|
}
|
|
</select>
|
|
</div>
|
|
|
|
@if (!candidate.IsExistingMatch)
|
|
{
|
|
<div data-location-same-panel hidden>
|
|
<label class="form-label" for="location-same-target-@i">Same location as <help-icon key="storyIntelligence.locations.sameLocation" mode="inline" /></label>
|
|
<select id="location-same-target-@i" class="form-select" data-location-unified-target>
|
|
<option value="">Choose location...</option>
|
|
@foreach (var group in Model.LocationReview.LocationTargetOptions
|
|
.Where(target => !IsSelfTarget(target, candidate))
|
|
.GroupBy(target => target.GroupName))
|
|
{
|
|
<optgroup label="@group.Key">
|
|
@foreach (var target in group)
|
|
{
|
|
<option value="@target.Value">@TargetLabel(target)</option>
|
|
}
|
|
</optgroup>
|
|
}
|
|
</select>
|
|
<input type="hidden" name="Locations[@i].ExistingLocationID" data-location-existing-target />
|
|
<input type="hidden" name="Locations[@i].AliasTargetKey" data-location-alias-target />
|
|
</div>
|
|
}
|
|
}
|
|
else { <p class="text-muted">Decision saved: @candidate.DecisionStatus</p> }
|
|
</div>
|
|
</details>
|
|
}
|
|
</div>
|
|
|
|
<div class="onboarding-actions">
|
|
<span class="text-muted">
|
|
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
|
|
· Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.FilteredCount.ToString("N0") locations
|
|
</span>
|
|
@if (Model.LocationReview.HasPreviousPage)
|
|
{
|
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="@Model.LocationReview.Scope" asp-route-page="@(Model.LocationReview.PageNumber - 1)">Previous</a>
|
|
}
|
|
@if (Model.LocationReview.HasNextPage)
|
|
{
|
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="@Model.LocationReview.Scope" asp-route-page="@(Model.LocationReview.PageNumber + 1)">Next</a>
|
|
}
|
|
@if (Model.LocationReview.CanImport)
|
|
{
|
|
<button class="btn btn-primary" type="submit" name="intent" value="save" data-busy-label="Saving locations...">Save location decisions</button>
|
|
}
|
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
|
</div>
|
|
</form>
|
|
}
|
|
</section>
|
|
</div>
|
|
</section>
|
|
|
|
@section Scripts {
|
|
<script>
|
|
(() => {
|
|
const form = document.querySelector("[data-location-review-form]");
|
|
if (!form) return;
|
|
|
|
const cards = () => Array.from(form.querySelectorAll("[data-location-card]"));
|
|
const setAction = (card, action) => {
|
|
const input = card.querySelector(`[data-location-action][value="${action}"]`);
|
|
if (input) {
|
|
input.checked = true;
|
|
updateCard(card);
|
|
}
|
|
};
|
|
const actionFor = (card) => card.querySelector("[data-location-action]:checked")?.value || "@StoryIntelligenceLocationImportActions.CreateNew";
|
|
const applyUnifiedTarget = (card) => {
|
|
const selector = card.querySelector("[data-location-unified-target]");
|
|
const existing = card.querySelector("[data-location-existing-target]");
|
|
const pending = card.querySelector("[data-location-alias-target]");
|
|
const value = selector?.value || "";
|
|
if (existing && selector) existing.value = value.startsWith("existing:") ? value.substring("existing:".length) : "";
|
|
if (pending && selector) pending.value = value.startsWith("candidate:") ? value.substring("candidate:".length) : "";
|
|
};
|
|
const isCanonicalTarget = (card) => {
|
|
applyUnifiedTarget(card);
|
|
const action = actionFor(card);
|
|
const pendingTarget = card.querySelector("[data-location-alias-target]")?.value || "";
|
|
return action !== "@StoryIntelligenceLocationImportActions.Ignore"
|
|
&& !pendingTarget;
|
|
};
|
|
const updateAliasTargets = () => {
|
|
cards().forEach(applyUnifiedTarget);
|
|
};
|
|
const updateCard = (card) => {
|
|
const action = actionFor(card);
|
|
const isExistingMatch = card.getAttribute("data-location-existing-match") === "true";
|
|
const namePanel = card.querySelector("[data-location-import-name-panel]");
|
|
const samePanel = card.querySelector("[data-location-same-panel]");
|
|
applyUnifiedTarget(card);
|
|
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.LinkExisting";
|
|
const parentPanel = card.querySelector("[data-location-parent-panel]");
|
|
if (parentPanel) parentPanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || (action === "@StoryIntelligenceLocationImportActions.LinkExisting" && !isExistingMatch);
|
|
if (samePanel) samePanel.hidden = action !== "@StoryIntelligenceLocationImportActions.LinkExisting";
|
|
updateAliasTargets();
|
|
};
|
|
document.querySelectorAll("[data-location-bulk]").forEach(button => {
|
|
button.addEventListener("click", () => {
|
|
const action = button.getAttribute("data-location-bulk");
|
|
if (action === "link-existing") cards().forEach(card => {
|
|
if (card.querySelector("[data-location-existing-target]")?.value) {
|
|
setAction(card, "@StoryIntelligenceLocationImportActions.LinkExisting");
|
|
}
|
|
});
|
|
if (action === "expand") cards().forEach(card => card.open = true);
|
|
if (action === "collapse") cards().forEach(card => card.open = false);
|
|
});
|
|
});
|
|
form.querySelectorAll("[data-location-action]").forEach(input => {
|
|
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
|
|
});
|
|
form.querySelectorAll("[data-location-unified-target]").forEach(select => {
|
|
select.addEventListener("change", () => cards().forEach(updateCard));
|
|
});
|
|
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;
|
|
private static string TargetLabel(StoryIntelligenceLocationTargetOptionViewModel target)
|
|
=> string.IsNullOrWhiteSpace(target.AliasSummary) ? target.DisplayName : $"{target.DisplayName} ({target.AliasSummary})";
|
|
|
|
private static bool IsSelfTarget(StoryIntelligenceLocationTargetOptionViewModel target, StoryIntelligenceLocationReviewCandidateViewModel candidate)
|
|
=> (target.LocationID.HasValue && candidate.ExistingLocationID == target.LocationID)
|
|
|| (!string.IsNullOrWhiteSpace(target.CandidateKey) && string.Equals(target.CandidateKey, candidate.Key, StringComparison.OrdinalIgnoreCase));
|
|
}
|