diff --git a/PlotLine/Controllers/CharactersController.cs b/PlotLine/Controllers/CharactersController.cs index 4465d30..58a9c6f 100644 --- a/PlotLine/Controllers/CharactersController.cs +++ b/PlotLine/Controllers/CharactersController.cs @@ -44,8 +44,16 @@ public sealed class CharactersController(ICharacterService characters) : Control { if (!ModelState.IsValid) { - model.Project ??= (await characters.GetCreateCharacterAsync(model.ProjectID))?.Project; - return View("Edit", model); + var editModel = model.CharacterID == 0 + ? await characters.GetCreateCharacterAsync(model.ProjectID) + : await characters.GetEditCharacterAsync(model.CharacterID); + if (editModel is null) + { + return NotFound(); + } + + CopyCharacterFormValues(model, editModel); + return View("Edit", editModel); } int characterId; @@ -64,7 +72,9 @@ public sealed class CharactersController(ICharacterService characters) : Control createModel.CharacterName = model.CharacterName; createModel.ShortName = model.ShortName; + createModel.SexValueID = model.SexValueID; createModel.Sex = model.Sex; + createModel.CustomSex = model.CustomSex; createModel.BirthDate = model.BirthDate; createModel.AgeAtSeriesStart = model.AgeAtSeriesStart; createModel.Height = model.Height; @@ -92,7 +102,9 @@ public sealed class CharactersController(ICharacterService characters) : Control editModel.CharacterName = model.CharacterName; editModel.ShortName = model.ShortName; + editModel.SexValueID = model.SexValueID; editModel.Sex = model.Sex; + editModel.CustomSex = model.CustomSex; editModel.BirthDate = model.BirthDate; editModel.AgeAtSeriesStart = model.AgeAtSeriesStart; editModel.Height = model.Height; @@ -106,6 +118,22 @@ public sealed class CharactersController(ICharacterService characters) : Control return RedirectToAction(nameof(Details), new { id = characterId }); } + private static void CopyCharacterFormValues(CharacterEditViewModel source, CharacterEditViewModel target) + { + target.CharacterName = source.CharacterName; + target.ShortName = source.ShortName; + target.SexValueID = source.SexValueID; + target.Sex = source.Sex; + target.CustomSex = source.CustomSex; + target.BirthDate = source.BirthDate; + target.AgeAtSeriesStart = source.AgeAtSeriesStart; + target.Height = source.Height; + target.EyeColour = source.EyeColour; + target.CharacterImportance = source.CharacterImportance; + target.ShowInQuickAddBar = source.ShowInQuickAddBar; + target.DefaultDescription = source.DefaultDescription; + } + [HttpPost] [ValidateAntiForgeryToken] public async Task UploadImage(CharacterImageUploadViewModel model) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 0892c35..b27f095 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -182,6 +182,9 @@ public interface ICharacterRepository Task> ListCharactersAsync(int projectId); Task GetCharacterAsync(int characterId); Task SaveCharacterAsync(Character character); + Task> ListSexValuesAsync(int ownerUserId); + Task GetSexValueAsync(int characterSexValueId, int ownerUserId); + Task GetOrCreateSexValueAsync(int ownerUserId, string sexName); Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath); Task UpdateCharacterAvatarAsync(int characterId, int? sourceCharacterImageId, string? avatarImagePath, string? avatarThumbnailPath); Task> ListCharacterImagesAsync(int characterId); @@ -3542,6 +3545,7 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory) character.ProjectID, character.CharacterName, character.ShortName, + character.SexValueID, character.Sex, character.BirthDate, character.AgeAtSeriesStart, @@ -3557,6 +3561,44 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory) commandType: CommandType.StoredProcedure); } + public async Task> ListSexValuesAsync(int ownerUserId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + SELECT CharacterSexValueID, OwnerUserID, SexName, IsSystem, SortOrder, IsActive + FROM dbo.CharacterSexValues + WHERE IsActive = 1 + AND (IsSystem = 1 OR OwnerUserID = @OwnerUserID) + ORDER BY IsSystem DESC, SortOrder, SexName; + """, + new { OwnerUserID = ownerUserId }); + return rows.ToList(); + } + + public async Task GetSexValueAsync(int characterSexValueId, int ownerUserId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + """ + SELECT CharacterSexValueID, OwnerUserID, SexName, IsSystem, SortOrder, IsActive + FROM dbo.CharacterSexValues + WHERE CharacterSexValueID = @CharacterSexValueID + AND IsActive = 1 + AND (IsSystem = 1 OR OwnerUserID = @OwnerUserID); + """, + new { CharacterSexValueID = characterSexValueId, OwnerUserID = ownerUserId }); + } + + public async Task GetOrCreateSexValueAsync(int ownerUserId, string sexName) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.CharacterSexValue_GetOrCreate", + new { OwnerUserID = ownerUserId, SexName = sexName }, + commandType: CommandType.StoredProcedure); + } + public async Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index f241fe2..c8c89af 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -832,6 +832,7 @@ public sealed class Character public int ProjectID { get; set; } public string CharacterName { get; set; } = string.Empty; public string? ShortName { get; set; } + public int? SexValueID { get; set; } public string? Sex { get; set; } public DateTime? BirthDate { get; set; } public int? AgeAtSeriesStart { get; set; } @@ -853,6 +854,16 @@ public sealed class Character public string? ArchivedReason { get; set; } } +public sealed class CharacterSexValue +{ + public int CharacterSexValueID { get; set; } + public int? OwnerUserID { get; set; } + public string SexName { get; set; } = string.Empty; + public bool IsSystem { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; +} + public sealed class CharacterImage { public int CharacterImageID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index d2f90ac..7a84537 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -2184,6 +2184,7 @@ public sealed class TimelineService( (IReadOnlyList Characters, IReadOnlyList Appearances) characterTimeline = needsCharacterTimelineData ? await TimeTimelineLoadAsync("dbo.CharacterTimeline_GetByProject", () => characters.GetTimelineAsync(projectId, bookId), projectId, bookId) : ([], []); + ApplyTimelineCharacterAvatars(characterTimeline, allCharacters); var plotThreadDetailsById = plotThreads.ToDictionary(x => x.PlotThreadID); foreach (var threadEvent in timeline.ThreadEvents) { @@ -2726,6 +2727,37 @@ public sealed class TimelineService( }).ToList(); } + private static void ApplyTimelineCharacterAvatars( + (IReadOnlyList Characters, IReadOnlyList Appearances) characterTimeline, + IReadOnlyList allCharacters) + { + var characterImages = allCharacters.ToDictionary(x => x.CharacterID); + foreach (var character in characterTimeline.Characters) + { + if (!characterImages.TryGetValue(character.CharacterID, out var source)) + { + continue; + } + + character.ImagePath = source.ImagePath; + character.ThumbnailPath = source.ThumbnailPath; + character.AvatarImagePath = source.AvatarImagePath; + character.AvatarThumbnailPath = source.AvatarThumbnailPath; + character.AvatarSourceCharacterImageID = source.AvatarSourceCharacterImageID; + } + + foreach (var appearance in characterTimeline.Appearances) + { + if (!characterImages.TryGetValue(appearance.CharacterID, out var source)) + { + continue; + } + + appearance.CharacterImagePath = source.AvatarImagePath ?? source.ImagePath; + appearance.CharacterThumbnailPath = source.AvatarThumbnailPath ?? source.ThumbnailPath; + } + } + private static IReadOnlyList BuildCharacterLanes(IReadOnlyList characters, IReadOnlyList appearances, IReadOnlyList orderedScenes) { var appearancesByCharacterAndScene = appearances @@ -8773,6 +8805,7 @@ public sealed class CharacterService( IProjectRepository projects, IBookRepository books, ICharacterRepository characters, + IProjectCollaborationRepository collaboration, ISubscriptionService subscriptions, IProjectActivityService activity, ICurrentUserService currentUser, @@ -8791,7 +8824,9 @@ public sealed class CharacterService( public async Task GetCreateCharacterAsync(int projectId) { var project = await projects.GetAsync(projectId); - return project is null ? null : new CharacterEditViewModel { ProjectID = projectId, Project = project }; + return project is null + ? null + : await PopulateCharacterEditOptionsAsync(new CharacterEditViewModel { ProjectID = projectId, Project = project }); } public async Task GetEditCharacterAsync(int characterId) @@ -8802,12 +8837,13 @@ public sealed class CharacterService( return null; } - return new CharacterEditViewModel + return await PopulateCharacterEditOptionsAsync(new CharacterEditViewModel { CharacterID = character.CharacterID, ProjectID = character.ProjectID, CharacterName = character.CharacterName, ShortName = character.ShortName, + SexValueID = character.SexValueID, Sex = character.Sex, BirthDate = character.BirthDate, AgeAtSeriesStart = character.AgeAtSeriesStart, @@ -8821,7 +8857,7 @@ public sealed class CharacterService( AvatarImagePath = character.AvatarImagePath, AvatarThumbnailPath = character.AvatarThumbnailPath, Project = await projects.GetAsync(character.ProjectID) - }; + }); } public async Task GetCharacterDetailAsync(int characterId) @@ -8853,6 +8889,7 @@ public sealed class CharacterService( { Project = project, Character = character, + ProjectCharacters = projectCharacters, Images = await characters.ListCharacterImagesAsync(character.CharacterID), DisplayAge = DisplayAge(character), Appearances = await GetAppearancesByCharacterAsync(character.CharacterID), @@ -8927,13 +8964,15 @@ public sealed class CharacterService( var isNew = model.CharacterID == 0; var existing = isNew ? null : await characters.GetCharacterAsync(model.CharacterID); + var sex = await ResolveCharacterSexAsync(model.ProjectID, model.SexValueID, model.CustomSex, model.Sex); var characterId = await characters.SaveCharacterAsync(new Character { CharacterID = model.CharacterID, ProjectID = model.ProjectID, CharacterName = model.CharacterName, ShortName = model.ShortName, - Sex = model.Sex, + SexValueID = sex.SexValueID, + Sex = sex.SexName, BirthDate = model.BirthDate, AgeAtSeriesStart = model.AgeAtSeriesStart, Height = model.Height, @@ -8963,6 +9002,55 @@ public sealed class CharacterService( return characterId; } + private async Task PopulateCharacterEditOptionsAsync(CharacterEditViewModel model) + { + var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID); + IReadOnlyList sexValues = ownerUserId.HasValue + ? await characters.ListSexValuesAsync(ownerUserId.Value) + : []; + + model.SexOptions = new[] { new SelectListItem("Not set", string.Empty) } + .Concat(sexValues.Select(value => new SelectListItem(value.SexName, value.CharacterSexValueID.ToString(), value.CharacterSexValueID == model.SexValueID))) + .ToList(); + + if (model.SexValueID is null && !string.IsNullOrWhiteSpace(model.Sex)) + { + var matching = sexValues.FirstOrDefault(value => string.Equals(value.SexName, model.Sex, StringComparison.OrdinalIgnoreCase)); + if (matching is not null) + { + model.SexValueID = matching.CharacterSexValueID; + } + } + + return model; + } + + private async Task<(int? SexValueID, string? SexName)> ResolveCharacterSexAsync(int projectId, int? sexValueId, string? customSex, string? fallbackSex) + { + var ownerUserId = await collaboration.GetOwnerUserIdAsync(projectId); + if (!ownerUserId.HasValue) + { + return (sexValueId, fallbackSex); + } + + if (!string.IsNullOrWhiteSpace(customSex)) + { + var trimmed = customSex.Trim(); + var customId = await characters.GetOrCreateSexValueAsync(ownerUserId.Value, trimmed); + return (customId, trimmed); + } + + if (sexValueId.HasValue) + { + var sexValue = await characters.GetSexValueAsync(sexValueId.Value, ownerUserId.Value); + return sexValue is null + ? (null, null) + : (sexValue.CharacterSexValueID, sexValue.SexName); + } + + return (null, null); + } + public async Task UploadCharacterImageAsync(CharacterImageUploadViewModel model) { var character = await characters.GetCharacterAsync(model.CharacterID); diff --git a/PlotLine/Sql/087_Phase14B_CharacterRelationshipsIdentity.sql b/PlotLine/Sql/087_Phase14B_CharacterRelationshipsIdentity.sql new file mode 100644 index 0000000..c715f5c --- /dev/null +++ b/PlotLine/Sql/087_Phase14B_CharacterRelationshipsIdentity.sql @@ -0,0 +1,250 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.CharacterSexValues', N'U') IS NULL +BEGIN + CREATE TABLE dbo.CharacterSexValues + ( + CharacterSexValueID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterSexValues PRIMARY KEY, + OwnerUserID int NULL, + SexName nvarchar(100) NOT NULL, + IsSystem bit NOT NULL CONSTRAINT DF_CharacterSexValues_IsSystem DEFAULT (0), + SortOrder int NOT NULL CONSTRAINT DF_CharacterSexValues_SortOrder DEFAULT (0), + IsActive bit NOT NULL CONSTRAINT DF_CharacterSexValues_IsActive DEFAULT (1), + CreatedDateUTC datetime2 NOT NULL CONSTRAINT DF_CharacterSexValues_CreatedDateUTC DEFAULT (SYSUTCDATETIME()), + CONSTRAINT FK_CharacterSexValues_AppUser FOREIGN KEY (OwnerUserID) REFERENCES dbo.AppUser(UserID) + ); +END; +GO + +IF COL_LENGTH(N'dbo.Characters', N'SexValueID') IS NULL + ALTER TABLE dbo.Characters ADD SexValueID int NULL; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_Characters_CharacterSexValues') + ALTER TABLE dbo.Characters ADD CONSTRAINT FK_Characters_CharacterSexValues FOREIGN KEY (SexValueID) REFERENCES dbo.CharacterSexValues(CharacterSexValueID); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterSexValues_SystemName' AND object_id = OBJECT_ID(N'dbo.CharacterSexValues')) + CREATE UNIQUE INDEX UX_CharacterSexValues_SystemName ON dbo.CharacterSexValues(SexName) WHERE IsSystem = 1 AND IsActive = 1; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterSexValues_OwnerName' AND object_id = OBJECT_ID(N'dbo.CharacterSexValues')) + CREATE UNIQUE INDEX UX_CharacterSexValues_OwnerName ON dbo.CharacterSexValues(OwnerUserID, SexName) WHERE OwnerUserID IS NOT NULL AND IsActive = 1; +GO + +IF NOT EXISTS (SELECT 1 FROM dbo.CharacterSexValues WHERE IsSystem = 1 AND SexName = N'Male') + INSERT dbo.CharacterSexValues (OwnerUserID, SexName, IsSystem, SortOrder) VALUES (NULL, N'Male', 1, 10); +GO + +IF NOT EXISTS (SELECT 1 FROM dbo.CharacterSexValues WHERE IsSystem = 1 AND SexName = N'Female') + INSERT dbo.CharacterSexValues (OwnerUserID, SexName, IsSystem, SortOrder) VALUES (NULL, N'Female', 1, 20); +GO + +IF NOT EXISTS (SELECT 1 FROM dbo.CharacterSexValues WHERE IsSystem = 1 AND SexName = N'Other') + INSERT dbo.CharacterSexValues (OwnerUserID, SexName, IsSystem, SortOrder) VALUES (NULL, N'Other', 1, 30); +GO + +;WITH CharacterOwners AS +( + SELECT c.CharacterID, ownerAccess.UserID AS OwnerUserID, LTRIM(RTRIM(c.Sex)) AS SexName + FROM dbo.Characters c + INNER JOIN dbo.ProjectUserAccess ownerAccess ON ownerAccess.ProjectID = c.ProjectID + AND ownerAccess.AccessRole = N'Owner' + AND ownerAccess.IsActive = 1 + WHERE NULLIF(LTRIM(RTRIM(c.Sex)), N'') IS NOT NULL +), +CustomSexValues AS +( + SELECT DISTINCT co.OwnerUserID, co.SexName + FROM CharacterOwners co + WHERE NOT EXISTS + ( + SELECT 1 + FROM dbo.CharacterSexValues csv + WHERE csv.IsSystem = 1 + AND csv.IsActive = 1 + AND LOWER(csv.SexName) = LOWER(co.SexName) + ) + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.CharacterSexValues csv + WHERE csv.OwnerUserID = co.OwnerUserID + AND csv.IsActive = 1 + AND LOWER(csv.SexName) = LOWER(co.SexName) + ) +) +INSERT dbo.CharacterSexValues (OwnerUserID, SexName, IsSystem, SortOrder) +SELECT OwnerUserID, SexName, 0, 100 +FROM CustomSexValues; +GO + +;WITH CharacterOwners AS +( + SELECT c.CharacterID, ownerAccess.UserID AS OwnerUserID, LTRIM(RTRIM(c.Sex)) AS SexName + FROM dbo.Characters c + INNER JOIN dbo.ProjectUserAccess ownerAccess ON ownerAccess.ProjectID = c.ProjectID + AND ownerAccess.AccessRole = N'Owner' + AND ownerAccess.IsActive = 1 + WHERE NULLIF(LTRIM(RTRIM(c.Sex)), N'') IS NOT NULL +) +UPDATE c +SET SexValueID = csv.CharacterSexValueID +FROM dbo.Characters c +INNER JOIN CharacterOwners co ON co.CharacterID = c.CharacterID +INNER JOIN dbo.CharacterSexValues csv ON csv.IsActive = 1 + AND LOWER(csv.SexName) = LOWER(co.SexName) + AND (csv.IsSystem = 1 OR csv.OwnerUserID = co.OwnerUserID) +WHERE c.SexValueID IS NULL; +GO + +CREATE OR ALTER PROCEDURE dbo.CharacterSexValue_GetOrCreate + @OwnerUserID int, + @SexName nvarchar(100) +AS +BEGIN + SET NOCOUNT ON; + + SET @SexName = NULLIF(LTRIM(RTRIM(@SexName)), N''); + IF @SexName IS NULL + THROW 53001, 'Sex name is required.', 1; + + DECLARE @CharacterSexValueID int; + + SELECT TOP (1) @CharacterSexValueID = CharacterSexValueID + FROM dbo.CharacterSexValues + WHERE IsActive = 1 + AND LOWER(SexName) = LOWER(@SexName) + AND (IsSystem = 1 OR OwnerUserID = @OwnerUserID) + ORDER BY IsSystem DESC, SortOrder, SexName; + + IF @CharacterSexValueID IS NULL + BEGIN + INSERT dbo.CharacterSexValues (OwnerUserID, SexName, IsSystem, SortOrder) + VALUES (@OwnerUserID, @SexName, 0, 100); + + SET @CharacterSexValueID = CAST(SCOPE_IDENTITY() AS int); + END + + SELECT @CharacterSexValueID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Character_ListByProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + SELECT CharacterID, ProjectID, CharacterName, ShortName, SexValueID, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, + Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath, + AvatarSourceCharacterImageID, AvatarImagePath, AvatarThumbnailPath, + CreatedDate, UpdatedDate, IsArchived, ArchivedDate, ArchivedReason + FROM dbo.Characters + WHERE ProjectID = @ProjectID AND IsArchived = 0 + ORDER BY CharacterName; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Character_Get + @CharacterID int +AS +BEGIN + SET NOCOUNT ON; + SELECT CharacterID, ProjectID, CharacterName, ShortName, SexValueID, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, + Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath, + AvatarSourceCharacterImageID, AvatarImagePath, AvatarThumbnailPath, + CreatedDate, UpdatedDate, IsArchived, ArchivedDate, ArchivedReason + FROM dbo.Characters + WHERE CharacterID = @CharacterID AND IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Character_Save + @CharacterID int = NULL, + @ProjectID int, + @CharacterName nvarchar(200), + @ShortName nvarchar(100) = NULL, + @SexValueID int = NULL, + @Sex nvarchar(100) = NULL, + @BirthDate date = NULL, + @AgeAtSeriesStart int = NULL, + @AgeReferenceSceneID int = NULL, + @Height nvarchar(50) = NULL, + @EyeColour nvarchar(50) = NULL, + @CharacterImportance int = NULL, + @ShowInQuickAddBar bit = 0, + @DefaultDescription nvarchar(max) = NULL, + @ImagePath nvarchar(500) = NULL, + @ThumbnailPath nvarchar(500) = NULL +AS +BEGIN + SET NOCOUNT ON; + IF @CharacterID IS NULL OR @CharacterID = 0 + BEGIN + INSERT dbo.Characters (ProjectID, CharacterName, ShortName, SexValueID, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath) + VALUES (@ProjectID, @CharacterName, @ShortName, @SexValueID, @Sex, @BirthDate, @AgeAtSeriesStart, @AgeReferenceSceneID, @Height, @EyeColour, @CharacterImportance, @ShowInQuickAddBar, @DefaultDescription, @ImagePath, @ThumbnailPath); + SET @CharacterID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.Characters + SET CharacterName = @CharacterName, ShortName = @ShortName, SexValueID = @SexValueID, Sex = @Sex, BirthDate = @BirthDate, + AgeAtSeriesStart = @AgeAtSeriesStart, AgeReferenceSceneID = @AgeReferenceSceneID, + Height = @Height, EyeColour = @EyeColour, CharacterImportance = @CharacterImportance, + ShowInQuickAddBar = @ShowInQuickAddBar, DefaultDescription = @DefaultDescription, + ImagePath = @ImagePath, ThumbnailPath = @ThumbnailPath, + UpdatedDate = SYSUTCDATETIME() + WHERE CharacterID = @CharacterID; + END + SELECT @CharacterID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneFloorPlanOccupancy_ListByScene + @SceneID int +AS +BEGIN + SET NOCOUNT ON; + SELECT o.SceneFloorPlanOccupancyID, o.SceneID, o.FloorPlanID, o.FloorPlanFloorID, + o.CharacterID, c.CharacterName, c.ShortName AS CharacterShortName, + COALESCE(c.AvatarImagePath, c.ImagePath) AS CharacterImagePath, + COALESCE(c.AvatarThumbnailPath, c.ThumbnailPath) AS CharacterThumbnailPath, + o.StoryAssetID, sa.AssetName, sa.ImagePath AS AssetImagePath, sa.ThumbnailPath AS AssetThumbnailPath, + o.LocationID, COALESCE(lp.LocationName, l.LocationName) AS LocationName, COALESCE(lp.LocationPath, l.LocationName) AS LocationPath, + fpf.Name AS FloorName, o.CreatedDate, o.ModifiedDate + FROM dbo.SceneFloorPlanOccupancy o + INNER JOIN dbo.Locations l ON l.LocationID = o.LocationID + LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = o.LocationID + LEFT JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = o.FloorPlanFloorID + LEFT JOIN dbo.Characters c ON c.CharacterID = o.CharacterID + LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = o.StoryAssetID + WHERE o.SceneID = @SceneID + ORDER BY COALESCE(c.CharacterName, sa.AssetName); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneFloorPlanOccupancy_ListByFloorPlan + @FloorPlanID int +AS +BEGIN + SET NOCOUNT ON; + SELECT o.SceneFloorPlanOccupancyID, o.SceneID, o.FloorPlanID, o.FloorPlanFloorID, + o.CharacterID, c.CharacterName, c.ShortName AS CharacterShortName, + COALESCE(c.AvatarImagePath, c.ImagePath) AS CharacterImagePath, + COALESCE(c.AvatarThumbnailPath, c.ThumbnailPath) AS CharacterThumbnailPath, + o.StoryAssetID, sa.AssetName, sa.ImagePath AS AssetImagePath, sa.ThumbnailPath AS AssetThumbnailPath, + o.LocationID, COALESCE(lp.LocationName, l.LocationName) AS LocationName, COALESCE(lp.LocationPath, l.LocationName) AS LocationPath, + fpf.Name AS FloorName, o.CreatedDate, o.ModifiedDate + FROM dbo.SceneFloorPlanOccupancy o + INNER JOIN dbo.Locations l ON l.LocationID = o.LocationID + LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = o.LocationID + LEFT JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = o.FloorPlanFloorID + LEFT JOIN dbo.Characters c ON c.CharacterID = o.CharacterID + LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = o.StoryAssetID + WHERE o.FloorPlanID = @FloorPlanID + ORDER BY o.SceneID, COALESCE(c.CharacterName, sa.AssetName); +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index dd0fa0c..d189999 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -1606,8 +1606,15 @@ public sealed class CharacterEditViewModel [Display(Name = "Short name")] public string? ShortName { get; set; } + [Display(Name = "Sex")] + public int? SexValueID { get; set; } + public string? Sex { get; set; } + [Display(Name = "Add custom sex")] + [StringLength(100)] + public string? CustomSex { get; set; } + [Display(Name = "Birth date")] public DateTime? BirthDate { get; set; } @@ -1647,6 +1654,8 @@ public sealed class CharacterEditViewModel new("Minor", "2") ]; + public IReadOnlyList SexOptions { get; set; } = []; + public Project? Project { get; set; } } @@ -1654,6 +1663,7 @@ public sealed class CharacterDetailViewModel { public Project Project { get; set; } = new(); public Character Character { get; set; } = new(); + public IReadOnlyList ProjectCharacters { get; set; } = []; public IReadOnlyList Images { get; set; } = []; public string DisplayAge { get; set; } = "Unknown"; public IReadOnlyList Appearances { get; set; } = []; diff --git a/PlotLine/Views/Characters/Details.cshtml b/PlotLine/Views/Characters/Details.cshtml index e6e9883..6d445a4 100644 --- a/PlotLine/Views/Characters/Details.cshtml +++ b/PlotLine/Views/Characters/Details.cshtml @@ -10,6 +10,7 @@ >= 1 => "Minor", _ => "Timeline fallback" }; + var characterLookup = Model.ProjectCharacters.ToDictionary(x => x.CharacterID); }