Improve story calendar day selection
This commit is contained in:
parent
ce0ec5655c
commit
91331b0dd0
@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Globalization;
|
||||||
using Microsoft.AspNetCore.Http.Metadata;
|
using Microsoft.AspNetCore.Http.Metadata;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@ -132,7 +133,13 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Story Calendar separates unplaced scenes without guessing", StoryCalendarSeparatesUnplacedScenesWithoutGuessing),
|
("Story Calendar separates unplaced scenes without guessing", StoryCalendarSeparatesUnplacedScenesWithoutGuessing),
|
||||||
("Story Calendar book filter and all books scope work", StoryCalendarBookFilterAndAllBooksScopeWork),
|
("Story Calendar book filter and all books scope work", StoryCalendarBookFilterAndAllBooksScopeWork),
|
||||||
("Story Calendar month navigation uses story dates across years", StoryCalendarMonthNavigationUsesStoryDatesAcrossYears),
|
("Story Calendar month navigation uses story dates across years", StoryCalendarMonthNavigationUsesStoryDatesAcrossYears),
|
||||||
|
("Story Calendar month dropdown follows displayed month", StoryCalendarMonthDropdownFollowsDisplayedMonth),
|
||||||
("Story Calendar handles no dated scenes", StoryCalendarHandlesNoDatedScenes),
|
("Story Calendar handles no dated scenes", StoryCalendarHandlesNoDatedScenes),
|
||||||
|
("Story Calendar day details endpoint returns partial", StoryCalendarDayDetailsEndpointReturnsPartial),
|
||||||
|
("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 day selection uses progressive AJAX hooks", StoryCalendarDaySelectionUsesProgressiveAjaxHooks),
|
||||||
("Story Intelligence structured metric values validate and import", StoryIntelligenceStructuredMetricValuesValidateAndImport),
|
("Story Intelligence structured metric values validate and import", StoryIntelligenceStructuredMetricValuesValidateAndImport),
|
||||||
("Story Intelligence structured schema covers complete response model", StoryIntelligenceStructuredSchemaCoversCompleteResponseModel),
|
("Story Intelligence structured schema covers complete response model", StoryIntelligenceStructuredSchemaCoversCompleteResponseModel),
|
||||||
("Story Intelligence structured schema remains fixture agnostic", StoryIntelligenceStructuredSchemaRemainsFixtureAgnostic),
|
("Story Intelligence structured schema remains fixture agnostic", StoryIntelligenceStructuredSchemaRemainsFixtureAgnostic),
|
||||||
@ -7414,6 +7421,22 @@ static void StoryCalendarMonthNavigationUsesStoryDatesAcrossYears()
|
|||||||
Assert(december.NextStoryMonth == new DateOnly(1983, 1, 1), "Next story month should follow months that contain story scenes.");
|
Assert(december.NextStoryMonth == new DateOnly(1983, 1, 1), "Next story month should follow months that contain story scenes.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void StoryCalendarMonthDropdownFollowsDisplayedMonth()
|
||||||
|
{
|
||||||
|
var service = new StoryCalendarService(new FakeTimelineRepository(BuildCalendarTimelineData()));
|
||||||
|
var firstDetected = service.GetAsync(1, null, new DateOnly(1982, 12, 1), null).GetAwaiter().GetResult()!;
|
||||||
|
var beforeDetected = service.GetAsync(1, null, new DateOnly(1982, 11, 1), null).GetAwaiter().GetResult()!;
|
||||||
|
var afterDetected = service.GetAsync(1, null, new DateOnly(1983, 2, 1), null).GetAwaiter().GetResult()!;
|
||||||
|
|
||||||
|
Assert(firstDetected.MonthOptions.Single(option => option.Value == "1982-12").Selected, "A detected selected month should be selected normally.");
|
||||||
|
Assert(beforeDetected.MonthOptions.First().Value == "1982-11", "A selected month before the detected range should be injected at the start of the dropdown.");
|
||||||
|
Assert(beforeDetected.MonthOptions.Single(option => option.Value == "1982-11").Selected, "The injected earlier month should be selected.");
|
||||||
|
Assert(afterDetected.MonthOptions.Last().Value == "1983-02", "A selected month after the detected range should be injected at the end of the dropdown.");
|
||||||
|
Assert(afterDetected.MonthOptions.Single(option => option.Value == "1983-02").Selected, "The injected later month should be selected.");
|
||||||
|
Assert(beforeDetected.MonthOptions.Single(option => option.Selected).Value == beforeDetected.SelectedMonth!.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture), "The dropdown selected option must match the rendered month.");
|
||||||
|
Assert(afterDetected.MonthOptions.Single(option => option.Selected).Value == afterDetected.SelectedMonth!.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture), "The dropdown selected option must match the rendered month after the detected range.");
|
||||||
|
}
|
||||||
|
|
||||||
static void StoryCalendarHandlesNoDatedScenes()
|
static void StoryCalendarHandlesNoDatedScenes()
|
||||||
{
|
{
|
||||||
var data = BuildCalendarTimelineData(includeDatedScenes: false);
|
var data = BuildCalendarTimelineData(includeDatedScenes: false);
|
||||||
@ -7426,6 +7449,63 @@ static void StoryCalendarHandlesNoDatedScenes()
|
|||||||
Assert(model.UnplacedScenes.Count == 1, "Undated scenes should still be visible as unplaced.");
|
Assert(model.UnplacedScenes.Count == 1, "Undated scenes should still be visible as unplaced.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void StoryCalendarDayDetailsEndpointReturnsPartial()
|
||||||
|
{
|
||||||
|
var controller = CreateStoryCalendarController(BuildCalendarTimelineData());
|
||||||
|
var result = controller.DayDetails(1, null, "1982-12-28").GetAwaiter().GetResult();
|
||||||
|
var partial = AssertIs<PartialViewResult>(result, "Valid day details request should return a partial view.");
|
||||||
|
var model = AssertIs<StoryCalendarDayDetailsViewModel>(partial.Model!, "Day details partial should receive the focused day details model.");
|
||||||
|
|
||||||
|
Assert(partial.ViewName == "_StoryCalendarDayDetails", "Day details endpoint should render the calendar day details partial.");
|
||||||
|
Assert(model.SelectedDay?.Date == new DateOnly(1982, 12, 28), "Day details should render the requested date.");
|
||||||
|
Assert(model.SelectedDay?.Scenes.Count == 4, "Day details should include scenes on the requested date.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryCalendarDayDetailsEndpointRespectsBookFilter()
|
||||||
|
{
|
||||||
|
var allBooksController = CreateStoryCalendarController(BuildCalendarTimelineData());
|
||||||
|
var bookOneController = CreateStoryCalendarController(BuildCalendarTimelineData());
|
||||||
|
var allBooks = AssertIs<PartialViewResult>(allBooksController.DayDetails(1, null, "1983-01-02").GetAwaiter().GetResult(), "All-books day details should return a partial.");
|
||||||
|
var bookOne = AssertIs<PartialViewResult>(bookOneController.DayDetails(1, 10, "1983-01-02").GetAwaiter().GetResult(), "Book-filtered day details should return a partial.");
|
||||||
|
var allBooksModel = AssertIs<StoryCalendarDayDetailsViewModel>(allBooks.Model!, "All-books partial should use day details model.");
|
||||||
|
var bookOneModel = AssertIs<StoryCalendarDayDetailsViewModel>(bookOne.Model!, "Book-filtered partial should use day details model.");
|
||||||
|
|
||||||
|
Assert(allBooksModel.SelectedDay?.Scenes.Any(scene => scene.BookID == 20) == true, $"All books should include scenes from the second book on the selected date. Date: {allBooksModel.SelectedDay?.QueryValue ?? "none"}; scenes: {string.Join(", ", allBooksModel.SelectedDay?.Scenes.Select(scene => $"{scene.SceneID}:{scene.BookID}") ?? [])}");
|
||||||
|
Assert(bookOneModel.SelectedDay?.Scenes.Any() == false, "Book filter should exclude scenes outside the selected book.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryCalendarDayDetailsEndpointHandlesEmptyStoryDate()
|
||||||
|
{
|
||||||
|
var controller = CreateStoryCalendarController(BuildCalendarTimelineData());
|
||||||
|
var partial = AssertIs<PartialViewResult>(controller.DayDetails(1, null, "1982-12-29").GetAwaiter().GetResult(), "Empty day details should still return a partial.");
|
||||||
|
var model = AssertIs<StoryCalendarDayDetailsViewModel>(partial.Model!, "Empty day partial should use day details model.");
|
||||||
|
|
||||||
|
Assert(model.SelectedDay?.Date == new DateOnly(1982, 12, 29), "Empty day details should preserve the requested date.");
|
||||||
|
Assert(model.SelectedDay?.Scenes.Count == 0, "Empty day details should have no scenes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryCalendarDayDetailsEndpointRejectsInvalidProject()
|
||||||
|
{
|
||||||
|
var controller = CreateStoryCalendarController(BuildCalendarTimelineData());
|
||||||
|
var result = controller.DayDetails(999, null, "1982-12-28").GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
Assert(result is NotFoundResult, "Invalid project should follow the main calendar security/access path and return NotFound.");
|
||||||
|
Assert(typeof(StoryCalendarController).GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: false).Any(), "Story Calendar endpoints should remain authorization protected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryCalendarDaySelectionUsesProgressiveAjaxHooks()
|
||||||
|
{
|
||||||
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/StoryCalendar/Index.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(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.");
|
||||||
|
}
|
||||||
|
|
||||||
static void StoryIntelligenceStructuredMetricValuesValidateAndImport()
|
static void StoryIntelligenceStructuredMetricValuesValidateAndImport()
|
||||||
{
|
{
|
||||||
var validator = new StorySceneValidator();
|
var validator = new StorySceneValidator();
|
||||||
@ -7614,6 +7694,9 @@ static TimelineData BuildCalendarTimelineData(bool includeDatedScenes = true)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static StoryCalendarController CreateStoryCalendarController(TimelineData data)
|
||||||
|
=> new(new StoryCalendarService(new FakeTimelineRepository(data)), NullLogger<StoryCalendarController>.Instance);
|
||||||
|
|
||||||
static Scene CalendarScene(
|
static Scene CalendarScene(
|
||||||
int sceneId,
|
int sceneId,
|
||||||
int bookId,
|
int bookId,
|
||||||
@ -7786,6 +7869,16 @@ static void Assert(bool condition, string message)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static T AssertIs<T>(object value, string message)
|
||||||
|
{
|
||||||
|
if (value is T typed)
|
||||||
|
{
|
||||||
|
return typed;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
|
||||||
static void AssertThrows(Action action, string message)
|
static void AssertThrows(Action action, string message)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
|
|
||||||
namespace PlotLine.Controllers;
|
namespace PlotLine.Controllers;
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public sealed class StoryCalendarController(IStoryCalendarService calendar) : Controller
|
public sealed class StoryCalendarController(IStoryCalendarService calendar, ILogger<StoryCalendarController> logger) : Controller
|
||||||
{
|
{
|
||||||
public async Task<IActionResult> Index(int projectId, int? bookId, string? month, string? date)
|
public async Task<IActionResult> Index(int projectId, int? bookId, string? month, string? date)
|
||||||
{
|
{
|
||||||
@ -21,6 +23,32 @@ public sealed class StoryCalendarController(IStoryCalendarService calendar) : Co
|
|||||||
return model is null ? NotFound() : View(model);
|
return model is null ? NotFound() : View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> DayDetails(int projectId, int? bookId, string? date)
|
||||||
|
{
|
||||||
|
if (projectId == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("ProjectID is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedDate = ParseDate(date);
|
||||||
|
if (!selectedDate.HasValue)
|
||||||
|
{
|
||||||
|
return BadRequest("Date is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var model = await calendar.GetAsync(projectId, bookId, FirstOfMonth(selectedDate.Value), selectedDate);
|
||||||
|
return model is null ? NotFound() : PartialView("_StoryCalendarDayDetails", model.DayDetails);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to load Story Calendar day details for project {ProjectID} and date {StoryDate}.", projectId, date);
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError, "The selected story date could not be loaded.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public IActionResult ApplyFilters(int projectId, int? bookId, string? month)
|
public IActionResult ApplyFilters(int projectId, int? bookId, string? month)
|
||||||
@ -66,4 +94,7 @@ public sealed class StoryCalendarController(IStoryCalendarService calendar) : Co
|
|||||||
? parsed
|
? parsed
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DateOnly FirstOfMonth(DateOnly date)
|
||||||
|
=> new(date.Year, date.Month, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -508,15 +508,31 @@ public sealed class StoryCalendarService(ITimelineRepository timelineRepository)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var options = new List<SelectListItem>();
|
var options = new List<SelectListItem>();
|
||||||
for (var month = FirstOfMonth(earliest.Value); month <= FirstOfMonth(latest.Value); month = month.AddMonths(1))
|
var firstMonth = FirstOfMonth(earliest.Value);
|
||||||
|
var lastMonth = FirstOfMonth(latest.Value);
|
||||||
|
var normalizedSelectedMonth = selectedMonth.HasValue ? FirstOfMonth(selectedMonth.Value) : (DateOnly?)null;
|
||||||
|
|
||||||
|
if (normalizedSelectedMonth.HasValue && normalizedSelectedMonth.Value < firstMonth)
|
||||||
{
|
{
|
||||||
var value = month.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
options.Add(BuildMonthOption(normalizedSelectedMonth.Value, selected: true));
|
||||||
options.Add(new SelectListItem(month.ToString("MMMM yyyy", UkCulture), value, selectedMonth == month));
|
}
|
||||||
|
|
||||||
|
for (var month = firstMonth; month <= lastMonth; month = month.AddMonths(1))
|
||||||
|
{
|
||||||
|
options.Add(BuildMonthOption(month, normalizedSelectedMonth == month));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSelectedMonth.HasValue && normalizedSelectedMonth.Value > lastMonth)
|
||||||
|
{
|
||||||
|
options.Add(BuildMonthOption(normalizedSelectedMonth.Value, selected: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SelectListItem BuildMonthOption(DateOnly month, bool selected)
|
||||||
|
=> new(month.ToString("MMMM yyyy", UkCulture), month.ToString("yyyy-MM", CultureInfo.InvariantCulture), selected);
|
||||||
|
|
||||||
private static DateOnly? AdjacentStoryMonth(IReadOnlyList<StoryCalendarSceneViewModel> datedScenes, DateOnly selectedMonth, bool previous)
|
private static DateOnly? AdjacentStoryMonth(IReadOnlyList<StoryCalendarSceneViewModel> datedScenes, DateOnly selectedMonth, bool previous)
|
||||||
{
|
{
|
||||||
var months = datedScenes
|
var months = datedScenes
|
||||||
|
|||||||
@ -1273,6 +1273,12 @@ public sealed class StoryCalendarViewModel
|
|||||||
public IReadOnlyList<StoryCalendarSceneViewModel> DatedScenes { get; set; } = [];
|
public IReadOnlyList<StoryCalendarSceneViewModel> DatedScenes { get; set; } = [];
|
||||||
public IReadOnlyList<StoryCalendarSceneViewModel> UnplacedScenes { get; set; } = [];
|
public IReadOnlyList<StoryCalendarSceneViewModel> UnplacedScenes { get; set; } = [];
|
||||||
public StoryCalendarDayViewModel? SelectedDay { get; set; }
|
public StoryCalendarDayViewModel? SelectedDay { get; set; }
|
||||||
|
public StoryCalendarDayDetailsViewModel DayDetails => new()
|
||||||
|
{
|
||||||
|
ProjectID = Project.ProjectID,
|
||||||
|
SelectedBookID = SelectedBookID,
|
||||||
|
SelectedDay = SelectedDay
|
||||||
|
};
|
||||||
public int SceneCount => DatedScenes.Count;
|
public int SceneCount => DatedScenes.Count;
|
||||||
public int ActiveStoryDayCount => DatedScenes.Select(scene => scene.StoryDate).Distinct().Count();
|
public int ActiveStoryDayCount => DatedScenes.Select(scene => scene.StoryDate).Distinct().Count();
|
||||||
public bool HasDatedScenes => DatedScenes.Count > 0;
|
public bool HasDatedScenes => DatedScenes.Count > 0;
|
||||||
@ -1293,6 +1299,14 @@ public sealed class StoryCalendarViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryCalendarDayDetailsViewModel
|
||||||
|
{
|
||||||
|
public int ProjectID { get; set; }
|
||||||
|
public int? SelectedBookID { get; set; }
|
||||||
|
public StoryCalendarDayViewModel? SelectedDay { get; set; }
|
||||||
|
public bool ShowBookLabels => !SelectedBookID.HasValue;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryCalendarWeekViewModel
|
public sealed class StoryCalendarWeekViewModel
|
||||||
{
|
{
|
||||||
public IReadOnlyList<StoryCalendarDayViewModel> Days { get; set; } = [];
|
public IReadOnlyList<StoryCalendarDayViewModel> Days { get; set; } = [];
|
||||||
|
|||||||
@ -135,6 +135,10 @@ else
|
|||||||
asp-route-bookId="@Model.SelectedBookID"
|
asp-route-bookId="@Model.SelectedBookID"
|
||||||
asp-route-month="@selectedMonthValue"
|
asp-route-month="@selectedMonthValue"
|
||||||
asp-route-date="@day.QueryValue"
|
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">
|
aria-label="@day.DayLabel">
|
||||||
<span class="story-calendar-day-number">@day.DayNumber</span>
|
<span class="story-calendar-day-number">@day.DayNumber</span>
|
||||||
<span class="story-calendar-day-scenes">
|
<span class="story-calendar-day-scenes">
|
||||||
@ -161,61 +165,8 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="story-calendar-detail" aria-label="Selected day">
|
<aside class="story-calendar-detail" aria-label="Selected day" aria-live="polite" data-story-calendar-detail>
|
||||||
@if (Model.SelectedDay is null)
|
<partial name="_StoryCalendarDayDetails" model="Model.DayDetails" />
|
||||||
{
|
|
||||||
<h2>Select a story day</h2>
|
|
||||||
<p class="muted">Choose a date in the grid to inspect its scenes in narrative order.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<h2>@Model.SelectedDay.DayLabel</h2>
|
|
||||||
@if (!Model.SelectedDay.Scenes.Any())
|
|
||||||
{
|
|
||||||
<p class="muted">No scenes are placed on this story date.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="story-calendar-detail-list">
|
|
||||||
@foreach (var scene in Model.SelectedDay.Scenes)
|
|
||||||
{
|
|
||||||
<article class="story-calendar-detail-scene">
|
|
||||||
<div class="story-calendar-detail-meta">
|
|
||||||
@if (showBookLabels)
|
|
||||||
{
|
|
||||||
<span>@scene.BookTitle</span>
|
|
||||||
}
|
|
||||||
<span>Chapter @scene.ChapterNumber:g</span>
|
|
||||||
<span>Scene @scene.SceneNumber:g</span>
|
|
||||||
</div>
|
|
||||||
<h3>@scene.Title</h3>
|
|
||||||
<dl>
|
|
||||||
@if (!string.IsNullOrWhiteSpace(scene.Pov))
|
|
||||||
{
|
|
||||||
<dt>POV</dt>
|
|
||||||
<dd>@scene.Pov</dd>
|
|
||||||
}
|
|
||||||
<dt>Time</dt>
|
|
||||||
<dd>@scene.TimeLabel</dd>
|
|
||||||
</dl>
|
|
||||||
@if (!string.IsNullOrWhiteSpace(scene.Summary))
|
|
||||||
{
|
|
||||||
<p>@scene.Summary</p>
|
|
||||||
}
|
|
||||||
<div class="button-row">
|
|
||||||
<a class="btn btn-outline-primary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open scene</a>
|
|
||||||
<a class="btn btn-outline-secondary btn-sm"
|
|
||||||
asp-controller="Timeline"
|
|
||||||
asp-action="Index"
|
|
||||||
asp-route-projectId="@Model.Project.ProjectID"
|
|
||||||
asp-route-bookId="@scene.BookID"
|
|
||||||
asp-route-selectedSceneId="@scene.SceneID">Open in Timeline</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
56
PlotLine/Views/StoryCalendar/_StoryCalendarDayDetails.cshtml
Normal file
56
PlotLine/Views/StoryCalendar/_StoryCalendarDayDetails.cshtml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
@model StoryCalendarDayDetailsViewModel
|
||||||
|
|
||||||
|
@if (Model.SelectedDay is null)
|
||||||
|
{
|
||||||
|
<h2>Select a story day</h2>
|
||||||
|
<p class="muted">Choose a date in the grid to inspect its scenes in narrative order.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<h2>@Model.SelectedDay.DayLabel</h2>
|
||||||
|
@if (!Model.SelectedDay.Scenes.Any())
|
||||||
|
{
|
||||||
|
<p class="muted">No scenes are placed on this story date.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="story-calendar-detail-list">
|
||||||
|
@foreach (var scene in Model.SelectedDay.Scenes)
|
||||||
|
{
|
||||||
|
<article class="story-calendar-detail-scene">
|
||||||
|
<div class="story-calendar-detail-meta">
|
||||||
|
@if (Model.ShowBookLabels)
|
||||||
|
{
|
||||||
|
<span>@scene.BookTitle</span>
|
||||||
|
}
|
||||||
|
<span>Chapter @scene.ChapterNumber:g</span>
|
||||||
|
<span>Scene @scene.SceneNumber:g</span>
|
||||||
|
</div>
|
||||||
|
<h3>@scene.Title</h3>
|
||||||
|
<dl>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(scene.Pov))
|
||||||
|
{
|
||||||
|
<dt>POV</dt>
|
||||||
|
<dd>@scene.Pov</dd>
|
||||||
|
}
|
||||||
|
<dt>Time</dt>
|
||||||
|
<dd>@scene.TimeLabel</dd>
|
||||||
|
</dl>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(scene.Summary))
|
||||||
|
{
|
||||||
|
<p>@scene.Summary</p>
|
||||||
|
}
|
||||||
|
<div class="button-row">
|
||||||
|
<a class="btn btn-outline-primary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open scene</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm"
|
||||||
|
asp-controller="Timeline"
|
||||||
|
asp-action="Index"
|
||||||
|
asp-route-projectId="@Model.ProjectID"
|
||||||
|
asp-route-bookId="@scene.BookID"
|
||||||
|
asp-route-selectedSceneId="@scene.SceneID">Open in Timeline</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1537,6 +1537,23 @@ body.timeline-inspector-resizing {
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.story-calendar-detail-notice {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--plotline-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
background: var(--plotline-soft);
|
||||||
|
color: var(--plotline-muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-calendar-detail-notice.is-error {
|
||||||
|
border-color: color-mix(in srgb, var(--bs-danger) 34%, var(--plotline-line));
|
||||||
|
background: color-mix(in srgb, var(--bs-danger) 8%, var(--plotline-panel));
|
||||||
|
color: var(--bs-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.story-calendar-detail-list {
|
.story-calendar-detail-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
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
@ -2450,4 +2450,63 @@
|
|||||||
showForm("location-relationship", "Create location relationship", `${payload.label} -> ${targetLabel}`, "Location drop");
|
showForm("location-relationship", "Create location relationship", `${payload.label} -> ${targetLabel}`, "Location drop");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const storyCalendarDetail = document.querySelector("[data-story-calendar-detail]");
|
||||||
|
if (storyCalendarDetail) {
|
||||||
|
const clearCalendarNotice = () => {
|
||||||
|
storyCalendarDetail.querySelectorAll("[data-story-calendar-notice]").forEach((notice) => notice.remove());
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCalendarNotice = (message, state) => {
|
||||||
|
clearCalendarNotice();
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("click", async (event) => {
|
||||||
|
const link = event.target.closest("[data-story-calendar-day]");
|
||||||
|
if (!link || !link.isConnected || !window.fetch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, {
|
||||||
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||||
|
credentials: "same-origin"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Day details 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());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showCalendarNotice("The selected story date could not be loaded. Use the date link to retry.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
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