From 34a8d94cb4128d965254de3d114ff9d57008fad6 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 22 Jun 2026 10:10:39 +0100 Subject: [PATCH] Implemented the Visual Identity infrastructure pass. Changed Added image fields for Characters and Story Assets in models, view models, repositories, services, and save flows. Added reusable avatar/initials rendering via [AvatarHelper.cs](C:/Source/PlotLine/PlotLine/Services/AvatarHelper.cs) and [_Avatar.cshtml](C:/Source/PlotLine/PlotLine/Views/Shared/_Avatar.cshtml). Added upload/remove processing through existing upload storage in [VisualIdentityImageService.cs](C:/Source/PlotLine/PlotLine/Services/VisualIdentityImageService.cs). Updated Character and Story Asset list/detail/edit views with thumbnails, initials fallback, upload, replace, and remove controls. Added styling in [site.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.css) and refreshed site.min.css. Database Added new migration [079_VisualIdentityCharacterAssetImages.sql](C:/Source/PlotLine/PlotLine/Sql/079_VisualIdentityCharacterAssetImages.sql). Adds nullable ImagePath and ThumbnailPath to Characters and StoryAssets. Applied locally and confirmed the columns exist. Stored Procedures Updated active procedure definitions only: Character_ListByProject Character_Get Character_Save Character_UpdateImage StoryAsset_ListByProject StoryAsset_Get StoryAsset_Save StoryAsset_UpdateImage --- PlotLine/Controllers/CharactersController.cs | 29 ++ PlotLine/Controllers/StoryAssetsController.cs | 42 ++- PlotLine/Data/Repositories.cs | 28 +- PlotLine/Models/CoreModels.cs | 4 + PlotLine/Program.cs | 1 + PlotLine/Services/AvatarHelper.cs | 56 ++++ PlotLine/Services/CoreServices.cs | 59 +++- .../Services/VisualIdentityImageService.cs | 307 ++++++++++++++++++ ...079_VisualIdentityCharacterAssetImages.sql | 182 +++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 28 ++ PlotLine/Views/Characters/Details.cshtml | 1 + PlotLine/Views/Characters/Edit.cshtml | 29 +- PlotLine/Views/Characters/Index.cshtml | 11 +- PlotLine/Views/Shared/_Avatar.cshtml | 16 + PlotLine/Views/StoryAssets/Details.cshtml | 1 + PlotLine/Views/StoryAssets/Edit.cshtml | 29 +- PlotLine/Views/StoryAssets/Index.cshtml | 11 +- PlotLine/wwwroot/css/site.css | 80 +++++ PlotLine/wwwroot/css/site.min.css | 2 +- 19 files changed, 897 insertions(+), 19 deletions(-) create mode 100644 PlotLine/Services/AvatarHelper.cs create mode 100644 PlotLine/Services/VisualIdentityImageService.cs create mode 100644 PlotLine/Sql/079_VisualIdentityCharacterAssetImages.sql create mode 100644 PlotLine/Views/Shared/_Avatar.cshtml diff --git a/PlotLine/Controllers/CharactersController.cs b/PlotLine/Controllers/CharactersController.cs index 3b29ffe..dc128c9 100644 --- a/PlotLine/Controllers/CharactersController.cs +++ b/PlotLine/Controllers/CharactersController.cs @@ -38,6 +38,7 @@ public sealed class CharactersController(ICharacterService characters) : Control { if (!ModelState.IsValid) { + model.Project ??= (await characters.GetCreateCharacterAsync(model.ProjectID))?.Project; return View("Edit", model); } @@ -67,6 +68,34 @@ public sealed class CharactersController(ICharacterService characters) : Control createModel.DefaultDescription = model.DefaultDescription; return View("Edit", createModel); } + catch (EntityImageUploadException ex) + { + TempData["ImageError"] = ex.Message; + return RedirectToAction(nameof(Edit), new { id = ex.EntityId }); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + var editModel = model.CharacterID == 0 + ? await characters.GetCreateCharacterAsync(model.ProjectID) + : await characters.GetEditCharacterAsync(model.CharacterID); + if (editModel is null) + { + return NotFound(); + } + + editModel.CharacterName = model.CharacterName; + editModel.ShortName = model.ShortName; + editModel.Sex = model.Sex; + editModel.BirthDate = model.BirthDate; + editModel.AgeAtSeriesStart = model.AgeAtSeriesStart; + editModel.Height = model.Height; + editModel.EyeColour = model.EyeColour; + editModel.CharacterImportance = model.CharacterImportance; + editModel.ShowInQuickAddBar = model.ShowInQuickAddBar; + editModel.DefaultDescription = model.DefaultDescription; + return View("Edit", editModel); + } return RedirectToAction(nameof(Details), new { id = characterId }); } diff --git a/PlotLine/Controllers/StoryAssetsController.cs b/PlotLine/Controllers/StoryAssetsController.cs index 7cbfcda..592991b 100644 --- a/PlotLine/Controllers/StoryAssetsController.cs +++ b/PlotLine/Controllers/StoryAssetsController.cs @@ -38,10 +38,27 @@ public sealed class StoryAssetsController(IAssetService assets) : Controller { if (!ModelState.IsValid) { - return View("Edit", model); + var invalidModel = await RehydrateEditModel(model); + return invalidModel is null ? NotFound() : View("Edit", invalidModel); + } + + int assetId; + try + { + assetId = await assets.SaveAssetAsync(model); + } + catch (EntityImageUploadException ex) + { + TempData["ImageError"] = ex.Message; + return RedirectToAction(nameof(Edit), new { id = ex.EntityId }); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + var editModel = await RehydrateEditModel(model); + return editModel is null ? NotFound() : View("Edit", editModel); } - var assetId = await assets.SaveAssetAsync(model); return RedirectToAction(nameof(Details), new { id = assetId }); } @@ -81,4 +98,25 @@ public sealed class StoryAssetsController(IAssetService assets) : Controller await assets.DeleteDependencyAsync(id); return RedirectToAction(nameof(Details), new { id = returnAssetId }); } + + private async Task RehydrateEditModel(StoryAssetEditViewModel posted) + { + var model = posted.StoryAssetID == 0 + ? await assets.GetCreateAssetAsync(posted.ProjectID) + : await assets.GetEditAssetAsync(posted.StoryAssetID); + if (model is null) + { + return null; + } + + model.AssetName = posted.AssetName; + model.AssetKindID = posted.AssetKindID; + model.Description = posted.Description; + model.Importance = posted.Importance; + model.CurrentStateID = posted.CurrentStateID; + model.CurrentLocationID = posted.CurrentLocationID; + model.IsResolved = posted.IsResolved; + model.ShowInQuickAddBar = posted.ShowInQuickAddBar; + return model; + } } diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index d18ae8a..d8048a0 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -147,6 +147,7 @@ public interface IAssetRepository Task> ListAssetsAsync(int projectId); Task GetAssetAsync(int storyAssetId); Task SaveAssetAsync(StoryAsset asset); + Task UpdateAssetImageAsync(int storyAssetId, string? imagePath, string? thumbnailPath); Task ArchiveAssetAsync(int storyAssetId); Task> ListEventsBySceneAsync(int sceneId); Task> ListEventsByAssetAsync(int storyAssetId); @@ -170,6 +171,7 @@ public interface ICharacterRepository Task> ListCharactersAsync(int projectId); Task GetCharacterAsync(int characterId); Task SaveCharacterAsync(Character character); + Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath); Task ArchiveCharacterAsync(int characterId); Task> ListSceneCharactersAsync(int sceneId); Task SaveSceneCharacterAsync(SceneCharacter sceneCharacter); @@ -3104,11 +3106,22 @@ public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : I asset.CurrentStateID, asset.CurrentLocationID, asset.IsResolved, - asset.ShowInQuickAddBar + asset.ShowInQuickAddBar, + asset.ImagePath, + asset.ThumbnailPath }, commandType: CommandType.StoredProcedure); } + public async Task UpdateAssetImageAsync(int storyAssetId, string? imagePath, string? thumbnailPath) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.StoryAsset_UpdateImage", + new { StoryAssetID = storyAssetId, ImagePath = imagePath, ThumbnailPath = thumbnailPath }, + commandType: CommandType.StoredProcedure); + } + public async Task ArchiveAssetAsync(int storyAssetId) { using var connection = connectionFactory.CreateConnection(); @@ -3369,11 +3382,22 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory) character.EyeColour, character.CharacterImportance, character.ShowInQuickAddBar, - character.DefaultDescription + character.DefaultDescription, + character.ImagePath, + character.ThumbnailPath }, commandType: CommandType.StoredProcedure); } + public async Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.Character_UpdateImage", + new { CharacterID = characterId, ImagePath = imagePath, ThumbnailPath = thumbnailPath }, + commandType: CommandType.StoredProcedure); + } + public async Task ArchiveCharacterAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 238132e..e57cd65 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -669,6 +669,8 @@ public sealed class StoryAsset public string? CurrentLocationPath { get; set; } public bool IsResolved { get; set; } public bool ShowInQuickAddBar { get; set; } + public string? ImagePath { get; set; } + public string? ThumbnailPath { get; set; } public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } @@ -828,6 +830,8 @@ public sealed class Character public int? CharacterImportance { get; set; } public bool ShowInQuickAddBar { get; set; } public string? DefaultDescription { get; set; } + public string? ImagePath { get; set; } + public string? ThumbnailPath { get; set; } public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 6ce2b72..77b581b 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -132,6 +132,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/AvatarHelper.cs b/PlotLine/Services/AvatarHelper.cs new file mode 100644 index 0000000..9cfc115 --- /dev/null +++ b/PlotLine/Services/AvatarHelper.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace PlotLine.Services; + +public static class AvatarHelper +{ + public static string Initials(string? displayName) + { + var words = SignificantWords(displayName).ToList(); + if (words.Count >= 2) + { + return string.Concat(words.Take(2).Select(word => char.ToUpperInvariant(word[0]))); + } + + if (words.Count == 1) + { + return char.ToUpperInvariant(words[0][0]).ToString(); + } + + return "?"; + } + + private static IEnumerable SignificantWords(string? displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + { + yield break; + } + + var builder = new StringBuilder(); + foreach (var ch in displayName) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(ch); + continue; + } + + if (ch is '\'' or '\u2019') + { + continue; + } + + if (builder.Length > 0) + { + yield return builder.ToString(); + builder.Clear(); + } + } + + if (builder.Length > 0) + { + yield return builder.ToString(); + } + } +} diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 8515c49..99174f0 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -48,6 +48,11 @@ public sealed class ProjectCollaborationResult public static ProjectCollaborationResult Inaccessible() => new() { NotFound = true, Message = "Project not found." }; } +public sealed class EntityImageUploadException(string message, int entityId) : InvalidOperationException(message) +{ + public int EntityId { get; } = entityId; +} + public sealed class ProjectActivityService( IProjectRepository projects, IProjectActivityRepository activity, @@ -7143,7 +7148,12 @@ public sealed class StorageService( } } -public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations, IProjectActivityService activity) : IAssetService +public sealed class AssetService( + IProjectRepository projects, + IAssetRepository assets, + ILocationRepository locations, + IProjectActivityService activity, + IVisualIdentityImageService visualIdentityImages) : IAssetService { public async Task GetAssetsAsync(int projectId) { @@ -7205,6 +7215,8 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a CurrentLocationID = asset.CurrentLocationID, IsResolved = asset.IsResolved, ShowInQuickAddBar = asset.ShowInQuickAddBar, + ImagePath = asset.ImagePath, + ThumbnailPath = asset.ThumbnailPath, Project = project }, lookupData); } @@ -7250,6 +7262,7 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a public async Task SaveAssetAsync(StoryAssetEditViewModel model) { var isNew = model.StoryAssetID == 0; + var existing = isNew ? null : await assets.GetAssetAsync(model.StoryAssetID); var assetId = await assets.SaveAssetAsync(new StoryAsset { StoryAssetID = model.StoryAssetID, @@ -7261,8 +7274,25 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a CurrentStateID = model.CurrentStateID, CurrentLocationID = model.CurrentLocationID, IsResolved = model.IsResolved, - ShowInQuickAddBar = model.ShowInQuickAddBar + ShowInQuickAddBar = model.ShowInQuickAddBar, + ImagePath = model.ImagePath ?? existing?.ImagePath, + ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath }); + var imageHost = existing ?? new StoryAsset { StoryAssetID = assetId, ProjectID = model.ProjectID }; + imageHost.StoryAssetID = assetId; + imageHost.ProjectID = model.ProjectID; + if (model.ImageUpload is { Length: > 0 } upload) + { + var result = await visualIdentityImages.UploadAssetImageAsync(imageHost, upload); + if (!result.Succeeded) + { + throw new EntityImageUploadException(result.ErrorMessage ?? "Asset image could not be uploaded.", assetId); + } + } + else if (model.RemoveImage && existing is not null) + { + await visualIdentityImages.RemoveAssetImageAsync(existing); + } await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Asset", assetId, model.AssetName); return assetId; } @@ -8338,7 +8368,8 @@ public sealed class CharacterService( ICharacterRepository characters, ISubscriptionService subscriptions, IProjectActivityService activity, - ICurrentUserService currentUser) : ICharacterService + ICurrentUserService currentUser, + IVisualIdentityImageService visualIdentityImages) : ICharacterService { public async Task GetCharactersAsync(int projectId) { @@ -8378,6 +8409,8 @@ public sealed class CharacterService( CharacterImportance = character.CharacterImportance, ShowInQuickAddBar = character.ShowInQuickAddBar, DefaultDescription = character.DefaultDescription, + ImagePath = character.ImagePath, + ThumbnailPath = character.ThumbnailPath, Project = await projects.GetAsync(character.ProjectID) }; } @@ -8459,6 +8492,7 @@ public sealed class CharacterService( } var isNew = model.CharacterID == 0; + var existing = isNew ? null : await characters.GetCharacterAsync(model.CharacterID); var characterId = await characters.SaveCharacterAsync(new Character { CharacterID = model.CharacterID, @@ -8472,8 +8506,25 @@ public sealed class CharacterService( EyeColour = model.EyeColour, CharacterImportance = model.CharacterImportance, ShowInQuickAddBar = model.ShowInQuickAddBar, - DefaultDescription = model.DefaultDescription + DefaultDescription = model.DefaultDescription, + ImagePath = model.ImagePath ?? existing?.ImagePath, + ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath }); + var imageHost = existing ?? new Character { CharacterID = characterId, ProjectID = model.ProjectID }; + imageHost.CharacterID = characterId; + imageHost.ProjectID = model.ProjectID; + if (model.ImageUpload is { Length: > 0 } upload) + { + var result = await visualIdentityImages.UploadCharacterImageAsync(imageHost, upload); + if (!result.Succeeded) + { + throw new EntityImageUploadException(result.ErrorMessage ?? "Character image could not be uploaded.", characterId); + } + } + else if (model.RemoveImage && existing is not null) + { + await visualIdentityImages.RemoveCharacterImageAsync(existing); + } await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Character", characterId, model.CharacterName); return characterId; } diff --git a/PlotLine/Services/VisualIdentityImageService.cs b/PlotLine/Services/VisualIdentityImageService.cs new file mode 100644 index 0000000..9e59f21 --- /dev/null +++ b/PlotLine/Services/VisualIdentityImageService.cs @@ -0,0 +1,307 @@ +using System.Collections.Concurrent; +using PlotLine.Data; +using PlotLine.Models; +using SkiaSharp; + +namespace PlotLine.Services; + +public interface IVisualIdentityImageService +{ + Task UploadCharacterImageAsync(Character character, IFormFile upload); + Task RemoveCharacterImageAsync(Character character); + Task UploadAssetImageAsync(StoryAsset asset, IFormFile upload); + Task RemoveAssetImageAsync(StoryAsset asset); +} + +public sealed record VisualIdentityImageResult(bool Succeeded, string? ErrorMessage = null); + +public sealed class VisualIdentityImageService( + IUploadStorageService uploadStorage, + IProjectCollaborationRepository collaboration, + ISubscriptionService subscriptions, + IUserFileRepository userFiles, + ICharacterRepository characters, + IAssetRepository assets, + ILogger logger) : IVisualIdentityImageService +{ + private const long MaxUploadBytes = 10L * 1024L * 1024L; + private const int ThumbnailSize = 128; + private const int StandardMaxSize = 1200; + 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 Task RemoveCharacterImageAsync(Character character) + => RemoveAsync( + character.ImagePath, + character.ThumbnailPath, + "uploads/characters", + (imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath)); + + public Task UploadAssetImageAsync(StoryAsset asset, IFormFile upload) + => UploadAsync( + $"asset:{asset.StoryAssetID}", + asset.ProjectID, + asset.StoryAssetID, + asset.ImagePath, + asset.ThumbnailPath, + "story-assets", + "StoryAssetImage", + (imagePath, thumbnailPath) => assets.UpdateAssetImageAsync(asset.StoryAssetID, imagePath, thumbnailPath), + upload); + + public Task RemoveAssetImageAsync(StoryAsset asset) + => RemoveAsync( + asset.ImagePath, + asset.ThumbnailPath, + "uploads/story-assets", + (imagePath, thumbnailPath) => assets.UpdateAssetImageAsync(asset.StoryAssetID, imagePath, thumbnailPath)); + + private async Task UploadAsync( + string lockKey, + int projectId, + int entityId, + string? oldImagePath, + string? oldThumbnailPath, + string folderName, + string entityType, + Func updateImage, + IFormFile upload) + { + var uploadLock = EntityLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1)); + await uploadLock.WaitAsync(); + try + { + return await UploadCoreAsync(projectId, entityId, oldImagePath, oldThumbnailPath, folderName, entityType, updateImage, upload); + } + finally + { + uploadLock.Release(); + } + } + + private async Task UploadCoreAsync( + int projectId, + int entityId, + string? oldImagePath, + string? oldThumbnailPath, + string folderName, + string entityType, + Func updateImage, + IFormFile upload) + { + 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(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 uploadRoot = uploadStorage.GetDirectory(folderName, entityId.ToString()); + var version = Guid.NewGuid().ToString("N"); + var imageTemp = Path.Combine(uploadRoot, $"image-{version}.tmp"); + var thumbnailTemp = Path.Combine(uploadRoot, $"thumb-{version}.tmp"); + var imagePath = Path.Combine(uploadRoot, "image.webp"); + var thumbnailPath = Path.Combine(uploadRoot, "thumb.webp"); + var imageStoragePath = uploadStorage.GetPublicPath(folderName, entityId.ToString(), "image.webp"); + var thumbnailStoragePath = uploadStorage.GetPublicPath(folderName, entityId.ToString(), "thumb.webp"); + + try + { + SaveContainedWebp(bitmap, imageTemp, StandardMaxSize, 82); + SaveCroppedWebp(bitmap, thumbnailTemp, ThumbnailSize, ThumbnailSize, 76); + + ReplaceFile(imageTemp, imagePath); + ReplaceFile(thumbnailTemp, thumbnailPath); + + await updateImage(imageStoragePath, thumbnailStoragePath); + await MarkPreviousLedgerRowsAsync(oldImagePath); + await MarkPreviousLedgerRowsAsync(oldThumbnailPath); + await TrackImageAsync(ownerUserId.Value, projectId, entityId, entityType, imageStoragePath, upload.FileName, new FileInfo(imagePath).Length); + await TrackImageAsync(ownerUserId.Value, projectId, entityId, entityType, thumbnailStoragePath, "Thumbnail: " + upload.FileName, new FileInfo(thumbnailPath).Length); + DeletePreviousPhysicalFile(oldImagePath, imageStoragePath, $"uploads/{folderName}"); + DeletePreviousPhysicalFile(oldThumbnailPath, thumbnailStoragePath, $"uploads/{folderName}"); + return new(true); + } + catch (Exception ex) + { + TryDeleteFile(imageTemp); + TryDeleteFile(thumbnailTemp); + logger.LogWarning(ex, "Visual identity image upload failed for {EntityType} {EntityID}.", entityType, entityId); + return new(false, "Image could not be processed. Try another image."); + } + } + } + + private async Task RemoveAsync(string? imagePath, string? thumbnailPath, string requiredPrefix, Func updateImage) + { + await updateImage(null, null); + await MarkPreviousLedgerRowsAsync(imagePath); + await MarkPreviousLedgerRowsAsync(thumbnailPath); + DeletePreviousPhysicalFile(imagePath, null, requiredPrefix); + DeletePreviousPhysicalFile(thumbnailPath, null, requiredPrefix); + } + + private static void SaveContainedWebp(SKBitmap source, string path, int maxSize, int quality) + { + var scale = Math.Min(maxSize / (float)source.Width, maxSize / (float)source.Height); + scale = Math.Min(scale, 1f); + var width = Math.Max(1, (int)Math.Round(source.Width * scale)); + var height = Math.Max(1, (int)Math.Round(source.Height * scale)); + SaveWebp(source, path, new SKRect(0, 0, source.Width, source.Height), width, height, quality); + } + + private static void SaveCroppedWebp(SKBitmap source, string path, int width, int height, int quality) + { + var sourceRatio = source.Width / (float)source.Height; + var targetRatio = width / (float)height; + SKRect sourceRect; + + if (sourceRatio > targetRatio) + { + var cropWidth = source.Height * targetRatio; + var left = (source.Width - cropWidth) / 2f; + sourceRect = new SKRect(left, 0, left + cropWidth, source.Height); + } + else + { + var cropHeight = source.Width / targetRatio; + var top = (source.Height - cropHeight) / 2f; + sourceRect = new SKRect(0, top, source.Width, top + cropHeight); + } + + SaveWebp(source, path, sourceRect, width, height, quality); + } + + private static void SaveWebp(SKBitmap source, string path, SKRect sourceRect, int width, int height, int quality) + { + using var surface = SKSurface.Create(new SKImageInfo(width, height)); + var canvas = surface.Canvas; + canvas.Clear(SKColors.Transparent); + using var paint = new SKPaint { IsAntialias = true }; + using var sourceImage = SKImage.FromBitmap(source); + canvas.DrawImage(sourceImage, sourceRect, new SKRect(0, 0, width, height), new SKSamplingOptions(SKCubicResampler.Mitchell), paint); + canvas.Flush(); + + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Webp, quality); + using var output = File.Create(path); + data.SaveTo(output); + } + + private async Task TrackImageAsync(int ownerUserId, int projectId, int entityId, string entityType, string storagePath, string originalFileName, long fileSizeBytes) + { + await userFiles.CreateAsync(new UserFile + { + UserID = ownerUserId, + ProjectID = projectId, + EntityType = entityType, + EntityID = entityId, + FileName = Path.GetFileName(storagePath), + OriginalFileName = originalFileName, + ContentType = "image/webp", + FileSizeBytes = fileSizeBytes, + StoragePath = storagePath + }); + } + + private async Task MarkPreviousLedgerRowsAsync(string? oldStoragePath) + { + if (!string.IsNullOrWhiteSpace(oldStoragePath)) + { + await userFiles.MarkDeletedByStoragePathAsync(oldStoragePath); + } + } + + private void DeletePreviousPhysicalFile(string? oldStoragePath, string? currentStoragePath, string requiredPrefix) + { + if (string.IsNullOrWhiteSpace(oldStoragePath) + || string.Equals(oldStoragePath, currentStoragePath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + uploadStorage.TryDeleteFile(oldStoragePath, requiredPrefix); + } + + private static void ReplaceFile(string tempPath, string finalPath) + { + if (File.Exists(finalPath)) + { + File.Delete(finalPath); + } + + File.Move(tempPath, finalPath); + } + + private static void TryDeleteFile(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + private static long EstimatedGeneratedSize(long uploadBytes) + => Math.Max(1, Math.Min(uploadBytes, MaxUploadBytes)); +} diff --git a/PlotLine/Sql/079_VisualIdentityCharacterAssetImages.sql b/PlotLine/Sql/079_VisualIdentityCharacterAssetImages.sql new file mode 100644 index 0000000..41a572e --- /dev/null +++ b/PlotLine/Sql/079_VisualIdentityCharacterAssetImages.sql @@ -0,0 +1,182 @@ +IF COL_LENGTH(N'dbo.Characters', N'ImagePath') IS NULL + ALTER TABLE dbo.Characters ADD ImagePath nvarchar(500) NULL; +GO + +IF COL_LENGTH(N'dbo.Characters', N'ThumbnailPath') IS NULL + ALTER TABLE dbo.Characters ADD ThumbnailPath nvarchar(500) NULL; +GO + +IF COL_LENGTH(N'dbo.StoryAssets', N'ImagePath') IS NULL + ALTER TABLE dbo.StoryAssets ADD ImagePath nvarchar(500) NULL; +GO + +IF COL_LENGTH(N'dbo.StoryAssets', N'ThumbnailPath') IS NULL + ALTER TABLE dbo.StoryAssets ADD ThumbnailPath nvarchar(500) NULL; +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, + CreatedDate, UpdatedDate, IsArchived + 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, + CreatedDate, UpdatedDate, IsArchived + 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.StoryAsset_ListByProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + SELECT sa.StoryAssetID, sa.ProjectID, sa.AssetName, sa.AssetKindID, ak.KindName, sa.Description, sa.Importance, + sa.CurrentStateID, ast.StateName AS CurrentStateName, sa.CurrentLocationID, + location.LocationName AS CurrentLocationName, location.LocationPath AS CurrentLocationPath, + sa.IsResolved, sa.ShowInQuickAddBar, sa.ImagePath, sa.ThumbnailPath, sa.CreatedDate, sa.UpdatedDate, sa.IsArchived + FROM dbo.StoryAssets sa + INNER JOIN dbo.AssetKinds ak ON ak.AssetKindID = sa.AssetKindID + LEFT JOIN dbo.AssetStates ast ON ast.AssetStateID = sa.CurrentStateID + LEFT JOIN dbo.LocationPaths location ON location.LocationID = sa.CurrentLocationID + WHERE sa.ProjectID = @ProjectID AND sa.IsArchived = 0 + ORDER BY sa.Importance DESC, sa.AssetName; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryAsset_Get + @StoryAssetID int +AS +BEGIN + SET NOCOUNT ON; + SELECT sa.StoryAssetID, sa.ProjectID, sa.AssetName, sa.AssetKindID, ak.KindName, sa.Description, sa.Importance, + sa.CurrentStateID, ast.StateName AS CurrentStateName, sa.CurrentLocationID, + location.LocationName AS CurrentLocationName, location.LocationPath AS CurrentLocationPath, + sa.IsResolved, sa.ShowInQuickAddBar, sa.ImagePath, sa.ThumbnailPath, sa.CreatedDate, sa.UpdatedDate, sa.IsArchived + FROM dbo.StoryAssets sa + INNER JOIN dbo.AssetKinds ak ON ak.AssetKindID = sa.AssetKindID + LEFT JOIN dbo.AssetStates ast ON ast.AssetStateID = sa.CurrentStateID + LEFT JOIN dbo.LocationPaths location ON location.LocationID = sa.CurrentLocationID + WHERE sa.StoryAssetID = @StoryAssetID AND sa.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryAsset_Save + @StoryAssetID int = NULL, + @ProjectID int, + @AssetName nvarchar(200), + @AssetKindID int, + @Description nvarchar(max) = NULL, + @Importance int, + @CurrentStateID int = NULL, + @CurrentLocationID int = NULL, + @IsResolved bit, + @ShowInQuickAddBar bit = 0, + @ImagePath nvarchar(500) = NULL, + @ThumbnailPath nvarchar(500) = NULL +AS +BEGIN + SET NOCOUNT ON; + IF @StoryAssetID IS NULL OR @StoryAssetID = 0 + BEGIN + INSERT dbo.StoryAssets (ProjectID, AssetName, AssetKindID, Description, Importance, CurrentStateID, CurrentLocationID, IsResolved, ShowInQuickAddBar, ImagePath, ThumbnailPath) + VALUES (@ProjectID, @AssetName, @AssetKindID, @Description, @Importance, @CurrentStateID, @CurrentLocationID, @IsResolved, @ShowInQuickAddBar, @ImagePath, @ThumbnailPath); + SET @StoryAssetID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.StoryAssets + SET AssetName = @AssetName, AssetKindID = @AssetKindID, Description = @Description, Importance = @Importance, + CurrentStateID = @CurrentStateID, CurrentLocationID = @CurrentLocationID, IsResolved = @IsResolved, + ShowInQuickAddBar = @ShowInQuickAddBar, ImagePath = @ImagePath, ThumbnailPath = @ThumbnailPath, + UpdatedDate = SYSUTCDATETIME() + WHERE StoryAssetID = @StoryAssetID; + END + SELECT @StoryAssetID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryAsset_UpdateImage + @StoryAssetID int, + @ImagePath nvarchar(500) = NULL, + @ThumbnailPath nvarchar(500) = NULL +AS +BEGIN + SET NOCOUNT ON; + UPDATE dbo.StoryAssets + SET ImagePath = @ImagePath, + ThumbnailPath = @ThumbnailPath, + UpdatedDate = SYSUTCDATETIME() + WHERE StoryAssetID = @StoryAssetID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 0776c3c..3c69055 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -1417,6 +1417,15 @@ public sealed class StoryAssetEditViewModel [Display(Name = "Show in Quick Add Bar")] public bool ShowInQuickAddBar { get; set; } + public string? ImagePath { get; set; } + public string? ThumbnailPath { get; set; } + + [Display(Name = "Upload image")] + public IFormFile? ImageUpload { get; set; } + + [Display(Name = "Remove image")] + public bool RemoveImage { get; set; } + public Project? Project { get; set; } public IReadOnlyList AssetKindOptions { get; set; } = []; public IReadOnlyList AssetStateOptions { get; set; } = []; @@ -1541,6 +1550,15 @@ public sealed class CharacterEditViewModel [Display(Name = "Default description")] public string? DefaultDescription { get; set; } + public string? ImagePath { get; set; } + public string? ThumbnailPath { get; set; } + + [Display(Name = "Upload image")] + public IFormFile? ImageUpload { get; set; } + + [Display(Name = "Remove image")] + public bool RemoveImage { get; set; } + public IReadOnlyList ImportanceOptions { get; set; } = [ new("Use timeline fallback", string.Empty), @@ -1981,6 +1999,16 @@ public sealed class FloorPlanTransitionEditViewModel public string Label => string.IsNullOrWhiteSpace(DisplayLabel) ? TransitionType : DisplayLabel; } +public sealed class AvatarViewModel +{ + public string DisplayName { get; set; } = string.Empty; + public string? ImagePath { get; set; } + public string? ThumbnailPath { get; set; } + public string Size { get; set; } = "md"; + public string? CssClass { get; set; } + public bool Lazy { get; set; } = true; +} + public sealed class WarningDashboardViewModel { public Project Project { get; set; } = new(); diff --git a/PlotLine/Views/Characters/Details.cshtml b/PlotLine/Views/Characters/Details.cshtml index f7be52b..86bd615 100644 --- a/PlotLine/Views/Characters/Details.cshtml +++ b/PlotLine/Views/Characters/Details.cshtml @@ -22,6 +22,7 @@
+

Character

@Model.Character.CharacterName

diff --git a/PlotLine/Views/Characters/Edit.cshtml b/PlotLine/Views/Characters/Edit.cshtml index e308315..37e3763 100644 --- a/PlotLine/Views/Characters/Edit.cshtml +++ b/PlotLine/Views/Characters/Edit.cshtml @@ -10,9 +10,16 @@
-
+@if (TempData["ImageError"] is string imageError) +{ +
@imageError
+} + + + +
@@ -61,6 +68,26 @@
+
+
+
+

Character Image

+ +
+
+ + +
JPG, PNG or WebP. A 128 x 128 thumbnail is generated automatically.
+ @if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath)) + { + + } +
+
+
diff --git a/PlotLine/Views/Characters/Index.cshtml b/PlotLine/Views/Characters/Index.cshtml index 9d04a1f..70c4c91 100644 --- a/PlotLine/Views/Characters/Index.cshtml +++ b/PlotLine/Views/Characters/Index.cshtml @@ -33,10 +33,13 @@ @foreach (var character in Model.Characters) {
-
-

Character

-

@character.CharacterName

-

@character.DefaultDescription

+
+ +
+

Character

+

@character.CharacterName

+

@character.DefaultDescription

+