Implement character relationship and identity improvements

This commit is contained in:
Nick Beckley 2026-06-22 21:39:45 +01:00
parent 28cab2adf4
commit b460b38583
11 changed files with 640 additions and 105 deletions

View File

@ -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<IActionResult> UploadImage(CharacterImageUploadViewModel model)

View File

@ -182,6 +182,9 @@ public interface ICharacterRepository
Task<IReadOnlyList<Character>> ListCharactersAsync(int projectId);
Task<Character?> GetCharacterAsync(int characterId);
Task<int> SaveCharacterAsync(Character character);
Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId);
Task<CharacterSexValue?> GetSexValueAsync(int characterSexValueId, int ownerUserId);
Task<int> GetOrCreateSexValueAsync(int ownerUserId, string sexName);
Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath);
Task UpdateCharacterAvatarAsync(int characterId, int? sourceCharacterImageId, string? avatarImagePath, string? avatarThumbnailPath);
Task<IReadOnlyList<CharacterImage>> 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<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<CharacterSexValue>(
"""
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<CharacterSexValue?> GetSexValueAsync(int characterSexValueId, int ownerUserId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterSexValue>(
"""
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<int> GetOrCreateSexValueAsync(int ownerUserId, string sexName)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"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();

View File

@ -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; }

View File

