using Microsoft.AspNetCore.Mvc; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; namespace PlotLine.Controllers; public sealed class TimelineController(ITimelineService timeline) : Controller { public async Task Index([FromQuery] TimelineFilterViewModel filter) { if (filter.ProjectID == 0) { return BadRequest("ProjectID is required."); } var model = await timeline.GetAsync(filter); return model is null ? NotFound() : View(model); } public async Task Settings(int projectId) { var model = await timeline.GetSettingsAsync(projectId); return model is null ? NotFound() : View(model); } [HttpPost] [ValidateAntiForgeryToken] public async Task Settings(TimelineSettingsViewModel model) { if (model.ShowMetricShape && !model.SelectedMetricTypeIDs.Any()) { ModelState.AddModelError(nameof(model.SelectedMetricTypeIDs), "Select at least one metric or disable Metric Shape."); } if (!ModelState.IsValid) { var current = await timeline.GetSettingsAsync(model.ProjectID); if (current is null) { return NotFound(); } current.ShowSceneCards = model.ShowSceneCards; current.ShowMetricShape = model.ShowMetricShape; current.ShowPlotLines = model.ShowPlotLines; current.ShowStoryAssets = model.ShowStoryAssets; current.ShowCharacterAppearances = model.ShowCharacterAppearances; current.ShowWarnings = model.ShowWarnings; current.SelectedMetricTypeIDs = model.SelectedMetricTypeIDs; current.MetricOptions = current.MetricOptions .Select(x => new TimelineMetricSettingOptionViewModel { MetricTypeID = x.MetricTypeID, MetricName = x.MetricName, Description = x.Description, SortOrder = x.SortOrder, IsSelected = model.SelectedMetricTypeIDs.Contains(x.MetricTypeID) }) .ToList(); return View(current); } await timeline.SaveSettingsAsync(model); TempData["TimelineSettingsMessage"] = "Timeline settings saved."; return RedirectToAction(nameof(Settings), new { projectId = model.ProjectID }); } [HttpPost] [ValidateAntiForgeryToken] public async Task SavePreset(TimelinePresetSaveViewModel model) { await timeline.SavePresetAsync(model); return RedirectToAction(nameof(Index), new { projectId = model.ProjectID, bookId = model.BookID }); } public async Task LoadPreset(int id) { var preset = await timeline.GetPresetAsync(id); if (preset is null) { return NotFound(); } var filter = System.Text.Json.JsonSerializer.Deserialize(preset.FilterJson) ?? new TimelineFilterViewModel(); filter.ProjectID = preset.ProjectID; filter.BookID = preset.BookID; filter.FocusType = preset.FocusType; filter.FocusID = preset.FocusID; return RedirectToAction(nameof(Index), filter); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeletePreset(int id, int projectId, int? bookId) { await timeline.ArchivePresetAsync(id); return RedirectToAction(nameof(Index), new { projectId, bookId }); } [HttpPost] [ValidateAntiForgeryToken] public async Task DropMoveScene(TimelineSceneDropMoveViewModel model) { var summary = await timeline.MoveSceneByDropAsync(model); if (summary is not null) { TempData["TimelineDragMessage"] = $"Scene moved. Validation found {summary.WarningCount} warning{(summary.WarningCount == 1 ? "" : "s")}."; } return RedirectToAction(nameof(Index), new { projectId = model.ProjectID, bookId = model.BookID, selectedSceneId = model.SceneID }); } [HttpPost] [ValidateAntiForgeryToken] public async Task DropCharacter(TimelineSceneCharacterDropViewModel model) { await RunDropActionAsync(() => timeline.AddCharacterByDropAsync(model), "Character added to scene."); return RedirectToAction(nameof(Index), new { projectId = model.ReturnProjectID, bookId = model.ReturnBookID, selectedSceneId = model.SceneID }); } [HttpPost] [ValidateAntiForgeryToken] public async Task DropAssetEvent(TimelineAssetEventDropViewModel model) { await RunDropActionAsync(() => timeline.AddAssetEventByDropAsync(model), "Asset event added to scene."); return RedirectToAction(nameof(Index), new { projectId = model.ReturnProjectID, bookId = model.ReturnBookID, selectedSceneId = model.SceneID }); } [HttpPost] [ValidateAntiForgeryToken] public async Task DropAssetDependency(TimelineAssetDependencyDropViewModel model) { await RunDropActionAsync(() => timeline.AddAssetDependencyByDropAsync(model), "Asset dependency saved."); return RedirectToAction(nameof(Index), new { projectId = model.ReturnProjectID, bookId = model.ReturnBookID }); } [HttpPost] [ValidateAntiForgeryToken] public async Task DropLocationRelationship(TimelineLocationRelationshipDropViewModel model) { await RunDropActionAsync(() => timeline.AddLocationRelationshipByDropAsync(model), "Location relationship saved."); return RedirectToAction(nameof(Index), new { projectId = model.ProjectID, bookId = model.ReturnBookID }); } private async Task RunDropActionAsync(Func action, string successMessage) { try { await action(); TempData["TimelineDragMessage"] = successMessage; } catch (InvalidOperationException ex) { TempData["TimelineDragMessage"] = ex.Message; } } }