Phase 12B: Continuity Explorer Page.
This commit is contained in:
parent
eafa9927c7
commit
e7aabe8b41
28
PlotLine/Controllers/ContinuityExplorerController.cs
Normal file
28
PlotLine/Controllers/ContinuityExplorerController.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Controllers;
|
||||
|
||||
[Authorize]
|
||||
public sealed class ContinuityExplorerController(IStoryStateService storyState) : Controller
|
||||
{
|
||||
public async Task<IActionResult> Index([FromQuery] ContinuityFilter filter)
|
||||
{
|
||||
if (filter.ProjectID == 0)
|
||||
{
|
||||
return BadRequest("ProjectID is required.");
|
||||
}
|
||||
|
||||
var model = await storyState.GetContinuityExplorerAsync(filter);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public IActionResult ApplyFilters(ContinuityFilter filter)
|
||||
{
|
||||
return RedirectToAction(nameof(Index), filter);
|
||||
}
|
||||
}
|
||||
@ -114,6 +114,10 @@ public interface ISceneService
|
||||
public interface IStoryStateService
|
||||
{
|
||||
Task<StoryStateViewModel> GetStoryStateAsync(int sceneId);
|
||||
Task<ContinuityExplorerViewModel?> GetContinuityExplorerAsync(ContinuityFilter filter);
|
||||
Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter);
|
||||
Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter);
|
||||
Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter);
|
||||
}
|
||||
|
||||
public interface IPlotService
|
||||
@ -2204,6 +2208,7 @@ public sealed class TimelineService(
|
||||
}
|
||||
|
||||
public sealed class StoryStateService(
|
||||
IProjectRepository projects,
|
||||
IBookRepository books,
|
||||
IChapterRepository chapters,
|
||||
ISceneRepository scenes,
|
||||
@ -2227,7 +2232,7 @@ public sealed class StoryStateService(
|
||||
return new StoryStateViewModel();
|
||||
}
|
||||
|
||||
var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, book.BookID, false, [], false);
|
||||
var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, null, false, [], false);
|
||||
var orderedScenes = OrderScenes(timeline).ToList();
|
||||
var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId);
|
||||
if (currentSceneIndex < 0)
|
||||
@ -2235,22 +2240,252 @@ public sealed class StoryStateService(
|
||||
return new StoryStateViewModel();
|
||||
}
|
||||
|
||||
var sceneIndexes = orderedScenes
|
||||
.Select((scene, index) => new { scene.SceneID, Index = index })
|
||||
.ToDictionary(x => x.SceneID, x => x.Index);
|
||||
var sceneIndexes = BuildSceneIndexes(orderedScenes);
|
||||
var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList();
|
||||
var allowedSceneIds = scenesThroughCurrent.Select(x => x.SceneID).ToHashSet();
|
||||
var allLocations = await locations.ListByProjectAsync(book.ProjectID);
|
||||
|
||||
var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, book.BookID);
|
||||
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, book.BookID);
|
||||
var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, null);
|
||||
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, null);
|
||||
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent);
|
||||
var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets);
|
||||
|
||||
return new StoryStateViewModel
|
||||
var storyState = new StoryStateViewModel
|
||||
{
|
||||
Characters = BuildCharacterState(characterTimeline.Characters, characterTimeline.Appearances, sceneId, allowedSceneIds, sceneIndexes),
|
||||
Assets = BuildAssetState(assetTimeline.Assets, assetTimeline.Events, assetLocations, assetCustody, sceneId, allowedSceneIds, sceneIndexes, orderedScenes.ToDictionary(x => x.SceneID))
|
||||
};
|
||||
storyState.Locations = BuildLocationState(allLocations, storyState);
|
||||
return storyState;
|
||||
}
|
||||
|
||||
public async Task<ContinuityExplorerViewModel?> GetContinuityExplorerAsync(ContinuityFilter filter)
|
||||
{
|
||||
NormaliseFilter(filter);
|
||||
var project = await projects.GetAsync(filter.ProjectID);
|
||||
if (project is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var timeline = await timelineRepository.GetByProjectAsync(filter.ProjectID, null, false, [], false);
|
||||
var orderedScenes = OrderScenes(timeline).ToList();
|
||||
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
|
||||
var allCharacters = await characters.ListCharactersAsync(filter.ProjectID);
|
||||
var allAssets = await assets.ListAssetsAsync(filter.ProjectID);
|
||||
var allLocations = await locations.ListByProjectAsync(filter.ProjectID);
|
||||
filter.SelectedSceneID ??= scopedScenes.FirstOrDefault()?.SceneID ?? orderedScenes.FirstOrDefault()?.SceneID;
|
||||
|
||||
var model = new ContinuityExplorerViewModel
|
||||
{
|
||||
Project = project,
|
||||
Filter = filter,
|
||||
BookOptions = timeline.Books.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == filter.BookID)).ToList(),
|
||||
ChapterOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterID)).ToList(),
|
||||
SceneOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber:g}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SelectedSceneID)).ToList(),
|
||||
CharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), filter.CharacterIDs.Contains(x.CharacterID))).ToList(),
|
||||
AssetOptions = allAssets.Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString(), filter.AssetIDs.Contains(x.StoryAssetID))).ToList(),
|
||||
LocationOptions = allLocations.Select(x => new SelectListItem(x.LocationPath, x.LocationID.ToString(), filter.LocationIDs.Contains(x.LocationID))).ToList()
|
||||
};
|
||||
|
||||
if (string.Equals(filter.DisplayMode, "StoryState", StringComparison.OrdinalIgnoreCase) && filter.SelectedSceneID.HasValue)
|
||||
{
|
||||
model.StoryState = await GetStoryStateAsync(filter.SelectedSceneID.Value);
|
||||
FilterStoryState(model.StoryState, filter);
|
||||
}
|
||||
else if (string.Equals(filter.DisplayMode, "Journey", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var characterIds = filter.IncludeCharacters
|
||||
? (filter.CharacterIDs.Any() ? filter.CharacterIDs : allCharacters.Select(x => x.CharacterID)).ToList()
|
||||
: [];
|
||||
var assetIds = filter.IncludeAssets
|
||||
? (filter.AssetIDs.Any() ? filter.AssetIDs : allAssets.Select(x => x.StoryAssetID)).ToList()
|
||||
: [];
|
||||
|
||||
var characterJourney = new List<CharacterJourneyViewModel>();
|
||||
foreach (var characterId in characterIds)
|
||||
{
|
||||
characterJourney.AddRange(await GetCharacterJourneyAsync(filter.ProjectID, characterId, filter));
|
||||
}
|
||||
|
||||
var assetJourney = new List<AssetJourneyViewModel>();
|
||||
foreach (var assetId in assetIds)
|
||||
{
|
||||
assetJourney.AddRange(await GetAssetJourneyAsync(filter.ProjectID, assetId, filter));
|
||||
}
|
||||
|
||||
model.CharacterJourney = characterJourney;
|
||||
model.AssetJourney = assetJourney;
|
||||
}
|
||||
else if (string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var locationIds = filter.IncludeLocations
|
||||
? (filter.LocationIDs.Any() ? filter.LocationIDs : allLocations.Select(x => x.LocationID)).ToList()
|
||||
: [];
|
||||
|
||||
var activity = new List<LocationActivityViewModel>();
|
||||
foreach (var locationId in locationIds)
|
||||
{
|
||||
activity.AddRange(await GetLocationActivityAsync(filter.ProjectID, locationId, filter));
|
||||
}
|
||||
|
||||
model.LocationActivity = activity
|
||||
.OrderBy(x => scopedScenes.FindIndex(scene => scene.SceneID == x.SceneID))
|
||||
.ThenBy(x => x.LocationName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
public async Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter)
|
||||
{
|
||||
var context = await GetRangeContextAsync(projectId, filter);
|
||||
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
|
||||
var character = characterTimeline.Characters.FirstOrDefault(x => x.CharacterID == characterId);
|
||||
if (character is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var rows = characterTimeline.Appearances
|
||||
.Where(x => x.CharacterID == characterId && x.LocationID.HasValue && context.AllowedSceneIds.Contains(x.SceneID))
|
||||
.GroupBy(x => x.SceneID)
|
||||
.Select(x => x.OrderByDescending(row => row.UpdatedDate).First())
|
||||
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
||||
.ToList();
|
||||
var result = new List<CharacterJourneyViewModel>();
|
||||
int? previousLocationId = null;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row.LocationID == previousLocationId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
previousLocationId = row.LocationID;
|
||||
result.Add(new CharacterJourneyViewModel
|
||||
{
|
||||
CharacterID = character.CharacterID,
|
||||
CharacterName = character.CharacterName,
|
||||
SceneID = row.SceneID,
|
||||
SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, $"Scene {row.SceneNumber:g}"),
|
||||
Location = row.LocationPath ?? row.LocationName ?? "Unknown"
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter)
|
||||
{
|
||||
var context = await GetRangeContextAsync(projectId, filter);
|
||||
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
|
||||
var asset = assetTimeline.Assets.FirstOrDefault(x => x.StoryAssetID == assetId);
|
||||
if (asset is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes);
|
||||
var assetCustody = await assets.ListCustodyByAssetAsync(assetId);
|
||||
var assignments = BuildAssetAssignments(assetTimeline.Events, assetLocations, assetCustody, context.AllowedSceneIds);
|
||||
var rows = assignments
|
||||
.Where(x => x.StoryAssetID == assetId)
|
||||
.GroupBy(x => x.SceneID)
|
||||
.Select(x => x.OrderByDescending(row => row.Priority).ThenByDescending(row => row.UpdatedDate).First())
|
||||
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
||||
.ToList();
|
||||
var result = new List<AssetJourneyViewModel>();
|
||||
string? previousLabel = null;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (string.Equals(previousLabel, row.Label, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
previousLabel = row.Label;
|
||||
result.Add(new AssetJourneyViewModel
|
||||
{
|
||||
StoryAssetID = asset.StoryAssetID,
|
||||
AssetName = asset.AssetName,
|
||||
SceneID = row.SceneID,
|
||||
SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, "Unknown scene"),
|
||||
HolderOrLocation = row.Label
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter)
|
||||
{
|
||||
var context = await GetRangeContextAsync(projectId, filter);
|
||||
var allLocations = await locations.ListByProjectAsync(projectId);
|
||||
var location = allLocations.FirstOrDefault(x => x.LocationID == locationId);
|
||||
if (location is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
|
||||
var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes);
|
||||
var characterIds = filter.CharacterIDs.ToHashSet();
|
||||
var assetIds = filter.AssetIDs.ToHashSet();
|
||||
var result = new List<LocationActivityViewModel>();
|
||||
|
||||
foreach (var scene in context.ScopedScenes)
|
||||
{
|
||||
var sceneCharacters = filter.IncludeCharacters
|
||||
? characterTimeline.Appearances
|
||||
.Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!characterIds.Any() || characterIds.Contains(x.CharacterID)))
|
||||
.Select(x => x.CharacterName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x)
|
||||
.ToList()
|
||||
: [];
|
||||
var sceneAssets = filter.IncludeAssets
|
||||
? assetLocations
|
||||
.Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!assetIds.Any() || assetIds.Contains(x.StoryAssetID)))
|
||||
.Select(x => x.AssetName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x)
|
||||
.ToList()
|
||||
: [];
|
||||
|
||||
if (!sceneCharacters.Any() && !sceneAssets.Any())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new LocationActivityViewModel
|
||||
{
|
||||
LocationID = location.LocationID,
|
||||
LocationName = location.LocationPath,
|
||||
SceneID = scene.SceneID,
|
||||
SceneLabel = SceneLabel(scene.SceneNumber, scene.SceneTitle),
|
||||
CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters) : "None",
|
||||
AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets) : "None"
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<RangeContext> GetRangeContextAsync(int projectId, ContinuityFilter filter)
|
||||
{
|
||||
NormaliseFilter(filter);
|
||||
var timeline = await timelineRepository.GetByProjectAsync(projectId, null, false, [], false);
|
||||
var orderedScenes = OrderScenes(timeline).ToList();
|
||||
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
|
||||
return new RangeContext(
|
||||
scopedScenes,
|
||||
scopedScenes.Select(x => x.SceneID).ToHashSet(),
|
||||
BuildSceneIndexes(orderedScenes),
|
||||
orderedScenes.ToDictionary(x => x.SceneID, x => SceneLabel(x.SceneNumber, x.SceneTitle)));
|
||||
}
|
||||
|
||||
private static IEnumerable<Scene> OrderScenes(TimelineData timeline)
|
||||
@ -2267,6 +2502,36 @@ public sealed class StoryStateService(
|
||||
.ThenBy(scene => scene.SceneID);
|
||||
}
|
||||
|
||||
private static IEnumerable<Scene> ApplySceneRange(IReadOnlyList<Scene> orderedScenes, IReadOnlyList<Chapter> chapters, ContinuityFilter filter)
|
||||
{
|
||||
if (string.Equals(filter.SceneRangeMode, "Book", StringComparison.OrdinalIgnoreCase) && filter.BookID.HasValue)
|
||||
{
|
||||
var chapterIds = chapters.Where(x => x.BookID == filter.BookID.Value).Select(x => x.ChapterID).ToHashSet();
|
||||
return orderedScenes.Where(x => chapterIds.Contains(x.ChapterID));
|
||||
}
|
||||
|
||||
if (string.Equals(filter.SceneRangeMode, "Chapter", StringComparison.OrdinalIgnoreCase) && filter.ChapterID.HasValue)
|
||||
{
|
||||
return orderedScenes.Where(x => x.ChapterID == filter.ChapterID.Value);
|
||||
}
|
||||
|
||||
if (string.Equals(filter.SceneRangeMode, "Custom", StringComparison.OrdinalIgnoreCase) && (filter.StartSceneID.HasValue || filter.EndSceneID.HasValue))
|
||||
{
|
||||
var startIndex = filter.StartSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.StartSceneID.Value) : 0;
|
||||
var endIndex = filter.EndSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.EndSceneID.Value) : orderedScenes.Count - 1;
|
||||
startIndex = startIndex < 0 ? 0 : startIndex;
|
||||
endIndex = endIndex < 0 ? orderedScenes.Count - 1 : endIndex;
|
||||
if (startIndex > endIndex)
|
||||
{
|
||||
(startIndex, endIndex) = (endIndex, startIndex);
|
||||
}
|
||||
|
||||
return orderedScenes.Skip(startIndex).Take(endIndex - startIndex + 1);
|
||||
}
|
||||
|
||||
return orderedScenes;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SceneAssetLocation>> GetAssetLocationsThroughCurrentAsync(IReadOnlyList<Scene> scenesThroughCurrent)
|
||||
{
|
||||
var rows = new List<SceneAssetLocation>();
|
||||
@ -2324,6 +2589,7 @@ public sealed class StoryStateService(
|
||||
{
|
||||
CharacterID = character.CharacterID,
|
||||
CharacterName = character.CharacterName,
|
||||
LocationID = latest.LocationID,
|
||||
CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown",
|
||||
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
||||
LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle)
|
||||
@ -2342,19 +2608,7 @@ public sealed class StoryStateService(
|
||||
IReadOnlyDictionary<int, int> sceneIndexes,
|
||||
IReadOnlyDictionary<int, Scene> scenesById)
|
||||
{
|
||||
var stateAssignments = assetEvents
|
||||
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName))
|
||||
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.ToStateName!, 1, x.UpdatedDate));
|
||||
var locationAssignments = assetLocations
|
||||
.Where(x => allowedSceneIds.Contains(x.SceneID))
|
||||
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationPath, 2, x.UpdatedDate));
|
||||
var custodyAssignments = assetCustody
|
||||
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary))
|
||||
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.CustodianSummary!, 3, x.UpdatedDate));
|
||||
var assignments = stateAssignments
|
||||
.Concat(locationAssignments)
|
||||
.Concat(custodyAssignments)
|
||||
.ToList();
|
||||
var assignments = BuildAssetAssignments(assetEvents, assetLocations, assetCustody, allowedSceneIds);
|
||||
|
||||
return projectAssets
|
||||
.OrderByDescending(x => x.Importance)
|
||||
@ -2381,6 +2635,7 @@ public sealed class StoryStateService(
|
||||
{
|
||||
StoryAssetID = asset.StoryAssetID,
|
||||
AssetName = asset.AssetName,
|
||||
LocationID = latest.LocationID,
|
||||
CurrentHolderOrLocation = latest.Label,
|
||||
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
||||
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
|
||||
@ -2391,6 +2646,92 @@ public sealed class StoryStateService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<AssetStateAssignment> BuildAssetAssignments(
|
||||
IReadOnlyList<AssetEvent> assetEvents,
|
||||
IReadOnlyList<SceneAssetLocation> assetLocations,
|
||||
IReadOnlyList<AssetCustodyEvent> assetCustody,
|
||||
IReadOnlySet<int> allowedSceneIds)
|
||||
{
|
||||
var stateAssignments = assetEvents
|
||||
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName))
|
||||
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.ToStateName!, 1, x.UpdatedDate));
|
||||
var locationAssignments = assetLocations
|
||||
.Where(x => allowedSceneIds.Contains(x.SceneID))
|
||||
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationID, x.LocationPath, 2, x.UpdatedDate));
|
||||
var custodyAssignments = assetCustody
|
||||
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary))
|
||||
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.CustodianSummary!, 3, x.UpdatedDate));
|
||||
|
||||
return stateAssignments.Concat(locationAssignments).Concat(custodyAssignments).ToList();
|
||||
}
|
||||
|
||||
private static List<LocationStoryStateViewModel> BuildLocationState(IReadOnlyList<LocationItem> allLocations, StoryStateViewModel storyState)
|
||||
{
|
||||
return allLocations
|
||||
.OrderBy(x => x.LocationPath)
|
||||
.Select(location => new LocationStoryStateViewModel
|
||||
{
|
||||
LocationID = location.LocationID,
|
||||
LocationName = location.LocationName,
|
||||
LocationPath = location.LocationPath,
|
||||
CharactersPresent = storyState.Characters
|
||||
.Where(x => x.LocationID == location.LocationID)
|
||||
.Select(x => x.CharacterName)
|
||||
.OrderBy(x => x)
|
||||
.ToList(),
|
||||
AssetsPresent = storyState.Assets
|
||||
.Where(x => x.LocationID == location.LocationID)
|
||||
.Select(x => x.AssetName)
|
||||
.OrderBy(x => x)
|
||||
.ToList()
|
||||
})
|
||||
.Where(x => x.CharactersPresent.Any() || x.AssetsPresent.Any())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void FilterStoryState(StoryStateViewModel storyState, ContinuityFilter filter)
|
||||
{
|
||||
storyState.Characters = filter.IncludeCharacters
|
||||
? storyState.Characters.Where(x => !filter.CharacterIDs.Any() || filter.CharacterIDs.Contains(x.CharacterID)).ToList()
|
||||
: [];
|
||||
storyState.Assets = filter.IncludeAssets
|
||||
? storyState.Assets.Where(x => !filter.AssetIDs.Any() || filter.AssetIDs.Contains(x.StoryAssetID)).ToList()
|
||||
: [];
|
||||
storyState.Locations = filter.IncludeLocations
|
||||
? storyState.Locations.Where(x => !filter.LocationIDs.Any() || filter.LocationIDs.Contains(x.LocationID)).ToList()
|
||||
: [];
|
||||
}
|
||||
|
||||
private static Dictionary<int, int> BuildSceneIndexes(IReadOnlyList<Scene> orderedScenes)
|
||||
{
|
||||
return orderedScenes
|
||||
.Select((scene, index) => new { scene.SceneID, Index = index })
|
||||
.ToDictionary(x => x.SceneID, x => x.Index);
|
||||
}
|
||||
|
||||
private static void NormaliseFilter(ContinuityFilter filter)
|
||||
{
|
||||
if (filter.ProjectID <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filter.EntityTypes.Any())
|
||||
{
|
||||
filter.EntityTypes = ["Characters", "Assets", "Locations"];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(filter.SceneRangeMode))
|
||||
{
|
||||
filter.SceneRangeMode = "EntireProject";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(filter.DisplayMode))
|
||||
{
|
||||
filter.DisplayMode = "StoryState";
|
||||
}
|
||||
}
|
||||
|
||||
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(sceneTitle)
|
||||
@ -2401,9 +2742,16 @@ public sealed class StoryStateService(
|
||||
private sealed record AssetStateAssignment(
|
||||
int StoryAssetID,
|
||||
int SceneID,
|
||||
int? LocationID,
|
||||
string Label,
|
||||
int Priority,
|
||||
DateTime UpdatedDate);
|
||||
|
||||
private sealed record RangeContext(
|
||||
IReadOnlyList<Scene> ScopedScenes,
|
||||
IReadOnlySet<int> AllowedSceneIds,
|
||||
IReadOnlyDictionary<int, int> SceneIndexes,
|
||||
IReadOnlyDictionary<int, string> SceneLabels);
|
||||
}
|
||||
|
||||
public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots, IProjectActivityService activity) : IPlotService
|
||||
|
||||
@ -241,12 +241,14 @@ public sealed class StoryStateViewModel
|
||||
{
|
||||
public List<CharacterStoryStateViewModel> Characters { get; set; } = [];
|
||||
public List<AssetStoryStateViewModel> Assets { get; set; } = [];
|
||||
public List<LocationStoryStateViewModel> Locations { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CharacterStoryStateViewModel
|
||||
{
|
||||
public int CharacterID { get; set; }
|
||||
public string CharacterName { get; set; } = string.Empty;
|
||||
public int? LocationID { get; set; }
|
||||
public string CurrentLocation { get; set; } = "Unknown";
|
||||
public string State { get; set; } = "Unknown";
|
||||
public string LastConfirmed { get; set; } = "Never";
|
||||
@ -256,11 +258,85 @@ public sealed class AssetStoryStateViewModel
|
||||
{
|
||||
public int StoryAssetID { get; set; }
|
||||
public string AssetName { get; set; } = string.Empty;
|
||||
public int? LocationID { get; set; }
|
||||
public string CurrentHolderOrLocation { get; set; } = "Unknown";
|
||||
public string State { get; set; } = "Unknown";
|
||||
public string LastConfirmed { get; set; } = "Never";
|
||||
}
|
||||
|
||||
public sealed class LocationStoryStateViewModel
|
||||
{
|
||||
public int LocationID { get; set; }
|
||||
public string LocationName { get; set; } = string.Empty;
|
||||
public string LocationPath { get; set; } = string.Empty;
|
||||
public List<string> CharactersPresent { get; set; } = [];
|
||||
public List<string> AssetsPresent { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ContinuityFilter
|
||||
{
|
||||
public int ProjectID { get; set; }
|
||||
public List<string> EntityTypes { get; set; } = ["Characters", "Assets", "Locations"];
|
||||
public List<int> CharacterIDs { get; set; } = [];
|
||||
public List<int> AssetIDs { get; set; } = [];
|
||||
public List<int> LocationIDs { get; set; } = [];
|
||||
public string SceneRangeMode { get; set; } = "EntireProject";
|
||||
public int? BookID { get; set; }
|
||||
public int? ChapterID { get; set; }
|
||||
public int? StartSceneID { get; set; }
|
||||
public int? EndSceneID { get; set; }
|
||||
public string DisplayMode { get; set; } = "StoryState";
|
||||
public int? SelectedSceneID { get; set; }
|
||||
|
||||
public bool IncludeCharacters => EntityTypes.Contains("Characters", StringComparer.OrdinalIgnoreCase);
|
||||
public bool IncludeAssets => EntityTypes.Contains("Assets", StringComparer.OrdinalIgnoreCase);
|
||||
public bool IncludeLocations => EntityTypes.Contains("Locations", StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class ContinuityExplorerViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
public ContinuityFilter Filter { get; set; } = new();
|
||||
public StoryStateViewModel StoryState { get; set; } = new();
|
||||
public IReadOnlyList<CharacterJourneyViewModel> CharacterJourney { get; set; } = [];
|
||||
public IReadOnlyList<AssetJourneyViewModel> AssetJourney { get; set; } = [];
|
||||
public IReadOnlyList<LocationActivityViewModel> LocationActivity { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> AssetOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> LocationOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CharacterJourneyViewModel
|
||||
{
|
||||
public int CharacterID { get; set; }
|
||||
public string CharacterName { get; set; } = string.Empty;
|
||||
public int SceneID { get; set; }
|
||||
public string SceneLabel { get; set; } = string.Empty;
|
||||
public string Location { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class AssetJourneyViewModel
|
||||
{
|
||||
public int StoryAssetID { get; set; }
|
||||
public string AssetName { get; set; } = string.Empty;
|
||||
public int SceneID { get; set; }
|
||||
public string SceneLabel { get; set; } = string.Empty;
|
||||
public string HolderOrLocation { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class LocationActivityViewModel
|
||||
{
|
||||
public int LocationID { get; set; }
|
||||
public string LocationName { get; set; } = string.Empty;
|
||||
public int SceneID { get; set; }
|
||||
public string SceneLabel { get; set; } = string.Empty;
|
||||
public string CharactersPresent { get; set; } = string.Empty;
|
||||
public string AssetsPresent { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SceneWorkflowEditViewModel
|
||||
{
|
||||
public int SceneID { get; set; }
|
||||
|
||||
300
PlotLine/Views/ContinuityExplorer/Index.cshtml
Normal file
300
PlotLine/Views/ContinuityExplorer/Index.cshtml
Normal file
@ -0,0 +1,300 @@
|
||||
@model ContinuityExplorerViewModel
|
||||
@{
|
||||
ViewData["Title"] = $"Continuity Explorer - {Model.Project.ProjectName}";
|
||||
ViewData["ProjectSection"] = "Continuity Explorer";
|
||||
}
|
||||
|
||||
@functions {
|
||||
private static string StateBadgeClass(string state) => state switch
|
||||
{
|
||||
"Explicit" => "bg-success",
|
||||
"Inferred" => "bg-secondary",
|
||||
_ => "bg-warning text-dark"
|
||||
};
|
||||
|
||||
private bool IsEntitySelected(string entityType) =>
|
||||
Model.Filter.EntityTypes.Contains(entityType, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
<a asp-controller="Projects" asp-action="Index">Projects</a>
|
||||
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
|
||||
<span>Continuity Explorer</span>
|
||||
</nav>
|
||||
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Continuity</p>
|
||||
<h1>Continuity Explorer</h1>
|
||||
<p class="lead-text">Investigate where characters, assets, and locations stand across the story.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Filters</strong>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#continuity-filter-panel" aria-expanded="true" aria-controls="continuity-filter-panel">Toggle</button>
|
||||
</div>
|
||||
<div class="collapse show" id="continuity-filter-panel">
|
||||
<div class="card-body">
|
||||
<form asp-action="ApplyFilters" method="post">
|
||||
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label">Entity types</label>
|
||||
<div class="d-grid gap-1">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Characters" checked="@IsEntitySelected("Characters")" />
|
||||
<span class="form-check-label">Characters</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Assets" checked="@IsEntitySelected("Assets")" />
|
||||
<span class="form-check-label">Assets</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Locations" checked="@IsEntitySelected("Locations")" />
|
||||
<span class="form-check-label">Locations</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-9">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="CharacterIDs">Characters</label>
|
||||
<select class="form-select form-select-sm" id="CharacterIDs" name="CharacterIDs" asp-items="Model.CharacterOptions" multiple size="6"></select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="AssetIDs">Assets</label>
|
||||
<select class="form-select form-select-sm" id="AssetIDs" name="AssetIDs" asp-items="Model.AssetOptions" multiple size="6"></select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="LocationIDs">Locations</label>
|
||||
<select class="form-select form-select-sm" id="LocationIDs" name="LocationIDs" asp-items="Model.LocationOptions" multiple size="6"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label">Scene range</label>
|
||||
<div class="d-grid gap-1">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="radio" name="SceneRangeMode" value="EntireProject" checked="@(Model.Filter.SceneRangeMode == "EntireProject")" />
|
||||
<span class="form-check-label">Entire Project</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Book" checked="@(Model.Filter.SceneRangeMode == "Book")" />
|
||||
<span class="form-check-label">Book</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Chapter" checked="@(Model.Filter.SceneRangeMode == "Chapter")" />
|
||||
<span class="form-check-label">Chapter</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Custom" checked="@(Model.Filter.SceneRangeMode == "Custom")" />
|
||||
<span class="form-check-label">Custom Scene Range</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="BookID">Book</label>
|
||||
<select class="form-select form-select-sm" id="BookID" name="BookID" asp-items="Model.BookOptions">
|
||||
<option value="">Any book</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="ChapterID">Chapter</label>
|
||||
<select class="form-select form-select-sm" id="ChapterID" name="ChapterID" asp-items="Model.ChapterOptions">
|
||||
<option value="">Any chapter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="StartSceneID">Start scene</label>
|
||||
<select class="form-select form-select-sm" id="StartSceneID" name="StartSceneID" asp-items="Model.SceneOptions">
|
||||
<option value="">First scene</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="EndSceneID">End scene</label>
|
||||
<select class="form-select form-select-sm" id="EndSceneID" name="EndSceneID" asp-items="Model.SceneOptions">
|
||||
<option value="">Last scene</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label">Display mode</label>
|
||||
<div class="d-grid gap-1">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="radio" name="DisplayMode" value="StoryState" checked="@(Model.Filter.DisplayMode == "StoryState")" />
|
||||
<span class="form-check-label">Story State</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="radio" name="DisplayMode" value="Journey" checked="@(Model.Filter.DisplayMode == "Journey")" />
|
||||
<span class="form-check-label">Journey</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="radio" name="DisplayMode" value="LocationActivity" checked="@(Model.Filter.DisplayMode == "LocationActivity")" />
|
||||
<span class="form-check-label">Location Activity</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<label class="form-label" for="SelectedSceneID">Story State scene</label>
|
||||
<select class="form-select form-select-sm" id="SelectedSceneID" name="SelectedSceneID" asp-items="Model.SceneOptions"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Apply filters</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (Model.Filter.DisplayMode == "StoryState")
|
||||
{
|
||||
<section class="story-bible-section">
|
||||
<h2>Story State</h2>
|
||||
@if (!Model.StoryState.Characters.Any() && !Model.StoryState.Assets.Any() && !Model.StoryState.Locations.Any())
|
||||
{
|
||||
<p class="muted">No matching continuity information found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (Model.Filter.IncludeCharacters)
|
||||
{
|
||||
<h3>Characters</h3>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Character</th><th>Current Location</th><th>State</th><th>Last Confirmed</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var row in Model.StoryState.Characters)
|
||||
{
|
||||
<tr><td>@row.CharacterName</td><td>@row.CurrentLocation</td><td><span class="badge @StateBadgeClass(row.State)">@row.State</span></td><td>@row.LastConfirmed</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Filter.IncludeAssets)
|
||||
{
|
||||
<h3>Assets</h3>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Asset</th><th>Holder / Location</th><th>State</th><th>Last Confirmed</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var row in Model.StoryState.Assets)
|
||||
{
|
||||
<tr><td>@row.AssetName</td><td>@row.CurrentHolderOrLocation</td><td><span class="badge @StateBadgeClass(row.State)">@row.State</span></td><td>@row.LastConfirmed</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Filter.IncludeLocations)
|
||||
{
|
||||
<h3>Locations</h3>
|
||||
@if (!Model.StoryState.Locations.Any())
|
||||
{
|
||||
<p class="muted">No matching location presence found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
@foreach (var location in Model.StoryState.Locations)
|
||||
{
|
||||
<div class="col-lg-6">
|
||||
<article class="story-bible-card h-100">
|
||||
<h3>@location.LocationPath</h3>
|
||||
<p><strong>Characters:</strong> @(location.CharactersPresent.Any() ? string.Join(", ", location.CharactersPresent) : "None")</p>
|
||||
<p><strong>Assets:</strong> @(location.AssetsPresent.Any() ? string.Join(", ", location.AssetsPresent) : "None")</p>
|
||||
</article>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
else if (Model.Filter.DisplayMode == "Journey")
|
||||
{
|
||||
<section class="story-bible-section">
|
||||
<h2>Journey</h2>
|
||||
@if (!Model.CharacterJourney.Any() && !Model.AssetJourney.Any())
|
||||
{
|
||||
<p class="muted">No movement recorded.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var group in Model.CharacterJourney.GroupBy(x => new { x.CharacterID, x.CharacterName }))
|
||||
{
|
||||
<h3>@group.Key.CharacterName</h3>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Scene</th><th>Location</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var row in group)
|
||||
{
|
||||
<tr><td>@row.SceneLabel</td><td>@row.Location</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var group in Model.AssetJourney.GroupBy(x => new { x.StoryAssetID, x.AssetName }))
|
||||
{
|
||||
<h3>@group.Key.AssetName</h3>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Scene</th><th>Holder / Location</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var row in group)
|
||||
{
|
||||
<tr><td>@row.SceneLabel</td><td>@row.HolderOrLocation</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="story-bible-section">
|
||||
<h2>Location Activity</h2>
|
||||
@if (!Model.LocationActivity.Any())
|
||||
{
|
||||
<p class="muted">No matching continuity information found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Location</th><th>Scene</th><th>Characters Present</th><th>Assets Present</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var row in Model.LocationActivity)
|
||||
{
|
||||
<tr><td>@row.LocationName</td><td>@row.SceneLabel</td><td>@row.CharactersPresent</td><td>@row.AssetsPresent</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@ -20,6 +20,7 @@
|
||||
<a class="pw-project-nav__link @(ActiveClass("Metrics"))" asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@projectId">Metrics</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Scenarios"))" asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@projectId">Scenarios</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Story Bible"))" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@projectId">Story Bible</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Continuity Explorer"))" asp-controller="ContinuityExplorer" asp-action="Index" asp-route-projectId="@projectId">Continuity Explorer</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Health"))" asp-controller="Analytics" asp-action="Index" asp-route-projectId="@projectId">Health</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Warnings"))" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@projectId">Warnings</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Archive"))" asp-controller="Archives" asp-action="Index" asp-route-projectId="@projectId">Archive</a>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user