@ -2184,6 +2184,7 @@ public sealed class TimelineService(
(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> 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<Character> Characters, IReadOnlyList<SceneCharacter> Appearances) characterTimeline,
IReadOnlyList<Character> 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<CharacterLaneViewModel> BuildCharacterLanes(IReadOnlyList<Character> characters, IReadOnlyList<SceneCharacter> appearances, IReadOnlyList<Scene> 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<CharacterEditViewModel?> 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<CharacterEditViewModel?> 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<CharacterDetailViewModel?> 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<CharacterEditViewModel> PopulateCharacterEditOptionsAsync(CharacterEditViewModel model)
{
var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID);
IReadOnlyList<CharacterSexValue> 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<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model)
{
var character = await characters.GetCharacterAsync(model.CharacterID);

View File

@ -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

View File

@ -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<SelectListItem> 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<Character> ProjectCharacters { get; set; } = [];
public IReadOnlyList<CharacterImage> Images { get; set; } = [];
public string DisplayAge { get; set; } = "Unknown";
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];

View File

@ -10,6 +10,7 @@
>= 1 => "Minor",
_ => "Timeline fallback"
};
var characterLookup = Model.ProjectCharacters.ToDictionary(x => x.CharacterID);
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -98,99 +99,109 @@
{
var initialSourceId = relationship.CharacterAID;
var initialRelatedId = relationship.CharacterBID;
<div class="asset-mini-item">
<strong>@relationship.CharacterAName -> @relationship.CharacterBName</strong>
<span>@relationship.RelationshipTypeName <span class="status-pill">@relationship.RelationshipCategoryName</span></span>
<span>Reciprocal: @(relationship.IsReciprocal ? "Yes" : "No")</span>
<span>Reader initially knows: @(relationship.ReaderInitiallyKnows ? "Yes" : "No")</span>
@if (!string.IsNullOrWhiteSpace(relationship.InitialRelationshipStateName) || relationship.InitialIntensity.HasValue)
{
<span>@relationship.InitialRelationshipStateName@if (relationship.InitialIntensity.HasValue) { <text> / Intensity @relationship.InitialIntensity</text> }</span>
}
@if (!string.IsNullOrWhiteSpace(relationship.InitialBookDisplayTitle))
{
<span>Initial book: @relationship.InitialBookDisplayTitle</span>
}
@if (!string.IsNullOrWhiteSpace(relationship.Notes))
{
<span>@relationship.Notes</span>
}
<a asp-action="Details" asp-route-id="@(relationship.CharacterAID == Model.Character.CharacterID ? relationship.CharacterBID : relationship.CharacterAID)">Open related character</a>
<a asp-controller="Dynamics" asp-action="Relationship" asp-route-id="@relationship.CharacterRelationshipID">Evolution</a>
<details>
<summary>Edit</summary>
<form asp-action="SaveInitialRelationship" method="post" class="row g-2 mt-2">
<input type="hidden" name="CharacterRelationshipID" value="@relationship.CharacterRelationshipID" />
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="CharacterID" value="@initialSourceId" />
<input type="hidden" name="ReturnCharacterID" value="@Model.Character.CharacterID" />
<div class="col-md-6">
<label class="form-label">Related character <help-icon key="characters.relationships.relatedCharacter" mode="inline" /></label>
<select class="form-select form-select-sm" name="RelatedCharacterID" required>
@foreach (var option in Model.CharacterOptions.Where(x => x.Value != initialSourceId.ToString()))
{
<option value="@option.Value" selected="@(option.Value == initialRelatedId.ToString())">@option.Text</option>
}
</select>
Character? relatedCharacter = null;
characterLookup.TryGetValue(relationship.CharacterAID == Model.Character.CharacterID ? relationship.CharacterBID : relationship.CharacterAID, out relatedCharacter);
<div class="asset-mini-item character-relationship-editor" data-relationship-editor>
<div class="character-relationship-read" data-relationship-read>
<div class="visual-card-heading">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = relatedCharacter?.CharacterName ?? relationship.CharacterBName, ImagePath = relatedCharacter?.AvatarImagePath ?? relatedCharacter?.ImagePath, ThumbnailPath = relatedCharacter?.AvatarThumbnailPath ?? relatedCharacter?.ThumbnailPath })" />
<div>
<strong>@relationship.CharacterAName -> @relationship.CharacterBName</strong>
<span>@relationship.RelationshipTypeName <span class="status-pill">@relationship.RelationshipCategoryName</span></span>
<span>Reciprocal: @(relationship.IsReciprocal ? "Yes" : "No")</span>
<span>Reader initially knows: @(relationship.ReaderInitiallyKnows ? "Yes" : "No")</span>
@if (!string.IsNullOrWhiteSpace(relationship.InitialRelationshipStateName) || relationship.InitialIntensity.HasValue)
{
<span>@relationship.InitialRelationshipStateName@if (relationship.InitialIntensity.HasValue) { <text> / Intensity @relationship.InitialIntensity</text> }</span>
}
@if (!string.IsNullOrWhiteSpace(relationship.InitialBookDisplayTitle))
{
<span>Initial book: @relationship.InitialBookDisplayTitle</span>
}
@if (!string.IsNullOrWhiteSpace(relationship.Notes))
{
<span>@relationship.Notes</span>
}
</div>
<div class="col-md-6">
<label class="form-label">Relationship type <help-icon key="characters.relationships.relationshipType" mode="inline" /></label>
<select class="form-select form-select-sm" name="RelationshipTypeID">
@foreach (var option in Model.RelationshipTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == relationship.RelationshipTypeID.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Awareness <help-icon key="characters.relationships.awareness" mode="inline" /></label>
<select class="form-select form-select-sm" name="InitialRelationshipStateID">
<option value="">No awareness state</option>
@foreach (var option in Model.RelationshipStateOptions)
{
<option value="@option.Value" selected="@(option.Value == relationship.InitialRelationshipStateID?.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Initial book <help-icon key="characters.relationships.initialBook" mode="inline" /></label>
<select class="form-select form-select-sm" name="InitialBookID">
<option value="">Project start</option>
@foreach (var option in Model.BookOptions)
{
<option value="@option.Value" selected="@(option.Value == relationship.InitialBookID?.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-8">
<label class="form-label">Confidence / intensity <output>@(relationship.InitialIntensity ?? 5)</output> <help-icon key="characters.relationships.confidenceIntensity" mode="inline" /></label>
<input class="form-range" name="InitialIntensity" type="range" min="1" max="10" step="1" value="@(relationship.InitialIntensity ?? 5)" oninput="this.previousElementSibling.querySelector('output').value = this.value" />
</div>
<div class="col-md-4">
<label class="form-check mt-4">
<input type="hidden" name="IsReciprocal" value="false" />
<input class="form-check-input" name="IsReciprocal" type="checkbox" value="true" checked="@relationship.IsReciprocal" />
<span class="form-check-label">Reciprocal <help-icon key="characters.relationships.reciprocal" mode="inline" /></span>
</label>
<label class="form-check">
<input type="hidden" name="ReaderInitiallyKnows" value="false" />
<input class="form-check-input" name="ReaderInitiallyKnows" type="checkbox" value="true" checked="@relationship.ReaderInitiallyKnows" />
<span class="form-check-label">Reader initially knows <help-icon key="characters.relationships.readerInitiallyKnows" mode="inline" /></span>
</label>
</div>
<div class="col-12">
<label class="form-label">Notes <help-icon key="characters.relationships.notes" mode="inline" /></label>
<textarea class="form-control form-control-sm" name="Notes" rows="2">@relationship.Notes</textarea>
</div>
<div class="col-12">
<button class="btn btn-outline-primary btn-sm" type="submit">Save initial relationship</button>
</div>
</form>
</details>
<form asp-action="ArchiveRelationship" method="post" class="archive-button-form" data-confirm-message="Remove this relationship?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@relationship.CharacterRelationshipID" />
<input type="hidden" name="characterId" value="@Model.Character.CharacterID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Remove</button>
</div>
<div class="button-row">
<a asp-action="Details" asp-route-id="@(relationship.CharacterAID == Model.Character.CharacterID ? relationship.CharacterBID : relationship.CharacterAID)">Open related character</a>
<a asp-controller="Dynamics" asp-action="Relationship" asp-route-id="@relationship.CharacterRelationshipID">Evolution</a>
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-edit>Edit</button>
<form asp-action="ArchiveRelationship" method="post" class="archive-button-form" data-confirm-message="Remove this relationship?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@relationship.CharacterRelationshipID" />
<input type="hidden" name="characterId" value="@Model.Character.CharacterID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Remove</button>
</form>
</div>
</div>
<form asp-action="SaveInitialRelationship" method="post" class="row g-2 mt-2 d-none character-relationship-edit-form" data-relationship-form>
<input type="hidden" name="CharacterRelationshipID" value="@relationship.CharacterRelationshipID" />
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="CharacterID" value="@initialSourceId" />
<input type="hidden" name="ReturnCharacterID" value="@Model.Character.CharacterID" />
<div class="col-md-6">
<label class="form-label">Related character <help-icon key="characters.relationships.relatedCharacter" mode="inline" /></label>
<select class="form-select form-select-sm" name="RelatedCharacterID" required>
@foreach (var option in Model.CharacterOptions.Where(x => x.Value != initialSourceId.ToString()))
{
<option value="@option.Value" selected="@(option.Value == initialRelatedId.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Relationship type <help-icon key="characters.relationships.relationshipType" mode="inline" /></label>
<select class="form-select form-select-sm" name="RelationshipTypeID">
@foreach (var option in Model.RelationshipTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == relationship.RelationshipTypeID.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Awareness <help-icon key="characters.relationships.awareness" mode="inline" /></label>
<select class="form-select form-select-sm" name="InitialRelationshipStateID">
<option value="">No awareness state</option>
@foreach (var option in Model.RelationshipStateOptions)
{
<option value="@option.Value" selected="@(option.Value == relationship.InitialRelationshipStateID?.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Initial book <help-icon key="characters.relationships.initialBook" mode="inline" /></label>
<select class="form-select form-select-sm" name="InitialBookID">
<option value="">Project start</option>
@foreach (var option in Model.BookOptions)
{
<option value="@option.Value" selected="@(option.Value == relationship.InitialBookID?.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-8">
<label class="form-label">Confidence / intensity <output>@(relationship.InitialIntensity ?? 5)</output> <help-icon key="characters.relationships.confidenceIntensity" mode="inline" /></label>
<input class="form-range" name="InitialIntensity" type="range" min="1" max="10" step="1" value="@(relationship.InitialIntensity ?? 5)" oninput="this.previousElementSibling.querySelector('output').value = this.value" />
</div>
<div class="col-md-4">
<label class="form-check mt-4">
<input type="hidden" name="IsReciprocal" value="false" />
<input class="form-check-input" name="IsReciprocal" type="checkbox" value="true" checked="@relationship.IsReciprocal" />
<span class="form-check-label">Reciprocal <help-icon key="characters.relationships.reciprocal" mode="inline" /></span>
</label>
<label class="form-check">
<input type="hidden" name="ReaderInitiallyKnows" value="false" />
<input class="form-check-input" name="ReaderInitiallyKnows" type="checkbox" value="true" checked="@relationship.ReaderInitiallyKnows" />
<span class="form-check-label">Reader initially knows <help-icon key="characters.relationships.readerInitiallyKnows" mode="inline" /></span>
</label>
</div>
<div class="col-12">
<label class="form-label">Notes <help-icon key="characters.relationships.notes" mode="inline" /></label>
<textarea class="form-control form-control-sm" name="Notes" rows="3">@relationship.Notes</textarea>
</div>
<div class="col-12 button-row">
<button class="btn btn-outline-primary btn-sm" type="submit">Save Changes</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-cancel>Cancel</button>
</div>
</form>
</div>
}
@ -479,4 +490,22 @@
</section>
@section Scripts {
<script>
(() => {
document.querySelectorAll("[data-relationship-editor]").forEach((editor) => {
const read = editor.querySelector("[data-relationship-read]");
const form = editor.querySelector("[data-relationship-form]");
editor.querySelector("[data-relationship-edit]")?.addEventListener("click", () => {
read?.classList.add("d-none");
form?.classList.remove("d-none");
});
editor.querySelector("[data-relationship-cancel]")?.addEventListener("click", () => {
form?.classList.add("d-none");
read?.classList.remove("d-none");
});
});
})();
</script>
}

View File

@ -35,8 +35,14 @@
<input asp-for="ShortName" class="form-control" />
</div>
<div class="col-md-4">
<label asp-for="Sex" class="form-label"></label>
<input asp-for="Sex" class="form-control" />
<label asp-for="SexValueID" class="form-label"></label>
<select asp-for="SexValueID" asp-items="Model.SexOptions" class="form-select"></select>
<input asp-for="Sex" type="hidden" />
<div class="form-text">Choose a saved value, or add a custom value below.</div>
</div>
<div class="col-md-4">
<label asp-for="CustomSex" class="form-label"></label>
<input asp-for="CustomSex" class="form-control" placeholder="Optional custom value" />
</div>
<div class="col-md-4">
<label asp-for="BirthDate" class="form-label"></label>

View File

@ -800,7 +800,10 @@
}
@foreach (var character in Model.QuickAddCharacters)
{
<span class="drag-chip character" draggable="true" data-drag-type="character" data-drag-id="@character.CharacterID" data-drag-label="@character.CharacterName">@character.CharacterName</span>
<span class="drag-chip character" draggable="true" data-drag-type="character" data-drag-id="@character.CharacterID" data-drag-label="@character.CharacterName">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.AvatarImagePath ?? character.ImagePath, ThumbnailPath = character.AvatarThumbnailPath ?? character.ThumbnailPath, Size = "sm" })" />
@character.CharacterName
</span>
}
@foreach (var asset in Model.QuickAddAssets)
{
@ -1481,8 +1484,11 @@ else
var lastAppearance = orderedLaneAppearances.LastOrDefault();
<div class="character-lane-row @characterFocusClass">
<a class="character-lane-label" asp-controller="Characters" asp-action="Details" asp-route-id="@lane.Character.CharacterID">
<strong>@lane.Character.CharacterName</strong>
<span>@(lane.Character.ShortName ?? "Character")</span>
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = lane.Character.CharacterName, ImagePath = lane.Character.AvatarImagePath ?? lane.Character.ImagePath, ThumbnailPath = lane.Character.AvatarThumbnailPath ?? lane.Character.ThumbnailPath, Size = "sm" })" />
<span>
<strong>@lane.Character.CharacterName</strong>
<small>@(lane.Character.ShortName ?? "Character")</small>
</span>
</a>
<div class="character-lane-slots">
@foreach (var column in timelineColumns)
@ -1510,7 +1516,14 @@ else
asp-route-FocusID="@appearance.CharacterID"
title="@characterMarkerTooltip"
aria-label="@characterMarkerTooltip">
@CharacterMarkerLabel(appearance, firstAppearance, lastAppearance)
@if (!string.IsNullOrWhiteSpace(appearance.CharacterThumbnailPath) || !string.IsNullOrWhiteSpace(appearance.CharacterImagePath))
{
<img src="@(appearance.CharacterThumbnailPath ?? appearance.CharacterImagePath)" alt="" loading="lazy" />
}
else
{
@CharacterMarkerLabel(appearance, firstAppearance, lastAppearance)
}
</a>
}
</div>

View File

@ -642,6 +642,23 @@ a:hover {
background: rgba(255, 250, 241, 0.56);
}
.character-relationship-editor {
gap: 0.85rem;
}
.character-relationship-read {
display: grid;
gap: 0.75rem;
}
.character-relationship-edit-form {
width: 100%;
border: 1px solid var(--plotline-border-soft);
border-radius: var(--plotline-radius-sm);
padding: 0.85rem;
background: rgba(255, 250, 241, 0.56);
}
.character-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr));
@ -1172,6 +1189,24 @@ a:focus-visible {
box-shadow: 6px 0 18px rgba(62, 45, 28, 0.06);
}
.character-lane-label {
display: flex;
align-items: center;
gap: 0.55rem;
}
.character-lane-label > span {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.character-lane-label small {
color: var(--plotline-muted);
font-size: 0.75rem;
font-weight: 700;
}
.plot-lane-slot,
.asset-lane-slot,
.character-lane-slot {
@ -1197,7 +1232,18 @@ a:focus-visible {
}
.character-marker {
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff4d9;
overflow: hidden;
}
.character-marker img {
width: 24px;
height: 24px;
object-fit: cover;
border-radius: 999px;
}
.drag-chip,
@ -1205,6 +1251,17 @@ a:focus-visible {
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease;
}
.drag-chip.character {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.drag-chip .visual-avatar,
.character-lane-label .visual-avatar {
flex: 0 0 auto;
}
.drag-chip:hover,
[draggable="true"]:hover {
transform: translateY(-1px);
@ -2243,6 +2300,7 @@ a:focus-visible {
}
[data-theme="dark"] .character-gallery-upload,
[data-theme="dark"] .character-relationship-edit-form,
[data-theme="dark"] .character-gallery-card,
[data-theme="dark"] .character-avatar-crop-stage,
[data-theme="dark"] .character-avatar-preview {

File diff suppressed because one or more lines are too long