Implemented Phase 14A: Character Galleries & Avatar Cropping.

This commit is contained in:
Nick Beckley 2026-06-22 21:23:33 +01:00
parent 54fdc034ea
commit 28cab2adf4
14 changed files with 1011 additions and 17 deletions

View File

@ -32,6 +32,12 @@ public sealed class CharactersController(ICharacterService characters) : Control
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> CropAvatar(int imageId)
{
var model = await characters.GetAvatarCropAsync(imageId);
return model is null ? NotFound() : View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(CharacterEditViewModel model)
@ -100,6 +106,48 @@ public sealed class CharactersController(ICharacterService characters) : Control
return RedirectToAction(nameof(Details), new { id = characterId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadImage(CharacterImageUploadViewModel model)
{
var result = await characters.UploadCharacterImageAsync(model);
TempData[result.Succeeded ? "ImageMessage" : "ImageError"] = result.Succeeded
? "Image added to gallery."
: result.ErrorMessage ?? "Character image could not be uploaded.";
return RedirectToAction(nameof(Details), new { id = model.CharacterID });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveAvatar(CharacterAvatarCropViewModel model)
{
var result = await characters.SaveCharacterAvatarAsync(model);
TempData[result.Succeeded ? "ImageMessage" : "ImageError"] = result.Succeeded
? "Avatar updated."
: result.ErrorMessage ?? "Avatar could not be saved.";
return result.Succeeded
? RedirectToAction(nameof(Details), new { id = model.CharacterID })
: RedirectToAction(nameof(CropAvatar), new { imageId = model.CharacterImageID });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveAvatar(int id)
{
await characters.RemoveCharacterAvatarAsync(id);
TempData["ImageMessage"] = "Avatar removed.";
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteImage(int imageId, int characterId)
{
await characters.DeleteCharacterImageAsync(imageId);
TempData["ImageMessage"] = "Image removed from gallery.";
return RedirectToAction(nameof(Details), new { id = characterId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Archive(int id, int projectId)

View File

@ -183,6 +183,11 @@ public interface ICharacterRepository
Task<Character?> GetCharacterAsync(int characterId);
Task<int> SaveCharacterAsync(Character character);
Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath);
Task UpdateCharacterAvatarAsync(int characterId, int? sourceCharacterImageId, string? avatarImagePath, string? avatarThumbnailPath);
Task<IReadOnlyList<CharacterImage>> ListCharacterImagesAsync(int characterId);
Task<CharacterImage?> GetCharacterImageAsync(int characterImageId);
Task<int> CreateCharacterImageAsync(CharacterImage image);
Task DeleteCharacterImageAsync(int characterImageId);
Task ArchiveCharacterAsync(int characterId);
Task<IReadOnlyList<SceneCharacter>> ListSceneCharactersAsync(int sceneId);
Task<int> SaveSceneCharacterAsync(SceneCharacter sceneCharacter);
@ -3561,6 +3566,83 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
commandType: CommandType.StoredProcedure);
}
public async Task UpdateCharacterAvatarAsync(int characterId, int? sourceCharacterImageId, string? avatarImagePath, string? avatarThumbnailPath)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Character_UpdateAvatar",
new
{
CharacterID = characterId,
AvatarSourceCharacterImageID = sourceCharacterImageId,
AvatarImagePath = avatarImagePath,
AvatarThumbnailPath = avatarThumbnailPath
},
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<CharacterImage>> ListCharacterImagesAsync(int characterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<CharacterImage>(
"""
SELECT CharacterImageID, CharacterID, ProjectID, ImagePath, ThumbnailPath, Caption, SortOrder, UploadedDateUTC, IsDeleted
FROM dbo.CharacterImages
WHERE CharacterID = @CharacterID AND IsDeleted = 0
ORDER BY SortOrder, UploadedDateUTC, CharacterImageID;
""",
new { CharacterID = characterId });
return rows.ToList();
}
public async Task<CharacterImage?> GetCharacterImageAsync(int characterImageId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterImage>(
"""
SELECT CharacterImageID, CharacterID, ProjectID, ImagePath, ThumbnailPath, Caption, SortOrder, UploadedDateUTC, IsDeleted
FROM dbo.CharacterImages
WHERE CharacterImageID = @CharacterImageID AND IsDeleted = 0;
""",
new { CharacterImageID = characterImageId });
}
public async Task<int> CreateCharacterImageAsync(CharacterImage image)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"""
DECLARE @SortOrder int = COALESCE(@InputSortOrder, (SELECT ISNULL(MAX(SortOrder), 0) + 1 FROM dbo.CharacterImages WHERE CharacterID = @CharacterID));
INSERT dbo.CharacterImages (CharacterID, ProjectID, ImagePath, ThumbnailPath, Caption, SortOrder)
VALUES (@CharacterID, @ProjectID, @ImagePath, @ThumbnailPath, @Caption, @SortOrder);
SELECT CAST(SCOPE_IDENTITY() AS int);
""",
new
{
image.CharacterID,
image.ProjectID,
image.ImagePath,
image.ThumbnailPath,
image.Caption,
InputSortOrder = image.SortOrder == 0 ? (int?)null : image.SortOrder
});
}
public async Task DeleteCharacterImageAsync(int characterImageId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
UPDATE dbo.CharacterImages
SET IsDeleted = 1,
DeletedDateUTC = SYSUTCDATETIME()
WHERE CharacterImageID = @CharacterImageID;
""",
new { CharacterImageID = characterImageId });
}
public async Task ArchiveCharacterAsync(int characterId)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -843,6 +843,9 @@ public sealed class Character
public string? DefaultDescription { get; set; }
public string? ImagePath { get; set; }
public string? ThumbnailPath { get; set; }
public int? AvatarSourceCharacterImageID { get; set; }
public string? AvatarImagePath { get; set; }
public string? AvatarThumbnailPath { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; }
@ -850,6 +853,19 @@ public sealed class Character
public string? ArchivedReason { get; set; }
}
public sealed class CharacterImage
{
public int CharacterImageID { get; set; }
public int CharacterID { get; set; }
public int ProjectID { get; set; }
public string ImagePath { get; set; } = string.Empty;
public string ThumbnailPath { get; set; } = string.Empty;
public string? Caption { get; set; }
public int SortOrder { get; set; }
public DateTime UploadedDateUTC { get; set; }
public bool IsDeleted { get; set; }
}
public sealed class CharacterRoleInSceneType
{
public int CharacterRoleInSceneTypeID { get; set; }
@ -874,6 +890,8 @@ public sealed class SceneCharacter
public int ProjectID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public string? ShortName { get; set; }
public string? CharacterImagePath { get; set; }
public string? CharacterThumbnailPath { get; set; }
public DateTime? BirthDate { get; set; }
public int? AgeAtSeriesStart { get; set; }
public int? RoleInSceneTypeID { get; set; }

View File

@ -228,7 +228,12 @@ public interface ICharacterService
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
Task<int> SaveCharacterAsync(CharacterEditViewModel model);
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model);
Task RemoveCharacterAvatarAsync(int characterId);
Task DeleteCharacterImageAsync(int characterImageId);
Task ArchiveCharacterAsync(int characterId);
Task ArchiveRelationshipAsync(int characterRelationshipId);
Task<int> SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model);
@ -8813,6 +8818,8 @@ public sealed class CharacterService(
DefaultDescription = character.DefaultDescription,
ImagePath = character.ImagePath,
ThumbnailPath = character.ThumbnailPath,
AvatarImagePath = character.AvatarImagePath,
AvatarThumbnailPath = character.AvatarThumbnailPath,
Project = await projects.GetAsync(character.ProjectID)
};
}
@ -8846,6 +8853,7 @@ public sealed class CharacterService(
{
Project = project,
Character = character,
Images = await characters.ListCharacterImagesAsync(character.CharacterID),
DisplayAge = DisplayAge(character),
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID),
@ -8886,6 +8894,30 @@ public sealed class CharacterService(
};
}
public async Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId)
{
var image = await characters.GetCharacterImageAsync(characterImageId);
if (image is null)
{
return null;
}
var character = await characters.GetCharacterAsync(image.CharacterID);
if (character is null)
{
return null;
}
return new CharacterAvatarCropViewModel
{
CharacterID = character.CharacterID,
CharacterImageID = image.CharacterImageID,
CharacterName = character.CharacterName,
ImagePath = image.ImagePath,
Caption = image.Caption
};
}
public async Task<int> SaveCharacterAsync(CharacterEditViewModel model)
{
if (model.CharacterID == 0)
@ -8931,6 +8963,67 @@ public sealed class CharacterService(
return characterId;
}
public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model)
{
var character = await characters.GetCharacterAsync(model.CharacterID);
if (character is null)
{
return new(false, "Character not found.");
}
return model.ImageUpload is null
? new(false, "Choose an image to upload.")
: await visualIdentityImages.UploadCharacterGalleryImageAsync(character, model.ImageUpload, model.Caption);
}
public async Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model)
{
var character = await characters.GetCharacterAsync(model.CharacterID);
var image = await characters.GetCharacterImageAsync(model.CharacterImageID);
if (character is null || image is null || image.CharacterID != character.CharacterID)
{
return new(false, "Character image not found.");
}
return await visualIdentityImages.CreateCharacterAvatarAsync(character, image, model.CropX, model.CropY, model.CropSize);
}
public async Task RemoveCharacterAvatarAsync(int characterId)
{
var character = await characters.GetCharacterAsync(characterId);
if (character is not null)
{
await visualIdentityImages.RemoveCharacterAvatarAsync(character);
await activity.RecordAsync(character.ProjectID, "Updated", "Character", characterId, character.CharacterName);
}
}
public async Task DeleteCharacterImageAsync(int characterImageId)
{
var image = await characters.GetCharacterImageAsync(characterImageId);
if (image is null)
{
return;
}
var character = await characters.GetCharacterAsync(image.CharacterID);
if (character is not null && string.Equals(character.ImagePath, image.ImagePath, StringComparison.OrdinalIgnoreCase))
{
await characters.UpdateCharacterImageAsync(character.CharacterID, null, null);
}
await visualIdentityImages.DeleteCharacterGalleryImageAsync(image);
if (character?.AvatarSourceCharacterImageID == image.CharacterImageID)
{
await visualIdentityImages.RemoveCharacterAvatarAsync(character);
}
if (character is not null)
{
await activity.RecordAsync(character.ProjectID, "Updated", "Character", character.CharacterID, character.CharacterName);
}
}
public async Task ArchiveCharacterAsync(int characterId)
{
var character = await characters.GetCharacterAsync(characterId);

View File

@ -8,12 +8,16 @@ namespace PlotLine.Services;
public interface IVisualIdentityImageService
{
Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload);
Task<VisualIdentityImageResult> UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption);
Task<VisualIdentityImageResult> CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize);
Task RemoveCharacterImageAsync(Character character);
Task RemoveCharacterAvatarAsync(Character character);
Task DeleteCharacterGalleryImageAsync(CharacterImage image);
Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload);
Task RemoveAssetImageAsync(StoryAsset asset);
}
public sealed record VisualIdentityImageResult(bool Succeeded, string? ErrorMessage = null);
public sealed record VisualIdentityImageResult(bool Succeeded, string? ErrorMessage = null, int? CharacterImageID = null);
public sealed class VisualIdentityImageService(
IUploadStorageService uploadStorage,
@ -30,17 +34,11 @@ public sealed class VisualIdentityImageService(
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
private static readonly ConcurrentDictionary<string, SemaphoreSlim> EntityLocks = new();
public Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload)
=> UploadAsync(
$"character:{character.CharacterID}",
character.ProjectID,
character.CharacterID,
character.ImagePath,
character.ThumbnailPath,
"characters",
"CharacterImage",
(imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath),
upload);
public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload)
=> await UploadCharacterGalleryImageCoreAsync(character, upload, null, updateLegacyImage: true);
public async Task<VisualIdentityImageResult> UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption)
=> await UploadCharacterGalleryImageCoreAsync(character, upload, caption, updateLegacyImage: false);
public Task RemoveCharacterImageAsync(Character character)
=> RemoveAsync(
@ -49,6 +47,81 @@ public sealed class VisualIdentityImageService(
"uploads/characters",
(imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath));
public async Task RemoveCharacterAvatarAsync(Character character)
{
await characters.UpdateCharacterAvatarAsync(character.CharacterID, null, null, null);
await MarkPreviousLedgerRowsAsync(character.AvatarImagePath);
await MarkPreviousLedgerRowsAsync(character.AvatarThumbnailPath);
DeletePreviousPhysicalFile(character.AvatarImagePath, null, "uploads/characters");
DeletePreviousPhysicalFile(character.AvatarThumbnailPath, null, "uploads/characters");
}
public async Task DeleteCharacterGalleryImageAsync(CharacterImage image)
{
await characters.DeleteCharacterImageAsync(image.CharacterImageID);
await MarkPreviousLedgerRowsAsync(image.ImagePath);
await MarkPreviousLedgerRowsAsync(image.ThumbnailPath);
DeletePreviousPhysicalFile(image.ImagePath, null, "uploads/characters");
DeletePreviousPhysicalFile(image.ThumbnailPath, null, "uploads/characters");
}
public async Task<VisualIdentityImageResult> CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize)
{
if (sourceImage.CharacterID != character.CharacterID)
{
return new(false, "Choose an image from this character's gallery.");
}
var sourcePath = uploadStorage.TryResolvePublicPath(sourceImage.ImagePath, "uploads/characters");
if (sourcePath is null || !File.Exists(sourcePath))
{
return new(false, "The source image could not be found.");
}
var ownerUserId = await collaboration.GetOwnerUserIdAsync(character.ProjectID);
if (!ownerUserId.HasValue)
{
return new(false, "Project owner could not be found.");
}
var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, 256 * 256);
if (!storageLimit.Allowed)
{
return new(false, storageLimit.Message);
}
try
{
using var bitmap = SKBitmap.Decode(sourcePath) ?? throw new InvalidDataException();
var size = cropSize <= 0 ? Math.Min(bitmap.Width, bitmap.Height) : cropSize;
size = Math.Min(size, Math.Min(bitmap.Width, bitmap.Height));
cropX = Math.Clamp(cropX, 0, Math.Max(0, bitmap.Width - size));
cropY = Math.Clamp(cropY, 0, Math.Max(0, bitmap.Height - size));
var uploadRoot = uploadStorage.GetDirectory("characters", character.CharacterID.ToString());
var version = Guid.NewGuid().ToString("N");
var avatarTemp = Path.Combine(uploadRoot, $"avatar-{version}.tmp");
var avatarPath = Path.Combine(uploadRoot, "avatar.webp");
var avatarStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "avatar.webp");
SaveWebp(bitmap, avatarTemp, new SKRect(cropX, cropY, cropX + size, cropY + size), 256, 256, 82);
ReplaceFile(avatarTemp, avatarPath);
await characters.UpdateCharacterAvatarAsync(character.CharacterID, sourceImage.CharacterImageID, avatarStoragePath, avatarStoragePath);
await MarkPreviousLedgerRowsAsync(character.AvatarImagePath);
await MarkPreviousLedgerRowsAsync(character.AvatarThumbnailPath);
await TrackImageAsync(ownerUserId.Value, character.ProjectID, character.CharacterID, "CharacterAvatar", avatarStoragePath, "Character avatar", new FileInfo(avatarPath).Length);
DeletePreviousPhysicalFile(character.AvatarImagePath, avatarStoragePath, "uploads/characters");
DeletePreviousPhysicalFile(character.AvatarThumbnailPath, avatarStoragePath, "uploads/characters");
return new(true);
}
catch (Exception ex) when (ex is InvalidDataException or IOException or ArgumentException)
{
logger.LogWarning(ex, "Character avatar crop failed for Character {CharacterID}.", character.CharacterID);
return new(false, "Avatar could not be generated. Try another crop or source image.");
}
}
public Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload)
=> UploadAsync(
$"asset:{asset.StoryAssetID}",
@ -193,6 +266,118 @@ public sealed class VisualIdentityImageService(
}
}
private async Task<VisualIdentityImageResult> UploadCharacterGalleryImageCoreAsync(Character character, IFormFile upload, string? caption, bool updateLegacyImage)
{
var uploadLock = EntityLocks.GetOrAdd($"character-gallery:{character.CharacterID}", _ => new SemaphoreSlim(1, 1));
await uploadLock.WaitAsync();
try
{
if (upload.Length <= 0)
{
return new(false, "Choose an image to upload.");
}
if (upload.Length > MaxUploadBytes)
{
return new(false, "Image must be 10 MB or smaller.");
}
if (!SupportedExtensions.Contains(Path.GetExtension(upload.FileName)))
{
return new(false, "Image must be a JPG, PNG or WebP file.");
}
var ownerUserId = await collaboration.GetOwnerUserIdAsync(character.ProjectID);
if (!ownerUserId.HasValue)
{
return new(false, "Project owner could not be found.");
}
var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, EstimatedGeneratedSize(upload.Length));
if (!storageLimit.Allowed)
{
return new(false, storageLimit.Message);
}
SKBitmap bitmap;
try
{
await using var uploadStream = upload.OpenReadStream();
using var memory = new MemoryStream();
await uploadStream.CopyToAsync(memory);
memory.Position = 0;
using var codec = SKCodec.Create(memory) ?? throw new InvalidDataException();
if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp))
{
return new(false, "Image must be a valid JPG, PNG or WebP file.");
}
memory.Position = 0;
bitmap = SKBitmap.Decode(memory) ?? throw new InvalidDataException();
}
catch (Exception ex) when (ex is InvalidDataException or ArgumentException)
{
return new(false, "Image must be a valid JPG, PNG or WebP file.");
}
using (bitmap)
{
if (bitmap.Width <= 0 || bitmap.Height <= 0)
{
return new(false, "Image could not be read as a valid image.");
}
var version = Guid.NewGuid().ToString("N");
var uploadRoot = uploadStorage.GetDirectory("characters", character.CharacterID.ToString(), "gallery", version);
var imageTemp = Path.Combine(uploadRoot, "image.tmp");
var thumbnailTemp = Path.Combine(uploadRoot, "thumb.tmp");
var imagePath = Path.Combine(uploadRoot, "image.webp");
var thumbnailPath = Path.Combine(uploadRoot, "thumb.webp");
var imageStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "gallery", version, "image.webp");
var thumbnailStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "gallery", version, "thumb.webp");
try
{
SaveContainedWebp(bitmap, imageTemp, StandardMaxSize, 82);
SaveCroppedWebp(bitmap, thumbnailTemp, ThumbnailSize, ThumbnailSize, 76);
ReplaceFile(imageTemp, imagePath);
ReplaceFile(thumbnailTemp, thumbnailPath);
var characterImageId = await characters.CreateCharacterImageAsync(new CharacterImage
{
CharacterID = character.CharacterID,
ProjectID = character.ProjectID,
ImagePath = imageStoragePath,
ThumbnailPath = thumbnailStoragePath,
Caption = caption
});
if (updateLegacyImage || string.IsNullOrWhiteSpace(character.ImagePath))
{
await characters.UpdateCharacterImageAsync(character.CharacterID, imageStoragePath, thumbnailStoragePath);
}
await TrackImageAsync(ownerUserId.Value, character.ProjectID, characterImageId, "CharacterImage", imageStoragePath, upload.FileName, new FileInfo(imagePath).Length);
await TrackImageAsync(ownerUserId.Value, character.ProjectID, characterImageId, "CharacterImage", thumbnailStoragePath, "Thumbnail: " + upload.FileName, new FileInfo(thumbnailPath).Length);
return new(true, CharacterImageID: characterImageId);
}
catch (Exception ex)
{
TryDeleteFile(imageTemp);
TryDeleteFile(thumbnailTemp);
logger.LogWarning(ex, "Character gallery image upload failed for Character {CharacterID}.", character.CharacterID);
return new(false, "Image could not be processed. Try another image.");
}
}
}
finally
{
uploadLock.Release();
}
}
private async Task RemoveAsync(string? imagePath, string? thumbnailPath, string requiredPrefix, Func<string?, string?, Task> updateImage)
{
await updateImage(null, null);

View File

@ -0,0 +1,183 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.Characters', N'AvatarSourceCharacterImageID') IS NULL
ALTER TABLE dbo.Characters ADD AvatarSourceCharacterImageID int NULL;
GO
IF COL_LENGTH(N'dbo.Characters', N'AvatarImagePath') IS NULL
ALTER TABLE dbo.Characters ADD AvatarImagePath nvarchar(500) NULL;
GO
IF COL_LENGTH(N'dbo.Characters', N'AvatarThumbnailPath') IS NULL
ALTER TABLE dbo.Characters ADD AvatarThumbnailPath nvarchar(500) NULL;
GO
IF OBJECT_ID(N'dbo.CharacterImages', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CharacterImages
(
CharacterImageID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterImages PRIMARY KEY,
CharacterID int NOT NULL,
ProjectID int NOT NULL,
ImagePath nvarchar(500) NOT NULL,
ThumbnailPath nvarchar(500) NOT NULL,
Caption nvarchar(500) NULL,
SortOrder int NOT NULL CONSTRAINT DF_CharacterImages_SortOrder DEFAULT (0),
UploadedDateUTC datetime2 NOT NULL CONSTRAINT DF_CharacterImages_UploadedDateUTC DEFAULT (SYSUTCDATETIME()),
IsDeleted bit NOT NULL CONSTRAINT DF_CharacterImages_IsDeleted DEFAULT (0),
DeletedDateUTC datetime2 NULL,
CONSTRAINT FK_CharacterImages_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID),
CONSTRAINT FK_CharacterImages_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterImages_CharacterID_IsDeleted_SortOrder' AND object_id = OBJECT_ID(N'dbo.CharacterImages'))
CREATE INDEX IX_CharacterImages_CharacterID_IsDeleted_SortOrder ON dbo.CharacterImages(CharacterID, IsDeleted, SortOrder, UploadedDateUTC, CharacterImageID);
GO
INSERT dbo.CharacterImages (CharacterID, ProjectID, ImagePath, ThumbnailPath, Caption, SortOrder, UploadedDateUTC)
SELECT c.CharacterID, c.ProjectID, c.ImagePath, COALESCE(c.ThumbnailPath, c.ImagePath), N'Original character image', 0, COALESCE(c.UpdatedDate, SYSUTCDATETIME())
FROM dbo.Characters c
WHERE NULLIF(LTRIM(RTRIM(c.ImagePath)), N'') IS NOT NULL
AND NOT EXISTS
(
SELECT 1
FROM dbo.CharacterImages ci
WHERE ci.CharacterID = c.CharacterID
AND ci.ImagePath = c.ImagePath
AND ci.IsDeleted = 0
);
GO
CREATE OR ALTER PROCEDURE dbo.Character_ListByProject
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT CharacterID, ProjectID, CharacterName, ShortName, 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, 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,
@Sex nvarchar(50) = 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, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath)
VALUES (@ProjectID, @CharacterName, @ShortName, @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, 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.Character_UpdateImage
@CharacterID int,
@ImagePath nvarchar(500) = NULL,
@ThumbnailPath nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.Characters
SET ImagePath = @ImagePath,
ThumbnailPath = @ThumbnailPath,
UpdatedDate = SYSUTCDATETIME()
WHERE CharacterID = @CharacterID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Character_UpdateAvatar
@CharacterID int,
@AvatarSourceCharacterImageID int = NULL,
@AvatarImagePath nvarchar(500) = NULL,
@AvatarThumbnailPath nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.Characters
SET AvatarSourceCharacterImageID = @AvatarSourceCharacterImageID,
AvatarImagePath = @AvatarImagePath,
AvatarThumbnailPath = @AvatarThumbnailPath,
UpdatedDate = SYSUTCDATETIME()
WHERE CharacterID = @CharacterID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneCharacter_ListByScene
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
SELECT sc.SceneCharacterID, sc.SceneID, sc.CharacterID, c.ProjectID,
CASE WHEN c.IsArchived = 1 THEN CONCAT(c.CharacterName, N' (archived)') ELSE c.CharacterName END AS CharacterName,
c.ShortName,
COALESCE(c.AvatarImagePath, c.ImagePath) AS CharacterImagePath,
COALESCE(c.AvatarThumbnailPath, c.ThumbnailPath) AS CharacterThumbnailPath,
c.BirthDate, c.AgeAtSeriesStart,
sc.RoleInSceneTypeID, role.TypeName AS RoleInSceneTypeName, sc.PresenceTypeID, presence.TypeName AS PresenceTypeName,
sc.LocationID, location.LocationName, location.LocationPath, sc.EntryLocationID, entryLocation.LocationName AS EntryLocationName,
sc.ExitLocationID, exitLocation.LocationName AS ExitLocationName,
sc.AppearanceNotes, sc.OutfitDescription, sc.PhysicalCondition, sc.EmotionalState, sc.KnowledgeNotes,
sc.CreatedDate, sc.UpdatedDate
FROM dbo.SceneCharacters sc
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID
LEFT JOIN dbo.CharacterRoleInSceneTypes role ON role.CharacterRoleInSceneTypeID = sc.RoleInSceneTypeID
LEFT JOIN dbo.PresenceTypes presence ON presence.PresenceTypeID = sc.PresenceTypeID
LEFT JOIN dbo.LocationPaths location ON location.LocationID = sc.LocationID
LEFT JOIN dbo.LocationPaths entryLocation ON entryLocation.LocationID = sc.EntryLocationID
LEFT JOIN dbo.LocationPaths exitLocation ON exitLocation.LocationID = sc.ExitLocationID
WHERE sc.SceneID = @SceneID
ORDER BY role.SortOrder, c.CharacterName;
END;
GO

View File

@ -1630,6 +1630,8 @@ public sealed class CharacterEditViewModel
public string? ImagePath { get; set; }
public string? ThumbnailPath { get; set; }
public string? AvatarImagePath { get; set; }
public string? AvatarThumbnailPath { get; set; }
[Display(Name = "Upload image")]
public IFormFile? ImageUpload { get; set; }
@ -1652,6 +1654,7 @@ public sealed class CharacterDetailViewModel
{
public Project Project { get; set; } = new();
public Character Character { get; set; } = new();
public IReadOnlyList<CharacterImage> Images { get; set; } = [];
public string DisplayAge { get; set; } = "Unknown";
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
@ -1666,6 +1669,29 @@ public sealed class CharacterDetailViewModel
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
}
public sealed class CharacterImageUploadViewModel
{
public int CharacterID { get; set; }
[Display(Name = "Image")]
public IFormFile? ImageUpload { get; set; }
[StringLength(500)]
public string? Caption { get; set; }
}
public sealed class CharacterAvatarCropViewModel
{
public int CharacterID { get; set; }
public int CharacterImageID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public string ImagePath { get; set; } = string.Empty;
public string? Caption { get; set; }
public int CropX { get; set; }
public int CropY { get; set; }
public int CropSize { get; set; }
}
public sealed class CurrentRelationshipViewModel
{
public CharacterRelationship Relationship { get; set; } = new();

View File

@ -0,0 +1,152 @@
@model CharacterAvatarCropViewModel
@{
ViewData["Title"] = "Create Avatar";
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-action="Details" asp-route-id="@Model.CharacterID">@Model.CharacterName</a>
<span>Create Avatar</span>
</nav>
<div class="page-heading compact">
<div>
<p class="eyebrow">Character Avatar</p>
<h1>Create Avatar</h1>
<p class="lead-text">Choose a square crop. The original gallery image will be preserved.</p>
</div>
</div>
@if (TempData["ImageError"] is string imageError)
{
<div class="alert alert-warning">@imageError</div>
}
<form asp-action="SaveAvatar" method="post" class="edit-panel character-avatar-crop-form" data-avatar-crop-form>
<input asp-for="CharacterID" type="hidden" />
<input asp-for="CharacterImageID" type="hidden" />
<input asp-for="CropX" type="hidden" data-crop-x />
<input asp-for="CropY" type="hidden" data-crop-y />
<input asp-for="CropSize" type="hidden" data-crop-size />
<div class="character-avatar-crop-layout">
<div class="character-avatar-crop-stage" data-crop-stage>
<img src="@Model.ImagePath" alt="" data-crop-image />
<div class="character-avatar-crop-box" data-crop-box>
<span></span>
</div>
</div>
<div class="character-avatar-crop-side">
<p class="eyebrow">Preview</p>
<div class="character-avatar-preview" data-avatar-preview>
<img src="@Model.ImagePath" alt="" data-preview-image />
</div>
@if (!string.IsNullOrWhiteSpace(Model.Caption))
{
<p class="muted">@Model.Caption</p>
}
<label class="form-label" for="cropZoom">Crop size</label>
<input class="form-range" id="cropZoom" type="range" min="20" max="100" value="70" data-crop-size-slider />
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Save avatar</button>
<a class="btn btn-outline-secondary" asp-action="Details" asp-route-id="@Model.CharacterID">Cancel</a>
</div>
</form>
@section Scripts {
<script>
(() => {
const form = document.querySelector("[data-avatar-crop-form]");
if (!form) return;
const stage = form.querySelector("[data-crop-stage]");
const image = form.querySelector("[data-crop-image]");
const box = form.querySelector("[data-crop-box]");
const slider = form.querySelector("[data-crop-size-slider]");
const preview = form.querySelector("[data-avatar-preview]");
const previewImage = form.querySelector("[data-preview-image]");
const cropX = form.querySelector("[data-crop-x]");
const cropY = form.querySelector("[data-crop-y]");
const cropSize = form.querySelector("[data-crop-size]");
let crop = { x: 0, y: 0, size: 0 };
let dragging = false;
let dragOffset = { x: 0, y: 0 };
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const imageRect = () => {
const stageRect = stage.getBoundingClientRect();
const imgRect = image.getBoundingClientRect();
return {
x: imgRect.left - stageRect.left,
y: imgRect.top - stageRect.top,
width: imgRect.width,
height: imgRect.height
};
};
const sync = () => {
const rect = imageRect();
const minSide = Math.min(rect.width, rect.height);
const percent = Number(slider.value) / 100;
crop.size = Math.max(24, minSide * percent);
crop.x = clamp(crop.x, rect.x, rect.x + rect.width - crop.size);
crop.y = clamp(crop.y, rect.y, rect.y + rect.height - crop.size);
box.style.width = `${crop.size}px`;
box.style.height = `${crop.size}px`;
box.style.left = `${crop.x}px`;
box.style.top = `${crop.y}px`;
const naturalScaleX = image.naturalWidth / rect.width;
const naturalScaleY = image.naturalHeight / rect.height;
cropX.value = Math.round((crop.x - rect.x) * naturalScaleX);
cropY.value = Math.round((crop.y - rect.y) * naturalScaleY);
cropSize.value = Math.round(crop.size * Math.min(naturalScaleX, naturalScaleY));
const previewSize = preview.clientWidth || 180;
const scale = previewSize / crop.size;
previewImage.style.width = `${rect.width * scale}px`;
previewImage.style.height = `${rect.height * scale}px`;
previewImage.style.transform = `translate(${-(crop.x - rect.x) * scale}px, ${-(crop.y - rect.y) * scale}px)`;
};
const resetCrop = () => {
const rect = imageRect();
const minSide = Math.min(rect.width, rect.height);
crop.size = minSide * (Number(slider.value) / 100);
crop.x = rect.x + (rect.width - crop.size) / 2;
crop.y = rect.y + (rect.height - crop.size) / 2;
sync();
};
image.addEventListener("load", resetCrop);
if (image.complete) window.requestAnimationFrame(resetCrop);
window.addEventListener("resize", resetCrop);
slider.addEventListener("input", sync);
box.addEventListener("pointerdown", (event) => {
dragging = true;
box.setPointerCapture(event.pointerId);
dragOffset = { x: event.offsetX, y: event.offsetY };
});
box.addEventListener("pointermove", (event) => {
if (!dragging) return;
const stageRect = stage.getBoundingClientRect();
const rect = imageRect();
crop.x = clamp(event.clientX - stageRect.left - dragOffset.x, rect.x, rect.x + rect.width - crop.size);
crop.y = clamp(event.clientY - stageRect.top - dragOffset.y, rect.y, rect.y + rect.height - crop.size);
sync();
});
box.addEventListener("pointerup", () => {
dragging = false;
});
form.addEventListener("submit", sync);
})();
</script>
}

View File

@ -22,7 +22,7 @@
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading pw-page-heading">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.Character.CharacterName, ImagePath = Model.Character.ImagePath, ThumbnailPath = Model.Character.ThumbnailPath, Size = "xl", Lazy = false })" />
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.Character.CharacterName, ImagePath = Model.Character.AvatarImagePath ?? Model.Character.ImagePath, ThumbnailPath = Model.Character.AvatarThumbnailPath ?? Model.Character.ThumbnailPath, Size = "xl", Lazy = false })" />
<div>
<p class="eyebrow">Character</p>
<h1>@Model.Character.CharacterName <help-icon key="characters.details" /></h1>
@ -55,9 +55,18 @@
<a class="pw-character-nav__link" href="#character-relationships">Relationships</a>
<a class="pw-character-nav__link" asp-controller="Dynamics" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Arc</a>
<a class="pw-character-nav__link" asp-controller="Dynamics" asp-action="Knowledge" asp-route-id="@Model.Character.CharacterID">Knowledge</a>
<a class="pw-character-nav__link" href="#character-gallery">Gallery</a>
<a class="pw-character-nav__link" href="#character-appearances">Appearances</a>
</nav>
@if (TempData["ImageMessage"] is string imageMessage)
{
<div class="alert alert-success">@imageMessage</div>
}
@if (TempData["ImageError"] is string imageError)
{
<div class="alert alert-warning">@imageError</div>
}
@if (TempData["RelationshipMessage"] is string relationshipMessage)
{
<div class="alert alert-info">@relationshipMessage</div>
@ -248,6 +257,69 @@
</article>
</section>
<section id="character-gallery" class="list-section mt-3">
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-3">
<div>
<h2>Character Images <help-icon key="characters.images" /></h2>
<p class="muted mb-0">Keep portraits, full-body references, clothing notes, expressions, and inspiration images together.</p>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Character.AvatarImagePath))
{
<form asp-action="RemoveAvatar" method="post" class="archive-button-form" data-confirm-message="Remove this character avatar?&#10;&#10;The gallery images will be kept.">
<input type="hidden" name="id" value="@Model.Character.CharacterID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Remove avatar</button>
</form>
}
</div>
<form asp-action="UploadImage" method="post" enctype="multipart/form-data" class="character-gallery-upload mb-3">
<input type="hidden" name="CharacterID" value="@Model.Character.CharacterID" />
<div>
<label class="form-label" for="ImageUpload">Add image</label>
<input class="form-control" id="ImageUpload" name="ImageUpload" type="file" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" />
</div>
<div>
<label class="form-label" for="Caption">Caption</label>
<input class="form-control" id="Caption" name="Caption" maxlength="500" placeholder="Optional description" />
</div>
<button class="btn btn-primary" type="submit">Upload</button>
</form>
@if (!Model.Images.Any())
{
<p class="muted">No gallery images yet.</p>
}
else
{
<div class="character-gallery-grid">
@foreach (var image in Model.Images)
{
var isAvatarSource = Model.Character.AvatarSourceCharacterImageID == image.CharacterImageID;
<article class="character-gallery-card">
<img src="@image.ThumbnailPath" alt="" loading="lazy" />
<div class="character-gallery-card__body">
<strong>@(string.IsNullOrWhiteSpace(image.Caption) ? "Character reference" : image.Caption)</strong>
<span class="muted">Uploaded @image.UploadedDateUTC.ToLocalTime().ToString("yyyy-MM-dd")</span>
@if (isAvatarSource)
{
<span class="status-pill">Avatar source</span>
}
</div>
<div class="button-row character-gallery-card__actions">
<a class="btn btn-outline-primary btn-sm" asp-action="CropAvatar" asp-route-imageId="@image.CharacterImageID">Create Avatar</a>
<a class="btn btn-outline-secondary btn-sm" href="@image.ImagePath" target="_blank" rel="noopener">Open</a>
<form asp-action="DeleteImage" method="post" class="archive-button-form" data-confirm-message="Remove this image from the character gallery?">
<input type="hidden" name="imageId" value="@image.CharacterImageID" />
<input type="hidden" name="characterId" value="@Model.Character.CharacterID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Remove</button>
</form>
</div>
</article>
}
</div>
}
</section>
<section id="character-relationships" class="list-section mt-3">
<h2>Current Relationships <help-icon key="characters.currentRelationships" /></h2>
@if (!Model.CurrentRelationships.Any())

View File

@ -20,6 +20,8 @@
<input asp-for="ProjectID" type="hidden" />
<input asp-for="ImagePath" type="hidden" />
<input asp-for="ThumbnailPath" type="hidden" />
<input asp-for="AvatarImagePath" type="hidden" />
<input asp-for="AvatarThumbnailPath" type="hidden" />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="row g-3">
@ -72,12 +74,12 @@
<div class="visual-image-editor">
<div>
<p class="eyebrow">Character Image</p>
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.CharacterName, ImagePath = Model.ImagePath, ThumbnailPath = Model.ThumbnailPath, Size = "lg", Lazy = false })" />
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.CharacterName, ImagePath = Model.AvatarImagePath ?? Model.ImagePath, ThumbnailPath = Model.AvatarThumbnailPath ?? Model.ThumbnailPath, Size = "lg", Lazy = false })" />
</div>
<div class="visual-image-editor__controls">
<label asp-for="ImageUpload" class="form-label"></label>
<input asp-for="ImageUpload" class="form-control" type="file" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" />
<div class="form-text">JPG, PNG or WebP. A 128 x 128 thumbnail is generated automatically.</div>
<div class="form-text">JPG, PNG or WebP. The image is added to the character gallery and a thumbnail is generated automatically.</div>
@if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath))
{
<label class="form-check mt-2">

View File

@ -34,7 +34,7 @@
{
<article class="asset-card character-card" draggable="true" data-drag-type="character" data-drag-id="@character.CharacterID" data-drag-label="@character.CharacterName">
<div class="visual-card-heading">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.ImagePath, ThumbnailPath = character.ThumbnailPath })" />
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.AvatarImagePath ?? character.ImagePath, ThumbnailPath = character.AvatarThumbnailPath ?? character.ThumbnailPath })" />
<div>
<p class="eyebrow">Character</p>
<h2><a asp-action="Details" asp-route-id="@character.CharacterID">@character.CharacterName</a></h2>

View File

@ -373,6 +373,7 @@
var ageDisplay = CharacterAgeDisplayService.GetAgeForScene(character, Model.TimeModeName, Model.StartDateTime);
<article class="character-appearance-card">
<div class="character-appearance-summary">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.CharacterImagePath, ThumbnailPath = character.CharacterThumbnailPath })" />
<div>
<strong>@character.CharacterName</strong>
@if (!string.IsNullOrWhiteSpace(ageDisplay.DisplayText))

View File

@ -631,6 +631,114 @@ a:hover {
margin: 0;
}
.character-gallery-upload {
display: grid;
grid-template-columns: minmax(14rem, 1fr) minmax(14rem, 1fr) auto;
gap: 0.75rem;
align-items: end;
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));
gap: 0.9rem;
}
.character-gallery-card {
overflow: hidden;
border: 1px solid var(--plotline-border-soft);
border-radius: var(--plotline-radius-sm);
background: rgba(255, 253, 248, 0.72);
box-shadow: var(--plotline-shadow-subtle);
}
.character-gallery-card > img {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
background: var(--plotline-soft);
object-fit: cover;
}
.character-gallery-card__body {
display: grid;
gap: 0.3rem;
padding: 0.75rem;
}
.character-gallery-card__actions {
border-top: 1px solid rgba(84, 68, 48, 0.1);
padding: 0 0.75rem 0.75rem;
}
.character-avatar-crop-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, 16rem);
gap: 1rem;
align-items: start;
}
.character-avatar-crop-stage {
position: relative;
display: grid;
place-items: center;
min-height: 24rem;
overflow: hidden;
border: 1px solid var(--plotline-border-soft);
border-radius: var(--plotline-radius-sm);
background: var(--plotline-soft);
}
.character-avatar-crop-stage img {
display: block;
max-width: 100%;
max-height: 70vh;
user-select: none;
}
.character-avatar-crop-box {
position: absolute;
cursor: move;
touch-action: none;
border: 2px solid #fff8ec;
box-shadow: 0 0 0 9999px rgba(20, 17, 15, 0.42), 0 0 0 1px rgba(0, 0, 0, 0.18);
}
.character-avatar-crop-box span {
position: absolute;
right: 0.35rem;
bottom: 0.35rem;
width: 0.75rem;
height: 0.75rem;
border-right: 2px solid #fff8ec;
border-bottom: 2px solid #fff8ec;
}
.character-avatar-crop-side {
display: grid;
gap: 0.75rem;
}
.character-avatar-preview {
width: min(100%, 12rem);
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--plotline-border-soft);
border-radius: 999px;
background: var(--plotline-soft);
box-shadow: var(--plotline-shadow-subtle);
}
.character-avatar-preview img {
display: block;
max-width: none;
transform-origin: top left;
}
@media (max-width: 767.98px) {
.pw-page-heading {
align-items: stretch;
@ -659,6 +767,11 @@ a:hover {
.project-card__actions .dropdown {
flex: 1 1 auto;
}
.character-gallery-upload,
.character-avatar-crop-layout {
grid-template-columns: 1fr;
}
}
.timeline-workspace {
@ -2129,6 +2242,25 @@ a:focus-visible {
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.34);
}
[data-theme="dark"] .character-gallery-upload,
[data-theme="dark"] .character-gallery-card,
[data-theme="dark"] .character-avatar-crop-stage,
[data-theme="dark"] .character-avatar-preview {
color: var(--app-text);
border-color: var(--app-border);
background: rgba(42, 35, 30, 0.9);
}
[data-theme="dark"] .character-gallery-card__actions {
border-top-color: rgba(231, 214, 190, 0.14);
}
[data-theme="dark"] .character-gallery-card > img,
[data-theme="dark"] .character-avatar-crop-stage,
[data-theme="dark"] .character-avatar-preview {
background: #211b17;
}
[data-theme="dark"] .timeline-filter,
[data-theme="dark"] .workspace-heading .button-row,
[data-theme="dark"] .timeline-control-panel {

File diff suppressed because one or more lines are too long