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) if (!ModelState.IsValid)
{ {
model.Project ??= (await characters.GetCreateCharacterAsync(model.ProjectID))?.Project; var editModel = model.CharacterID == 0
return View("Edit", model); ? 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; int characterId;
@ -64,7 +72,9 @@ public sealed class CharactersController(ICharacterService characters) : Control
createModel.CharacterName = model.CharacterName; createModel.CharacterName = model.CharacterName;
createModel.ShortName = model.ShortName; createModel.ShortName = model.ShortName;
createModel.SexValueID = model.SexValueID;
createModel.Sex = model.Sex; createModel.Sex = model.Sex;
createModel.CustomSex = model.CustomSex;
createModel.BirthDate = model.BirthDate; createModel.BirthDate = model.BirthDate;
createModel.AgeAtSeriesStart = model.AgeAtSeriesStart; createModel.AgeAtSeriesStart = model.AgeAtSeriesStart;
createModel.Height = model.Height; createModel.Height = model.Height;
@ -92,7 +102,9 @@ public sealed class CharactersController(ICharacterService characters) : Control
editModel.CharacterName = model.CharacterName; editModel.CharacterName = model.CharacterName;
editModel.ShortName = model.ShortName; editModel.ShortName = model.ShortName;
editModel.SexValueID = model.SexValueID;
editModel.Sex = model.Sex; editModel.Sex = model.Sex;
editModel.CustomSex = model.CustomSex;
editModel.BirthDate = model.BirthDate; editModel.BirthDate = model.BirthDate;
editModel.AgeAtSeriesStart = model.AgeAtSeriesStart; editModel.AgeAtSeriesStart = model.AgeAtSeriesStart;
editModel.Height = model.Height; editModel.Height = model.Height;
@ -106,6 +118,22 @@ public sealed class CharactersController(ICharacterService characters) : Control
return RedirectToAction(nameof(Details), new { id = characterId }); 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] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> UploadImage(CharacterImageUploadViewModel model) public async Task<IActionResult> UploadImage(CharacterImageUploadViewModel model)

View File

@ -182,6 +182,9 @@ public interface ICharacterRepository
Task<IReadOnlyList<Character>> ListCharactersAsync(int projectId); Task<IReadOnlyList<Character>> ListCharactersAsync(int projectId);
Task<Character?> GetCharacterAsync(int characterId); Task<Character?> GetCharacterAsync(int characterId);
Task<int> SaveCharacterAsync(Character character); 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 UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath);
Task UpdateCharacterAvatarAsync(int characterId, int? sourceCharacterImageId, string? avatarImagePath, string? avatarThumbnailPath); Task UpdateCharacterAvatarAsync(int characterId, int? sourceCharacterImageId, string? avatarImagePath, string? avatarThumbnailPath);
Task<IReadOnlyList<CharacterImage>> ListCharacterImagesAsync(int characterId); Task<IReadOnlyList<CharacterImage>> ListCharacterImagesAsync(int characterId);
@ -3542,6 +3545,7 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
character.ProjectID, character.ProjectID,
character.CharacterName, character.CharacterName,
character.ShortName, character.ShortName,
character.SexValueID,
character.Sex, character.Sex,
character.BirthDate, character.BirthDate,
character.AgeAtSeriesStart, character.AgeAtSeriesStart,
@ -3557,6 +3561,44 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
commandType: CommandType.StoredProcedure); 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) public async Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -832,6 +832,7 @@ public sealed class Character
public int ProjectID { get; set; } public int ProjectID { get; set; }
public string CharacterName { get; set; } = string.Empty; public string CharacterName { get; set; } = string.Empty;
public string? ShortName { get; set; } public string? ShortName { get; set; }
public int? SexValueID { get; set; }
public string? Sex { get; set; } public string? Sex { get; set; }
public DateTime? BirthDate { get; set; } public DateTime? BirthDate { get; set; }
public int? AgeAtSeriesStart { get; set; } public int? AgeAtSeriesStart { get; set; }
@ -853,6 +854,16 @@ public sealed class Character
public string? ArchivedReason { get; set; } 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 sealed class CharacterImage
{ {
public int CharacterImageID { get; set; } public int CharacterImageID { get; set; }

View File

@ -2184,6 +2184,7 @@ public sealed class TimelineService(
(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances) characterTimeline = needsCharacterTimelineData (IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances) characterTimeline = needsCharacterTimelineData
? await TimeTimelineLoadAsync("dbo.CharacterTimeline_GetByProject", () => characters.GetTimelineAsync(projectId, bookId), projectId, bookId) ? await TimeTimelineLoadAsync("dbo.CharacterTimeline_GetByProject", () => characters.GetTimelineAsync(projectId, bookId), projectId, bookId)
: ([], []); : ([], []);
ApplyTimelineCharacterAvatars(characterTimeline, allCharacters);
var plotThreadDetailsById = plotThreads.ToDictionary(x => x.PlotThreadID); var plotThreadDetailsById = plotThreads.ToDictionary(x => x.PlotThreadID);
foreach (var threadEvent in timeline.ThreadEvents) foreach (var threadEvent in timeline.ThreadEvents)
{ {
@ -2726,6 +2727,37 @@ public sealed class TimelineService(
}).ToList(); }).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) private static IReadOnlyList<CharacterLaneViewModel> BuildCharacterLanes(IReadOnlyList<Character> characters, IReadOnlyList<SceneCharacter> appearances, IReadOnlyList<Scene> orderedScenes)
{ {
var appearancesByCharacterAndScene = appearances var appearancesByCharacterAndScene = appearances
@ -8773,6 +8805,7 @@ public sealed class CharacterService(
IProjectRepository projects, IProjectRepository projects,
IBookRepository books, IBookRepository books,
ICharacterRepository characters, ICharacterRepository characters,
IProjectCollaborationRepository collaboration,
ISubscriptionService subscriptions, ISubscriptionService subscriptions,
IProjectActivityService activity, IProjectActivityService activity,
ICurrentUserService currentUser, ICurrentUserService currentUser,
@ -8791,7 +8824,9 @@ public sealed class CharacterService(
public async Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId) public async Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId)
{ {
var project = await projects.GetAsync(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) public async Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId)
@ -8802,12 +8837,13 @@ public sealed class CharacterService(
return null; return null;
} }
return new CharacterEditViewModel return await PopulateCharacterEditOptionsAsync(new CharacterEditViewModel
{ {
CharacterID = character.CharacterID, CharacterID = character.CharacterID,
ProjectID = character.ProjectID, ProjectID = character.ProjectID,
CharacterName = character.CharacterName, CharacterName = character.CharacterName,
ShortName = character.ShortName, ShortName = character.ShortName,
SexValueID = character.SexValueID,
Sex = character.Sex, Sex = character.Sex,
BirthDate = character.BirthDate, BirthDate = character.BirthDate,
AgeAtSeriesStart = character.AgeAtSeriesStart, AgeAtSeriesStart = character.AgeAtSeriesStart,
@ -8821,7 +8857,7 @@ public sealed class CharacterService(
AvatarImagePath = character.AvatarImagePath, AvatarImagePath = character.AvatarImagePath,
AvatarThumbnailPath = character.AvatarThumbnailPath, AvatarThumbnailPath = character.AvatarThumbnailPath,
Project = await projects.GetAsync(character.ProjectID) Project = await projects.GetAsync(character.ProjectID)
}; });
} }
public async Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId) public async Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId)
@ -8853,6 +8889,7 @@ public sealed class CharacterService(
{ {
Project = project, Project = project,
Character = character, Character = character,
ProjectCharacters = projectCharacters,
Images = await characters.ListCharacterImagesAsync(character.CharacterID), Images = await characters.ListCharacterImagesAsync(character.CharacterID),
DisplayAge = DisplayAge(character), DisplayAge = DisplayAge(character),
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID), Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
@ -8927,13 +8964,15 @@ public sealed class CharacterService(
var isNew = model.CharacterID == 0; var isNew = model.CharacterID == 0;
var existing = isNew ? null : await characters.GetCharacterAsync(model.CharacterID); 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 var characterId = await characters.SaveCharacterAsync(new Character
{ {
CharacterID = model.CharacterID, CharacterID = model.CharacterID,
ProjectID = model.ProjectID, ProjectID = model.ProjectID,
CharacterName = model.CharacterName, CharacterName = model.CharacterName,
ShortName = model.ShortName, ShortName = model.ShortName,
Sex = model.Sex, SexValueID = sex.SexValueID,
Sex = sex.SexName,
BirthDate = model.BirthDate, BirthDate = model.BirthDate,
AgeAtSeriesStart = model.AgeAtSeriesStart, AgeAtSeriesStart = model.AgeAtSeriesStart,
Height = model.Height, Height = model.Height,
@ -8963,6 +9002,55 @@ public sealed class CharacterService(
return characterId; 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) public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model)
{ {
var character = await characters.GetCharacterAsync(model.CharacterID); 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")] [Display(Name = "Short name")]
public string? ShortName { get; set; } public string? ShortName { get; set; }
[Display(Name = "Sex")]
public int? SexValueID { get; set; }
public string? Sex { get; set; } public string? Sex { get; set; }
[Display(Name = "Add custom sex")]
[StringLength(100)]
public string? CustomSex { get; set; }
[Display(Name = "Birth date")] [Display(Name = "Birth date")]
public DateTime? BirthDate { get; set; } public DateTime? BirthDate { get; set; }
@ -1647,6 +1654,8 @@ public sealed class CharacterEditViewModel
new("Minor", "2") new("Minor", "2")
]; ];
public IReadOnlyList<SelectListItem> SexOptions { get; set; } = [];
public Project? Project { get; set; } public Project? Project { get; set; }
} }
@ -1654,6 +1663,7 @@ public sealed class CharacterDetailViewModel
{ {
public Project Project { get; set; } = new(); public Project Project { get; set; } = new();
public Character Character { get; set; } = new(); public Character Character { get; set; } = new();
public IReadOnlyList<Character> ProjectCharacters { get; set; } = [];
public IReadOnlyList<CharacterImage> Images { get; set; } = []; public IReadOnlyList<CharacterImage> Images { get; set; } = [];
public string DisplayAge { get; set; } = "Unknown"; public string DisplayAge { get; set; } = "Unknown";
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = []; public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];

View File

@ -10,6 +10,7 @@
>= 1 => "Minor", >= 1 => "Minor",
_ => "Timeline fallback" _ => "Timeline fallback"
}; };
var characterLookup = Model.ProjectCharacters.ToDictionary(x => x.CharacterID);
} }
<nav class="breadcrumb-trail" aria-label="Breadcrumb"> <nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -98,99 +99,109 @@
{ {
var initialSourceId = relationship.CharacterAID; var initialSourceId = relationship.CharacterAID;
var initialRelatedId = relationship.CharacterBID; var initialRelatedId = relationship.CharacterBID;
<div class="asset-mini-item"> Character? relatedCharacter = null;
<strong>@relationship.CharacterAName -> @relationship.CharacterBName</strong> characterLookup.TryGetValue(relationship.CharacterAID == Model.Character.CharacterID ? relationship.CharacterBID : relationship.CharacterAID, out relatedCharacter);
<span>@relationship.RelationshipTypeName <span class="status-pill">@relationship.RelationshipCategoryName</span></span> <div class="asset-mini-item character-relationship-editor" data-relationship-editor>
<span>Reciprocal: @(relationship.IsReciprocal ? "Yes" : "No")</span> <div class="character-relationship-read" data-relationship-read>
<span>Reader initially knows: @(relationship.ReaderInitiallyKnows ? "Yes" : "No")</span> <div class="visual-card-heading">
@if (!string.IsNullOrWhiteSpace(relationship.InitialRelationshipStateName) || relationship.InitialIntensity.HasValue) <partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = relatedCharacter?.CharacterName ?? relationship.CharacterBName, ImagePath = relatedCharacter?.AvatarImagePath ?? relatedCharacter?.ImagePath, ThumbnailPath = relatedCharacter?.AvatarThumbnailPath ?? relatedCharacter?.ThumbnailPath })" />
{ <div>
<span>@relationship.InitialRelationshipStateName@if (relationship.InitialIntensity.HasValue) { <text> / Intensity @relationship.InitialIntensity</text> }</span> <strong>@relationship.CharacterAName -> @relationship.CharacterBName</strong>
} <span>@relationship.RelationshipTypeName <span class="status-pill">@relationship.RelationshipCategoryName</span></span>
@if (!string.IsNullOrWhiteSpace(relationship.InitialBookDisplayTitle)) <span>Reciprocal: @(relationship.IsReciprocal ? "Yes" : "No")</span>
{ <span>Reader initially knows: @(relationship.ReaderInitiallyKnows ? "Yes" : "No")</span>
<span>Initial book: @relationship.InitialBookDisplayTitle</span> @if (!string.IsNullOrWhiteSpace(relationship.InitialRelationshipStateName) || relationship.InitialIntensity.HasValue)
} {
@if (!string.IsNullOrWhiteSpace(relationship.Notes)) <span>@relationship.InitialRelationshipStateName@if (relationship.InitialIntensity.HasValue) { <text> / Intensity @relationship.InitialIntensity</text> }</span>
{ }
<span>@relationship.Notes</span> @if (!string.IsNullOrWhiteSpace(relationship.InitialBookDisplayTitle))
} {
<a asp-action="Details" asp-route-id="@(relationship.CharacterAID == Model.Character.CharacterID ? relationship.CharacterBID : relationship.CharacterAID)">Open related character</a> <span>Initial book: @relationship.InitialBookDisplayTitle</span>
<a asp-controller="Dynamics" asp-action="Relationship" asp-route-id="@relationship.CharacterRelationshipID">Evolution</a> }
<details> @if (!string.IsNullOrWhiteSpace(relationship.Notes))
<summary>Edit</summary> {
<form asp-action="SaveInitialRelationship" method="post" class="row g-2 mt-2"> <span>@relationship.Notes</span>
<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>
<div class="col-md-6"> </div>
<label class="form-label">Relationship type <help-icon key="characters.relationships.relationshipType" mode="inline" /></label> <div class="button-row">
<select class="form-select form-select-sm" name="RelationshipTypeID"> <a asp-action="Details" asp-route-id="@(relationship.CharacterAID == Model.Character.CharacterID ? relationship.CharacterBID : relationship.CharacterAID)">Open related character</a>
@foreach (var option in Model.RelationshipTypeOptions) <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>
<option value="@option.Value" selected="@(option.Value == relationship.RelationshipTypeID.ToString())">@option.Text</option> <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" />
</select> <input type="hidden" name="characterId" value="@Model.Character.CharacterID" />
</div> <button class="btn btn-outline-danger btn-sm" type="submit">Remove</button>
<div class="col-md-6"> </form>
<label class="form-label">Awareness <help-icon key="characters.relationships.awareness" mode="inline" /></label> </div>
<select class="form-select form-select-sm" name="InitialRelationshipStateID"> </div>
<option value="">No awareness state</option> <form asp-action="SaveInitialRelationship" method="post" class="row g-2 mt-2 d-none character-relationship-edit-form" data-relationship-form>
@foreach (var option in Model.RelationshipStateOptions) <input type="hidden" name="CharacterRelationshipID" value="@relationship.CharacterRelationshipID" />
{ <input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<option value="@option.Value" selected="@(option.Value == relationship.InitialRelationshipStateID?.ToString())">@option.Text</option> <input type="hidden" name="CharacterID" value="@initialSourceId" />
} <input type="hidden" name="ReturnCharacterID" value="@Model.Character.CharacterID" />
</select> <div class="col-md-6">
</div> <label class="form-label">Related character <help-icon key="characters.relationships.relatedCharacter" mode="inline" /></label>
<div class="col-md-6"> <select class="form-select form-select-sm" name="RelatedCharacterID" required>
<label class="form-label">Initial book <help-icon key="characters.relationships.initialBook" mode="inline" /></label> @foreach (var option in Model.CharacterOptions.Where(x => x.Value != initialSourceId.ToString()))
<select class="form-select form-select-sm" name="InitialBookID"> {
<option value="">Project start</option> <option value="@option.Value" selected="@(option.Value == initialRelatedId.ToString())">@option.Text</option>
@foreach (var option in Model.BookOptions) }
{ </select>
<option value="@option.Value" selected="@(option.Value == relationship.InitialBookID?.ToString())">@option.Text</option> </div>
} <div class="col-md-6">
</select> <label class="form-label">Relationship type <help-icon key="characters.relationships.relationshipType" mode="inline" /></label>
</div> <select class="form-select form-select-sm" name="RelationshipTypeID">
<div class="col-md-8"> @foreach (var option in Model.RelationshipTypeOptions)
<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" /> <option value="@option.Value" selected="@(option.Value == relationship.RelationshipTypeID.ToString())">@option.Text</option>
</div> }
<div class="col-md-4"> </select>
<label class="form-check mt-4"> </div>
<input type="hidden" name="IsReciprocal" value="false" /> <div class="col-md-6">
<input class="form-check-input" name="IsReciprocal" type="checkbox" value="true" checked="@relationship.IsReciprocal" /> <label class="form-label">Awareness <help-icon key="characters.relationships.awareness" mode="inline" /></label>
<span class="form-check-label">Reciprocal <help-icon key="characters.relationships.reciprocal" mode="inline" /></span> <select class="form-select form-select-sm" name="InitialRelationshipStateID">
</label> <option value="">No awareness state</option>
<label class="form-check"> @foreach (var option in Model.RelationshipStateOptions)
<input type="hidden" name="ReaderInitiallyKnows" value="false" /> {
<input class="form-check-input" name="ReaderInitiallyKnows" type="checkbox" value="true" checked="@relationship.ReaderInitiallyKnows" /> <option value="@option.Value" selected="@(option.Value == relationship.InitialRelationshipStateID?.ToString())">@option.Text</option>
<span class="form-check-label">Reader initially knows <help-icon key="characters.relationships.readerInitiallyKnows" mode="inline" /></span> }
</label> </select>
</div> </div>
<div class="col-12"> <div class="col-md-6">
<label class="form-label">Notes <help-icon key="characters.relationships.notes" mode="inline" /></label> <label class="form-label">Initial book <help-icon key="characters.relationships.initialBook" mode="inline" /></label>
<textarea class="form-control form-control-sm" name="Notes" rows="2">@relationship.Notes</textarea> <select class="form-select form-select-sm" name="InitialBookID">
</div> <option value="">Project start</option>
<div class="col-12"> @foreach (var option in Model.BookOptions)
<button class="btn btn-outline-primary btn-sm" type="submit">Save initial relationship</button> {
</div> <option value="@option.Value" selected="@(option.Value == relationship.InitialBookID?.ToString())">@option.Text</option>
</form> }
</details> </select>
<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."> </div>
<input type="hidden" name="id" value="@relationship.CharacterRelationshipID" /> <div class="col-md-8">
<input type="hidden" name="characterId" value="@Model.Character.CharacterID" /> <label class="form-label">Confidence / intensity <output>@(relationship.InitialIntensity ?? 5)</output> <help-icon key="characters.relationships.confidenceIntensity" mode="inline" /></label>
<button class="btn btn-outline-danger btn-sm" type="submit">Remove</button> <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> </form>
</div> </div>
} }
@ -479,4 +490,22 @@
</section> </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" /> <input asp-for="ShortName" class="form-control" />
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="Sex" class="form-label"></label> <label asp-for="SexValueID" class="form-label"></label>
<input asp-for="Sex" class="form-control" /> <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>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="BirthDate" class="form-label"></label> <label asp-for="BirthDate" class="form-label"></label>

