Ajax-enable story calendar navigation
This commit is contained in:
parent
91331b0dd0
commit
e080a4aad6
@ -3,6 +3,7 @@ using System.Net;
|
||||
using System.Text;
|
||||
using System.IO.Compression;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Metadata;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@ -139,6 +140,7 @@ var tests = new (string Name, Action Test)[]
|
||||
("Story Calendar day details endpoint respects book filter", StoryCalendarDayDetailsEndpointRespectsBookFilter),
|
||||
("Story Calendar day details endpoint handles empty story date", StoryCalendarDayDetailsEndpointHandlesEmptyStoryDate),
|
||||
("Story Calendar day details endpoint rejects invalid project", StoryCalendarDayDetailsEndpointRejectsInvalidProject),
|
||||
("Story Calendar index returns surface partial for AJAX", StoryCalendarIndexReturnsSurfacePartialForAjax),
|
||||
("Story Calendar day selection uses progressive AJAX hooks", StoryCalendarDaySelectionUsesProgressiveAjaxHooks),
|
||||
("Story Intelligence structured metric values validate and import", StoryIntelligenceStructuredMetricValuesValidateAndImport),
|
||||
("Story Intelligence structured schema covers complete response model", StoryIntelligenceStructuredSchemaCoversCompleteResponseModel),
|
||||
@ -7493,17 +7495,36 @@ static void StoryCalendarDayDetailsEndpointRejectsInvalidProject()
|
||||
Assert(typeof(StoryCalendarController).GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: false).Any(), "Story Calendar endpoints should remain authorization protected.");
|
||||
}
|
||||
|
||||
static void StoryCalendarIndexReturnsSurfacePartialForAjax()
|
||||
{
|
||||
var controller = CreateStoryCalendarController(BuildCalendarTimelineData());
|
||||
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
|
||||
controller.Request.Headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
|
||||
var partial = AssertIs<PartialViewResult>(controller.Index(1, null, "1982-12", "1982-12-28").GetAwaiter().GetResult(), "AJAX calendar navigation should return a partial view.");
|
||||
var model = AssertIs<StoryCalendarViewModel>(partial.Model!, "Calendar surface partial should receive the full calendar model.");
|
||||
|
||||
Assert(partial.ViewName == "_StoryCalendarSurface", "AJAX calendar navigation should return the replaceable Calendar surface.");
|
||||
Assert(model.SelectedMonth == new DateOnly(1982, 12, 1), "AJAX calendar navigation should preserve selected month.");
|
||||
Assert(model.SelectedDate == new DateOnly(1982, 12, 28), "AJAX calendar navigation should preserve selected date.");
|
||||
}
|
||||
|
||||
static void StoryCalendarDaySelectionUsesProgressiveAjaxHooks()
|
||||
{
|
||||
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/StoryCalendar/Index.cshtml"));
|
||||
var surface = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/StoryCalendar/_StoryCalendarSurface.cshtml"));
|
||||
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/site.js"));
|
||||
|
||||
Assert(view.Contains("data-story-calendar-day", StringComparison.Ordinal), "Calendar date links should expose a progressive enhancement hook.");
|
||||
Assert(view.Contains("asp-action=\"Index\"", StringComparison.Ordinal), "Calendar date links should keep their full-page fallback href.");
|
||||
Assert(view.Contains("data-story-calendar-details-url", StringComparison.Ordinal), "Calendar date links should include the day-details endpoint URL.");
|
||||
Assert(script.Contains("fetch(detailsUrl", StringComparison.Ordinal), "Calendar date clicks should load day details asynchronously.");
|
||||
Assert(view.Contains("data-story-calendar-root", StringComparison.Ordinal), "Calendar page should expose a replaceable AJAX root.");
|
||||
Assert(surface.Contains("data-story-calendar-day", StringComparison.Ordinal), "Calendar date links should expose a progressive enhancement hook.");
|
||||
Assert(surface.Contains("data-story-calendar-nav", StringComparison.Ordinal), "Calendar month navigation links should expose a progressive enhancement hook.");
|
||||
Assert(surface.Contains("data-story-calendar-filter", StringComparison.Ordinal), "Calendar filter form should expose a progressive enhancement hook.");
|
||||
Assert(surface.Contains("asp-action=\"Index\"", StringComparison.Ordinal), "Calendar date links should keep their full-page fallback href.");
|
||||
Assert(script.Contains("loadCalendarSurface(link.href", StringComparison.Ordinal), "Calendar links should load the full calendar surface asynchronously.");
|
||||
Assert(script.Contains("buildFilterUrl(form)", StringComparison.Ordinal), "Calendar dropdown/form changes should load the calendar surface asynchronously.");
|
||||
Assert(script.Contains("root.innerHTML = await response.text()", StringComparison.Ordinal), "AJAX calendar navigation should replace the calendar section.");
|
||||
Assert(script.Contains("window.history.pushState", StringComparison.Ordinal), "Calendar date clicks should preserve selected-date URL state.");
|
||||
Assert(script.Contains("is-selected", StringComparison.Ordinal), "Calendar date clicks should update selected-day styling.");
|
||||
Assert(script.Contains("popstate", StringComparison.Ordinal), "Browser back/forward should reload the calendar surface without a full page reload.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceStructuredMetricValuesValidateAndImport()
|
||||
|
||||
@ -20,7 +20,14 @@ public sealed class StoryCalendarController(IStoryCalendarService calendar, ILog
|
||||
var selectedMonth = ParseMonth(month);
|
||||
var selectedDate = ParseDate(date);
|
||||
var model = await calendar.GetAsync(projectId, bookId, selectedMonth, selectedDate);
|
||||
return model is null ? NotFound() : View(model);
|
||||
if (model is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return IsAjaxRequest()
|
||||
? PartialView("_StoryCalendarSurface", model)
|
||||
: View(model);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -97,4 +104,7 @@ public sealed class StoryCalendarController(IStoryCalendarService calendar, ILog
|
||||
|
||||
private static DateOnly FirstOfMonth(DateOnly date)
|
||||
=> new(date.Year, date.Month, 1);
|
||||
|
||||
private bool IsAjaxRequest()
|
||||
=> string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
@model StoryCalendarViewModel
|
||||
@using System.Globalization
|
||||
@{
|
||||
ViewData["Title"] = $"Calendar - {Model.Project.ProjectName}";
|
||||
ViewData["ProjectSection"] = "Calendar";
|
||||
var selectedMonthValue = Model.SelectedMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
var selectedDateValue = Model.SelectedDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
var showBookLabels = !Model.SelectedBookID.HasValue;
|
||||
var weekdays = new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" };
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
@ -17,193 +12,6 @@
|
||||
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading story-calendar-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Story time</p>
|
||||
<h1>Calendar</h1>
|
||||
<p class="lead-text">@Model.StoryRangeLabel</p>
|
||||
</div>
|
||||
<div class="story-calendar-stats" aria-label="Calendar summary">
|
||||
<span><strong>@Model.SceneCount</strong> dated scenes</span>
|
||||
<span><strong>@Model.ActiveStoryDayCount</strong> story days</span>
|
||||
<span><strong>@Model.UnplacedScenes.Count</strong> unplaced</span>
|
||||
</div>
|
||||
<div data-story-calendar-root>
|
||||
<partial name="_StoryCalendarSurface" model="Model" />
|
||||
</div>
|
||||
|
||||
<section class="card mb-3 story-calendar-filter">
|
||||
<div class="card-body">
|
||||
<form asp-action="ApplyFilters" method="post" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<label class="form-label" for="bookId">Book</label>
|
||||
<select class="form-select" id="bookId" name="bookId" asp-items="Model.BookOptions"></select>
|
||||
</div>
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<label class="form-label" for="month">Month</label>
|
||||
<select class="form-select" id="month" name="month" disabled="@(!Model.MonthOptions.Any())">
|
||||
@if (!Model.MonthOptions.Any())
|
||||
{
|
||||
<option value="">No dated scenes</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var option in Model.MonthOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@option.Selected">@option.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 col-lg-2">
|
||||
<button class="btn btn-primary w-100" type="submit">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!Model.HasDatedScenes)
|
||||
{
|
||||
<section class="card story-calendar-empty">
|
||||
<div class="card-body">
|
||||
<h2>No dated scenes yet</h2>
|
||||
<p class="muted mb-0">Scenes will appear here once canonical scene chronology has a resolved start date.</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="story-calendar-shell">
|
||||
<div class="story-calendar-toolbar">
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.PreviousMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture)">Previous month</a>
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.NextMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture)">Next month</a>
|
||||
@if (Model.FirstStoryMonth.HasValue)
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.FirstStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)">First dated scene</a>
|
||||
}
|
||||
</div>
|
||||
<div class="story-calendar-story-months">
|
||||
@if (Model.PreviousStoryMonth.HasValue)
|
||||
{
|
||||
<a asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.PreviousStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)">Previous story month</a>
|
||||
}
|
||||
@if (Model.NextStoryMonth.HasValue)
|
||||
{
|
||||
<a asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.NextStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)">Next story month</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="story-calendar-layout">
|
||||
<div class="story-calendar-grid-wrap">
|
||||
<div class="story-calendar-month" aria-label="@Model.SelectedMonthLabel">
|
||||
<div class="story-calendar-month-title">@Model.SelectedMonthLabel</div>
|
||||
<div class="story-calendar-weekdays">
|
||||
@foreach (var weekday in weekdays)
|
||||
{
|
||||
<div>@weekday</div>
|
||||
}
|
||||
</div>
|
||||
<div class="story-calendar-grid">
|
||||
@foreach (var week in Model.Weeks)
|
||||
{
|
||||
@foreach (var day in week.Days)
|
||||
{
|
||||
var visibleScenes = day.Scenes.Take(3).ToList();
|
||||
var hiddenCount = Math.Max(0, day.Scenes.Count - visibleScenes.Count);
|
||||
<a class="story-calendar-day @(day.IsInSelectedMonth ? "" : "is-overflow") @(day.IsSelected ? "is-selected" : "")"
|
||||
asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@selectedMonthValue"
|
||||
asp-route-date="@day.QueryValue"
|
||||
data-story-calendar-day
|
||||
data-story-calendar-date="@day.QueryValue"
|
||||
data-story-calendar-details-url="@Url.Action("DayDetails", "StoryCalendar", new { projectId = Model.Project.ProjectID, bookId = Model.SelectedBookID, date = day.QueryValue })"
|
||||
aria-current="@(day.IsSelected ? "date" : null)"
|
||||
aria-label="@day.DayLabel">
|
||||
<span class="story-calendar-day-number">@day.DayNumber</span>
|
||||
<span class="story-calendar-day-scenes">
|
||||
@foreach (var scene in visibleScenes)
|
||||
{
|
||||
<span class="story-calendar-scene-chip">
|
||||
@if (showBookLabels)
|
||||
{
|
||||
<span class="story-calendar-book">@scene.BookLabel</span>
|
||||
}
|
||||
<span>@scene.SceneLabel</span>
|
||||
<strong>@scene.Title</strong>
|
||||
</span>
|
||||
}
|
||||
@if (hiddenCount > 0)
|
||||
{
|
||||
<span class="story-calendar-more">+ @hiddenCount more</span>
|
||||
}
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="story-calendar-detail" aria-label="Selected day" aria-live="polite" data-story-calendar-detail>
|
||||
<partial name="_StoryCalendarDayDetails" model="Model.DayDetails" />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="card story-calendar-unplaced">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Unplaced scenes</strong>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#story-calendar-unplaced" aria-expanded="false" aria-controls="story-calendar-unplaced">
|
||||
@Model.UnplacedScenes.Count scenes
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="story-calendar-unplaced">
|
||||
<div class="card-body">
|
||||
@if (!Model.UnplacedScenes.Any())
|
||||
{
|
||||
<p class="muted mb-0">All scenes in this scope have resolved story dates.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="story-calendar-unplaced-list">
|
||||
@foreach (var scene in Model.UnplacedScenes)
|
||||
{
|
||||
<article>
|
||||
<div>
|
||||
<strong>@scene.Title</strong>
|
||||
<span>@scene.BookTitle · Chapter @scene.ChapterNumber:g · Scene @scene.SceneNumber:g</span>
|
||||
@if (!string.IsNullOrWhiteSpace(scene.RelativeTimeText))
|
||||
{
|
||||
<small>@scene.RelativeTimeText</small>
|
||||
}
|
||||
</div>
|
||||
<a class="btn btn-outline-primary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open scene</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
202
PlotLine/Views/StoryCalendar/_StoryCalendarSurface.cshtml
Normal file
202
PlotLine/Views/StoryCalendar/_StoryCalendarSurface.cshtml
Normal file
@ -0,0 +1,202 @@
|
||||
@model StoryCalendarViewModel
|
||||
@using System.Globalization
|
||||
@{
|
||||
var selectedMonthValue = Model.SelectedMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
var showBookLabels = !Model.SelectedBookID.HasValue;
|
||||
var weekdays = new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" };
|
||||
}
|
||||
|
||||
<div class="page-heading story-calendar-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Story time</p>
|
||||
<h1>Calendar</h1>
|
||||
<p class="lead-text">@Model.StoryRangeLabel</p>
|
||||
</div>
|
||||
<div class="story-calendar-stats" aria-label="Calendar summary">
|
||||
<span><strong>@Model.SceneCount</strong> dated scenes</span>
|
||||
<span><strong>@Model.ActiveStoryDayCount</strong> story days</span>
|
||||
<span><strong>@Model.UnplacedScenes.Count</strong> unplaced</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card mb-3 story-calendar-filter">
|
||||
<div class="card-body">
|
||||
<form asp-action="ApplyFilters" method="post" class="row g-2 align-items-end" data-story-calendar-filter data-story-calendar-index-url="@Url.Action("Index", "StoryCalendar")">
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<label class="form-label" for="bookId">Book</label>
|
||||
<select class="form-select" id="bookId" name="bookId" asp-items="Model.BookOptions"></select>
|
||||
</div>
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<label class="form-label" for="month">Month</label>
|
||||
<select class="form-select" id="month" name="month" disabled="@(!Model.MonthOptions.Any())">
|
||||
@if (!Model.MonthOptions.Any())
|
||||
{
|
||||
<option value="">No dated scenes</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var option in Model.MonthOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@option.Selected">@option.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 col-lg-2">
|
||||
<button class="btn btn-primary w-100" type="submit">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!Model.HasDatedScenes)
|
||||
{
|
||||
<section class="card story-calendar-empty">
|
||||
<div class="card-body">
|
||||
<h2>No dated scenes yet</h2>
|
||||
<p class="muted mb-0">Scenes will appear here once canonical scene chronology has a resolved start date.</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="story-calendar-shell">
|
||||
<div class="story-calendar-toolbar">
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.PreviousMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
|
||||
data-story-calendar-nav>Previous month</a>
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.NextMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
|
||||
data-story-calendar-nav>Next month</a>
|
||||
@if (Model.FirstStoryMonth.HasValue)
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.FirstStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
|
||||
data-story-calendar-nav>First dated scene</a>
|
||||
}
|
||||
</div>
|
||||
<div class="story-calendar-story-months">
|
||||
@if (Model.PreviousStoryMonth.HasValue)
|
||||
{
|
||||
<a asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.PreviousStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
|
||||
data-story-calendar-nav>Previous story month</a>
|
||||
}
|
||||
@if (Model.NextStoryMonth.HasValue)
|
||||
{
|
||||
<a asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@Model.NextStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
|
||||
data-story-calendar-nav>Next story month</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="story-calendar-layout">
|
||||
<div class="story-calendar-grid-wrap">
|
||||
<div class="story-calendar-month" aria-label="@Model.SelectedMonthLabel">
|
||||
<div class="story-calendar-month-title">@Model.SelectedMonthLabel</div>
|
||||
<div class="story-calendar-weekdays">
|
||||
@foreach (var weekday in weekdays)
|
||||
{
|
||||
<div>@weekday</div>
|
||||
}
|
||||
</div>
|
||||
<div class="story-calendar-grid">
|
||||
@foreach (var week in Model.Weeks)
|
||||
{
|
||||
@foreach (var day in week.Days)
|
||||
{
|
||||
var visibleScenes = day.Scenes.Take(3).ToList();
|
||||
var hiddenCount = Math.Max(0, day.Scenes.Count - visibleScenes.Count);
|
||||
<a class="story-calendar-day @(day.IsInSelectedMonth ? "" : "is-overflow") @(day.IsSelected ? "is-selected" : "")"
|
||||
asp-action="Index"
|
||||
asp-route-projectId="@Model.Project.ProjectID"
|
||||
asp-route-bookId="@Model.SelectedBookID"
|
||||
asp-route-month="@selectedMonthValue"
|
||||
asp-route-date="@day.QueryValue"
|
||||
data-story-calendar-day
|
||||
data-story-calendar-date="@day.QueryValue"
|
||||
aria-current="@(day.IsSelected ? "date" : null)"
|
||||
aria-label="@day.DayLabel">
|
||||
<span class="story-calendar-day-number">@day.DayNumber</span>
|
||||
<span class="story-calendar-day-scenes">
|
||||
@foreach (var scene in visibleScenes)
|
||||
{
|
||||
<span class="story-calendar-scene-chip">
|
||||
@if (showBookLabels)
|
||||
{
|
||||
<span class="story-calendar-book">@scene.BookLabel</span>
|
||||
}
|
||||
<span>@scene.SceneLabel</span>
|
||||
<strong>@scene.Title</strong>
|
||||
</span>
|
||||
}
|
||||
@if (hiddenCount > 0)
|
||||
{
|
||||
<span class="story-calendar-more">+ @hiddenCount more</span>
|
||||
}
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="story-calendar-detail" aria-label="Selected day" aria-live="polite" data-story-calendar-detail>
|
||||
<partial name="_StoryCalendarDayDetails" model="Model.DayDetails" />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="card story-calendar-unplaced">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Unplaced scenes</strong>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#story-calendar-unplaced" aria-expanded="false" aria-controls="story-calendar-unplaced">
|
||||
@Model.UnplacedScenes.Count scenes
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="story-calendar-unplaced">
|
||||
<div class="card-body">
|
||||
@if (!Model.UnplacedScenes.Any())
|
||||
{
|
||||
<p class="muted mb-0">All scenes in this scope have resolved story dates.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="story-calendar-unplaced-list">
|
||||
@foreach (var scene in Model.UnplacedScenes)
|
||||
{
|
||||
<article>
|
||||
<div>
|
||||
<strong>@scene.Title</strong>
|
||||
<span>@scene.BookTitle · Chapter @scene.ChapterNumber:g · Scene @scene.SceneNumber:g</span>
|
||||
@if (!string.IsNullOrWhiteSpace(scene.RelativeTimeText))
|
||||
{
|
||||
<small>@scene.RelativeTimeText</small>
|
||||
}
|
||||
</div>
|
||||
<a class="btn btn-outline-primary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open scene</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -2451,62 +2451,112 @@
|
||||
});
|
||||
});
|
||||
|
||||
const storyCalendarDetail = document.querySelector("[data-story-calendar-detail]");
|
||||
if (storyCalendarDetail) {
|
||||
if (document.querySelector("[data-story-calendar-root]")) {
|
||||
const calendarRoot = () => document.querySelector("[data-story-calendar-root]");
|
||||
const calendarDetail = () => calendarRoot()?.querySelector("[data-story-calendar-detail]");
|
||||
|
||||
const clearCalendarNotice = () => {
|
||||
storyCalendarDetail.querySelectorAll("[data-story-calendar-notice]").forEach((notice) => notice.remove());
|
||||
calendarRoot()?.querySelectorAll("[data-story-calendar-notice]").forEach((notice) => notice.remove());
|
||||
};
|
||||
|
||||
const showCalendarNotice = (message, state) => {
|
||||
clearCalendarNotice();
|
||||
const detail = calendarDetail();
|
||||
const root = calendarRoot();
|
||||
const target = detail || root;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notice = document.createElement("p");
|
||||
notice.className = `story-calendar-detail-notice ${state === "error" ? "is-error" : ""}`.trim();
|
||||
notice.dataset.storyCalendarNotice = "true";
|
||||
notice.textContent = message;
|
||||
storyCalendarDetail.prepend(notice);
|
||||
target.prepend(notice);
|
||||
};
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const link = event.target.closest("[data-story-calendar-day]");
|
||||
if (!link || !link.isConnected || !window.fetch) {
|
||||
return;
|
||||
const loadCalendarSurface = async (url, historyMode = "push") => {
|
||||
const root = calendarRoot();
|
||||
if (!root || !window.fetch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const detailsUrl = link.dataset.storyCalendarDetailsUrl;
|
||||
const selectedDate = link.dataset.storyCalendarDate;
|
||||
if (!detailsUrl || !selectedDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
showCalendarNotice("Loading...", "loading");
|
||||
|
||||
try {
|
||||
const response = await fetch(detailsUrl, {
|
||||
const response = await fetch(url, {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
credentials: "same-origin"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Day details load failed");
|
||||
throw new Error("Calendar load failed");
|
||||
}
|
||||
|
||||
storyCalendarDetail.innerHTML = await response.text();
|
||||
document.querySelectorAll("[data-story-calendar-day].is-selected").forEach((day) => {
|
||||
day.classList.remove("is-selected");
|
||||
day.removeAttribute("aria-current");
|
||||
});
|
||||
link.classList.add("is-selected");
|
||||
link.setAttribute("aria-current", "date");
|
||||
|
||||
if (window.history?.pushState) {
|
||||
const nextUrl = new URL(link.href, window.location.href);
|
||||
nextUrl.searchParams.set("date", selectedDate);
|
||||
window.history.pushState({ storyCalendarDate: selectedDate }, "", nextUrl.toString());
|
||||
root.innerHTML = await response.text();
|
||||
if (window.history?.pushState && historyMode !== "none") {
|
||||
const nextUrl = new URL(url, window.location.href);
|
||||
const state = { storyCalendarUrl: nextUrl.toString() };
|
||||
if (historyMode === "replace") {
|
||||
window.history.replaceState(state, "", nextUrl.toString());
|
||||
} else {
|
||||
window.history.pushState(state, "", nextUrl.toString());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
showCalendarNotice("The selected story date could not be loaded. Use the date link to retry.", "error");
|
||||
showCalendarNotice("The calendar could not be loaded. Use the link or Apply button to retry.", "error");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildFilterUrl = (form) => {
|
||||
const formData = new FormData(form);
|
||||
const url = new URL(form.dataset.storyCalendarIndexUrl || form.action || window.location.href, window.location.href);
|
||||
url.search = "";
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === "__RequestVerificationToken" || value === "") {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
window.history?.replaceState?.({ storyCalendarUrl: window.location.href }, "", window.location.href);
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const link = event.target.closest("[data-story-calendar-day], [data-story-calendar-nav]");
|
||||
if (!link || !link.isConnected || !calendarRoot()?.contains(link) || !window.fetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await loadCalendarSurface(link.href, "push");
|
||||
});
|
||||
|
||||
document.addEventListener("submit", async (event) => {
|
||||
const form = event.target.closest("[data-story-calendar-filter]");
|
||||
if (!form || !form.isConnected || !window.fetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await loadCalendarSurface(buildFilterUrl(form), "push");
|
||||
});
|
||||
|
||||
document.addEventListener("change", async (event) => {
|
||||
const select = event.target.closest("[data-story-calendar-filter] select");
|
||||
const form = select?.closest("[data-story-calendar-filter]");
|
||||
if (!select || !form || !window.fetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadCalendarSurface(buildFilterUrl(form), "push");
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", async () => {
|
||||
await loadCalendarSurface(window.location.href, "none");
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
2
PlotLine/wwwroot/js/site.min.js
vendored
2
PlotLine/wwwroot/js/site.min.js
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user