Implement character relationship and identity improvements
This commit is contained in:
parent
28cab2adf4
commit
b460b38583
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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);
|
||||
|
||||
250
PlotLine/Sql/087_Phase14B_CharacterRelationshipsIdentity.sql
Normal file
250
PlotLine/Sql/087_Phase14B_CharacterRelationshipsIdentity.sql
Normal 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
|
||||
@ -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; } = [];
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
>= 1 => "Minor",
|
||||
_ => "Timeline fallback"
|
||||
};
|
||||
var characterLookup = Model.ProjectCharacters.ToDictionary(x => x.CharacterID);
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
@ -98,7 +99,13 @@
|
||||
{
|
||||
var initialSourceId = relationship.CharacterAID;
|
||||
var initialRelatedId = relationship.CharacterBID;
|
||||
<div class="asset-mini-item">
|
||||
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>
|
||||
@ -115,11 +122,20 @@
|
||||
{
|
||||
<span>@relationship.Notes</span>
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
<details>
|
||||
<summary>Edit</summary>
|
||||
<form asp-action="SaveInitialRelationship" method="post" class="row g-2 mt-2">
|
||||
<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? 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" />
|
||||
@ -180,18 +196,13 @@
|
||||
</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>
|
||||
<textarea class="form-control form-control-sm" name="Notes" rows="3">@relationship.Notes</textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button class="btn btn-outline-primary btn-sm" type="submit">Save initial relationship</button>
|
||||
<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>
|
||||
</details>
|
||||
<form asp-action="ArchiveRelationship" method="post" class="archive-button-form" data-confirm-message="Remove this relationship? 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>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
<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>
|
||||
<span>@(lane.Character.ShortName ?? "Character")</span>
|
||||
<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">
|
||||
@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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
2
PlotLine/wwwroot/css/plotline-theme.min.css
vendored
2
PlotLine/wwwroot/css/plotline-theme.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user