View File

@ -800,7 +800,10 @@
} }
@foreach (var character in Model.QuickAddCharacters) @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) @foreach (var asset in Model.QuickAddAssets)
{ {
@ -1481,8 +1484,11 @@ else
var lastAppearance = orderedLaneAppearances.LastOrDefault(); var lastAppearance = orderedLaneAppearances.LastOrDefault();
<div class="character-lane-row @characterFocusClass"> <div class="character-lane-row @characterFocusClass">
<a class="character-lane-label" asp-controller="Characters" asp-action="Details" asp-route-id="@lane.Character.CharacterID"> <a class="character-lane-label" asp-controller="Characters" asp-action="Details" asp-route-id="@lane.Character.CharacterID">
<strong>@lane.Character.CharacterName</strong> <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>@(lane.Character.ShortName ?? "Character")</span> <span>
<strong>@lane.Character.CharacterName</strong>
<small>@(lane.Character.ShortName ?? "Character")</small>
</span>
</a> </a>
<div class="character-lane-slots"> <div class="character-lane-slots">
@foreach (var column in timelineColumns) @foreach (var column in timelineColumns)
@ -1510,7 +1516,14 @@ else
asp-route-FocusID="@appearance.CharacterID" asp-route-FocusID="@appearance.CharacterID"
title="@characterMarkerTooltip" title="@characterMarkerTooltip"
aria-label="@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> </a>
} }
</div> </div>

