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
This commit is contained in:
Nick Beckley 2026-06-22 10:10:39 +01:00
parent b91bdeb138
commit 34a8d94cb4
19 changed files with 897 additions and 19 deletions

View File

@ -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 });
}

View File

@ -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<StoryAssetEditViewModel?> 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;
}
}

View File

@ -147,6 +147,7 @@ public interface IAssetRepository
Task<IReadOnlyList<StoryAsset>> ListAssetsAsync(int projectId);
Task<StoryAsset?> GetAssetAsync(int storyAssetId);
Task<int> SaveAssetAsync(StoryAsset asset);
Task UpdateAssetImageAsync(int storyAssetId, string? imagePath, string? thumbnailPath);
Task ArchiveAssetAsync(int storyAssetId);
Task<IReadOnlyList<AssetEvent>> ListEventsBySceneAsync(int sceneId);
Task<IReadOnlyList<AssetEvent>> ListEventsByAssetAsync(int storyAssetId);
@ -170,6 +171,7 @@ public interface ICharacterRepository
Task<IReadOnlyList<Character>> ListCharactersAsync(int projectId);
Task<Character?> GetCharacterAsync(int characterId);
Task<int> SaveCharacterAsync(Character character);
Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath);
Task ArchiveCharacterAsync(int characterId);
Task<IReadOnlyList<SceneCharacter>> ListSceneCharactersAsync(int sceneId);
Task<int> 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();

View File

@ -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; }

View File

@ -132,6 +132,7 @@ public class Program
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
builder.Services.AddScoped<IProjectActivityService, ProjectActivityService>();
builder.Services.AddScoped<IBookCoverService, BookCoverService>();
builder.Services.AddScoped<IVisualIdentityImageService, VisualIdentityImageService>();
builder.Services.AddScoped<IFloorPlanBackgroundImageService, FloorPlanBackgroundImageService>();
builder.Services.AddScoped<IBookService, BookService>();
builder.Services.AddScoped<IChapterService, ChapterService>();

View File

@ -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<string> 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();
}
}
}

View File

@ -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<StoryAssetListViewModel?> 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<int> 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<CharacterListViewModel?> 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;
}

View File

@ -0,0 +1,307 @@
using System.Collections.Concurrent;
using PlotLine.Data;
using PlotLine.Models;
using SkiaSharp;
namespace PlotLine.Services;
public interface IVisualIdentityImageService
{
Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload);
Task RemoveCharacterImageAsync(Character character);
Task<VisualIdentityImageResult> 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<VisualIdentityImageService> logger) : IVisualIdentityImageService
{
private const long MaxUploadBytes = 10L * 1024L * 1024L;
private const int ThumbnailSize = 128;
private const int StandardMaxSize = 1200;
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 Task RemoveCharacterImageAsync(Character character)
=> RemoveAsync(
character.ImagePath,
character.ThumbnailPath,
"uploads/characters",
(imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath));
public Task<VisualIdentityImageResult> 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<VisualIdentityImageResult> UploadAsync(
string lockKey,
int projectId,
int entityId,
string? oldImagePath,
string? oldThumbnailPath,
string folderName,
string entityType,
Func<string?, string?, Task> 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<VisualIdentityImageResult> UploadCoreAsync(
int projectId,
int entityId,
string? oldImagePath,
string? oldThumbnailPath,
string folderName,
string entityType,
Func<string?, string?, Task> 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<string?, string?, Task> 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));
}

View File

@ -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

View File

@ -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<SelectListItem> AssetKindOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> 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<SelectListItem> 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();

View File

@ -22,6 +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 })" />
<div>
<p class="eyebrow">Character</p>
<h1>@Model.Character.CharacterName <help-icon key="characters.details" /></h1>

View File

@ -10,9 +10,16 @@
</div>
</div>
<form asp-action="Save" method="post" class="edit-panel">
@if (TempData["ImageError"] is string imageError)
{
<div class="alert alert-warning">@imageError</div>
}
<form asp-action="Save" method="post" class="edit-panel" enctype="multipart/form-data">
<input asp-for="CharacterID" type="hidden" />
<input asp-for="ProjectID" type="hidden" />
<input asp-for="ImagePath" type="hidden" />
<input asp-for="ThumbnailPath" type="hidden" />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="row g-3">
@ -61,6 +68,26 @@
<label asp-for="DefaultDescription" class="form-label"></label>
<textarea asp-for="DefaultDescription" class="form-control" rows="5"></textarea>
</div>
<div class="col-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 })" />
</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>
@if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath))
{
<label class="form-check mt-2">
<input asp-for="RemoveImage" class="form-check-input" />
<span class="form-check-label">Remove image</span>
</label>
}
</div>
</div>
</div>
</div>
<div class="button-row mt-3">

View File

@ -33,10 +33,13 @@
@foreach (var character in Model.Characters)
{
<article class="asset-card character-card" draggable="true" data-drag-type="character" data-drag-id="@character.CharacterID" data-drag-label="@character.CharacterName">
<div>
<p class="eyebrow">Character</p>
<h2><a asp-action="Details" asp-route-id="@character.CharacterID">@character.CharacterName</a></h2>
<p class="muted">@character.DefaultDescription</p>
<div class="visual-card-heading">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.ImagePath, ThumbnailPath = character.ThumbnailPath })" />
<div>
<p class="eyebrow">Character</p>
<h2><a asp-action="Details" asp-route-id="@character.CharacterID">@character.CharacterName</a></h2>
<p class="muted">@character.DefaultDescription</p>
</div>
</div>
<div class="asset-card-footer">
@if (!string.IsNullOrWhiteSpace(character.ShortName))

