diff --git a/PlotLine/Controllers/CharactersController.cs b/PlotLine/Controllers/CharactersController.cs index dc128c9..4465d30 100644 --- a/PlotLine/Controllers/CharactersController.cs +++ b/PlotLine/Controllers/CharactersController.cs @@ -32,6 +32,12 @@ public sealed class CharactersController(ICharacterService characters) : Control return model is null ? NotFound() : View(model); } + public async Task CropAvatar(int imageId) + { + var model = await characters.GetAvatarCropAsync(imageId); + return model is null ? NotFound() : View(model); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task 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 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 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 RemoveAvatar(int id) + { + await characters.RemoveCharacterAvatarAsync(id); + TempData["ImageMessage"] = "Avatar removed."; + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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 Archive(int id, int projectId) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index b651e7e..0892c35 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -183,6 +183,11 @@ public interface ICharacterRepository Task GetCharacterAsync(int characterId); Task SaveCharacterAsync(Character character); Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath); + Task UpdateCharacterAvatarAsync(int characterId, int? sourceCharacterImageId, string? avatarImagePath, string? avatarThumbnailPath); + Task> ListCharacterImagesAsync(int characterId); + Task GetCharacterImageAsync(int characterImageId); + Task CreateCharacterImageAsync(CharacterImage image); + Task DeleteCharacterImageAsync(int characterImageId); Task ArchiveCharacterAsync(int characterId); Task> ListSceneCharactersAsync(int sceneId); Task 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> ListCharacterImagesAsync(int characterId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + 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 GetCharacterImageAsync(int characterImageId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + """ + 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 CreateCharacterImageAsync(CharacterImage image) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + """ + 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(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 1eecfa2..f241fe2 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -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; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 8490f07..d2f90ac 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -228,7 +228,12 @@ public interface ICharacterService Task GetCreateCharacterAsync(int projectId); Task GetEditCharacterAsync(int characterId); Task GetCharacterDetailAsync(int characterId); + Task GetAvatarCropAsync(int characterImageId); Task SaveCharacterAsync(CharacterEditViewModel model); + Task UploadCharacterImageAsync(CharacterImageUploadViewModel model); + Task SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model); + Task RemoveCharacterAvatarAsync(int characterId); + Task DeleteCharacterImageAsync(int characterImageId); Task ArchiveCharacterAsync(int characterId); Task ArchiveRelationshipAsync(int characterRelationshipId); Task 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 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 SaveCharacterAsync(CharacterEditViewModel model) { if (model.CharacterID == 0) @@ -8931,6 +8963,67 @@ public sealed class CharacterService( return characterId; } + public async Task 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 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); diff --git a/PlotLine/Services/VisualIdentityImageService.cs b/PlotLine/Services/VisualIdentityImageService.cs index 9e59f21..3315f6d 100644 --- a/PlotLine/Services/VisualIdentityImageService.cs +++ b/PlotLine/Services/VisualIdentityImageService.cs @@ -8,12 +8,16 @@ namespace PlotLine.Services; public interface IVisualIdentityImageService { Task UploadCharacterImageAsync(Character character, IFormFile upload); + Task UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption); + Task CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize); Task RemoveCharacterImageAsync(Character character); + Task RemoveCharacterAvatarAsync(Character character); + Task DeleteCharacterGalleryImageAsync(CharacterImage image); Task 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 SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" }; private static readonly ConcurrentDictionary EntityLocks = new(); - public Task 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 UploadCharacterImageAsync(Character character, IFormFile upload) + => await UploadCharacterGalleryImageCoreAsync(character, upload, null, updateLegacyImage: true); + + public async Task 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 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 UploadAssetImageAsync(StoryAsset asset, IFormFile upload) => UploadAsync( $"asset:{asset.StoryAssetID}", @@ -193,6 +266,118 @@ public sealed class VisualIdentityImageService( } } + private async Task 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 updateImage) { await updateImage(null, null); diff --git a/PlotLine/Sql/086_Phase14A_CharacterGalleriesAvatars.sql b/PlotLine/Sql/086_Phase14A_CharacterGalleriesAvatars.sql new file mode 100644 index 0000000..ccab14c --- /dev/null +++ b/PlotLine/Sql/086_Phase14A_CharacterGalleriesAvatars.sql @@ -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 diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 43241b2..dd0fa0c 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -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 Images { get; set; } = []; public string DisplayAge { get; set; } = "Unknown"; public IReadOnlyList Appearances { get; set; } = []; public IReadOnlyList AttributeEvents { get; set; } = []; @@ -1666,6 +1669,29 @@ public sealed class CharacterDetailViewModel public IReadOnlyList 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(); diff --git a/PlotLine/Views/Characters/CropAvatar.cshtml b/PlotLine/Views/Characters/CropAvatar.cshtml new file mode 100644 index 0000000..443235b --- /dev/null +++ b/PlotLine/Views/Characters/CropAvatar.cshtml @@ -0,0 +1,152 @@ +@model CharacterAvatarCropViewModel +@{ + ViewData["Title"] = "Create Avatar"; +} + + + +
+
+

Character Avatar

+

Create Avatar

+

Choose a square crop. The original gallery image will be preserved.

+
+
+ +@if (TempData["ImageError"] is string imageError) +{ +
@imageError
+} + +
+ + + + + + +
+
+ +
+ +
+
+
+

Preview

+
+ +
+ @if (!string.IsNullOrWhiteSpace(Model.Caption)) + { +

@Model.Caption

+ } + + +
+
+ +
+ + Cancel +
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/Characters/Details.cshtml b/PlotLine/Views/Characters/Details.cshtml index 86bd615..e6e9883 100644 --- a/PlotLine/Views/Characters/Details.cshtml +++ b/PlotLine/Views/Characters/Details.cshtml @@ -22,7 +22,7 @@
- +

Character

@Model.Character.CharacterName

@@ -55,9 +55,18 @@ Relationships Arc Knowledge + Gallery Appearances +@if (TempData["ImageMessage"] is string imageMessage) +{ +
@imageMessage
+} +@if (TempData["ImageError"] is string imageError) +{ +
@imageError
+} @if (TempData["RelationshipMessage"] is string relationshipMessage) {
@relationshipMessage
@@ -248,6 +257,69 @@ + +

Current Relationships

@if (!Model.CurrentRelationships.Any()) diff --git a/PlotLine/Views/Characters/Edit.cshtml b/PlotLine/Views/Characters/Edit.cshtml index 37e3763..ab514ef 100644 --- a/PlotLine/Views/Characters/Edit.cshtml +++ b/PlotLine/Views/Characters/Edit.cshtml @@ -20,6 +20,8 @@ + +
@@ -72,12 +74,12 @@

Character Image

- +
-
JPG, PNG or WebP. A 128 x 128 thumbnail is generated automatically.
+
JPG, PNG or WebP. The image is added to the character gallery and a thumbnail is generated automatically.
@if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath)) {