View File

@ -642,6 +642,23 @@ a:hover {
background: rgba(255, 250, 241, 0.56); 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 { .character-gallery-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)); 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); 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, .plot-lane-slot,
.asset-lane-slot, .asset-lane-slot,
.character-lane-slot { .character-lane-slot {
@ -1197,7 +1232,18 @@ a:focus-visible {
} }
.character-marker { .character-marker {
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff4d9; background: #fff4d9;
overflow: hidden;
}
.character-marker img {
width: 24px;
height: 24px;
object-fit: cover;
border-radius: 999px;
} }
.drag-chip, .drag-chip,
@ -1205,6 +1251,17 @@ a:focus-visible {
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease; 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, .drag-chip:hover,
[draggable="true"]:hover { [draggable="true"]:hover {
transform: translateY(-1px); transform: translateY(-1px);
@ -2243,6 +2300,7 @@ a:focus-visible {
} }
[data-theme="dark"] .character-gallery-upload, [data-theme="dark"] .character-gallery-upload,
[data-theme="dark"] .character-relationship-edit-form,
[data-theme="dark"] .character-gallery-card, [data-theme="dark"] .character-gallery-card,
[data-theme="dark"] .character-avatar-crop-stage, [data-theme="dark"] .character-avatar-crop-stage,
[data-theme="dark"] .character-avatar-preview { [data-theme="dark"] .character-avatar-preview {

File diff suppressed because one or more lines are too long