View File

@ -0,0 +1,16 @@
@model AvatarViewModel
@{
var imagePath = !string.IsNullOrWhiteSpace(Model.ThumbnailPath) ? Model.ThumbnailPath : Model.ImagePath;
var cssClass = $"visual-avatar visual-avatar--{Model.Size} {Model.CssClass}".Trim();
}
<span class="@cssClass" title="@Model.DisplayName">
@if (!string.IsNullOrWhiteSpace(imagePath))
{
<img src="@imagePath" alt="" loading="@(Model.Lazy ? "lazy" : "eager")" />
}
else
{
<span>@AvatarHelper.Initials(Model.DisplayName)</span>
}
</span>

View File

@ -17,6 +17,7 @@
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.Asset.AssetName, ImagePath = Model.Asset.ImagePath, ThumbnailPath = Model.Asset.ThumbnailPath, Size = "xl", Lazy = false })" />
<div>
<p class="eyebrow">@Model.Asset.KindName</p>
<h1>@Model.Asset.AssetName <help-icon key="storyAssets.details" /></h1>

View File

@ -10,9 +10,16 @@
</div>
</div>
<form asp-action="Save" method="post" class="edit-panel">
@if (TempData["ImageError"] is string imageError)
{
<div class="alert alert-warning">@imageError</div>
}
<form asp-action="Save" method="post" class="edit-panel" enctype="multipart/form-data">
<input asp-for="StoryAssetID" type="hidden" />
<input asp-for="ProjectID" type="hidden" />
<input asp-for="ImagePath" type="hidden" />
<input asp-for="ThumbnailPath" type="hidden" />
<div class="row g-3">
<div class="col-md-8">
@ -63,6 +70,26 @@
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="5"></textarea>
</div>
<div class="col-12">
<div class="visual-image-editor">
<div>
<p class="eyebrow">Asset Image</p>
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.AssetName, ImagePath = Model.ImagePath, ThumbnailPath = 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>
@if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath))
{
<label class="form-check mt-2">
<input asp-for="RemoveImage" class="form-check-input" />
<span class="form-check-label">Remove image</span>
</label>
}
</div>
</div>
</div>
</div>
<div class="button-row mt-3">

View File

@ -60,10 +60,13 @@
@foreach (var asset in Model.Assets)
{
<article class="asset-card" draggable="true" data-drag-type="asset" data-drag-id="@asset.StoryAssetID" data-drag-label="@asset.AssetName" data-asset-drop-target="true" data-asset-id="@asset.StoryAssetID" data-asset-label="@asset.AssetName">
<div>
<p class="eyebrow">@asset.KindName</p>
<h2><a asp-action="Details" asp-route-id="@asset.StoryAssetID">@asset.AssetName</a></h2>
<p class="muted">@asset.Description</p>
<div class="visual-card-heading">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = asset.AssetName, ImagePath = asset.ImagePath, ThumbnailPath = asset.ThumbnailPath })" />
<div>
<p class="eyebrow">@asset.KindName</p>
<h2><a asp-action="Details" asp-route-id="@asset.StoryAssetID">@asset.AssetName</a></h2>
<p class="muted">@asset.Description</p>
</div>
</div>
<div class="asset-card-footer">
<span class="status-pill">Importance @asset.Importance</span>

View File

@ -4952,6 +4952,73 @@ body.dragging-location [data-drag-type="location"] {
border-color: rgba(155, 52, 43, .6);
}
.visual-card-heading {
align-items: flex-start;
display: flex;
gap: 12px;
}
.visual-card-heading > div {
min-width: 0;
}
.visual-avatar {
align-items: center;
aspect-ratio: 1;
background: linear-gradient(135deg, rgba(47, 111, 99, .16), rgba(212, 154, 98, .16));
border: 1px solid rgba(47, 111, 99, .22);
border-radius: 999px;
color: var(--plotline-accent-dark, #22534a);
display: inline-flex;
flex: 0 0 auto;
font-weight: 900;
justify-content: center;
letter-spacing: .02em;
overflow: hidden;
text-transform: uppercase;
}
.visual-avatar img {
height: 100%;
object-fit: cover;
width: 100%;
}
.visual-avatar--sm {
font-size: .72rem;
width: 32px;
}
.visual-avatar--md {
font-size: .9rem;
width: 48px;
}
.visual-avatar--lg {
font-size: 1.3rem;
width: 96px;
}
.visual-avatar--xl {
font-size: 1.6rem;
width: 128px;
}
.visual-image-editor {
align-items: center;
background: rgba(47, 111, 99, .05);
border: 1px solid rgba(47, 111, 99, .14);
border-radius: 8px;
display: flex;
gap: 18px;
padding: 14px;
}
.visual-image-editor__controls {
flex: 1 1 auto;
min-width: 220px;
}
.floor-plan-block--room {
background: #d9eee7;
}
@ -5289,6 +5356,19 @@ body.dragging-location [data-drag-type="location"] {
border-color: rgba(240, 138, 120, .66);
}
[data-bs-theme="dark"] .visual-avatar,
.dark .visual-avatar {
background: linear-gradient(135deg, rgba(131, 199, 242, .14), rgba(212, 154, 98, .18));
border-color: rgba(244, 234, 220, .18);
color: #f4eadc;
}
[data-bs-theme="dark"] .visual-image-editor,
.dark .visual-image-editor {
background: rgba(244, 234, 220, .04);
border-color: rgba(244, 234, 220, .12);
}
[data-bs-theme="dark"] .floor-plan-transition-item,
.dark .floor-plan-transition-item {
border-color: rgba(212, 154, 98, .22);

File diff suppressed because one or more lines are too long