Implemented Phase 14A: Character Galleries & Avatar Cropping.
This commit is contained in:
parent
54fdc034ea
commit
28cab2adf4
@ -32,6 +32,12 @@ public sealed class CharactersController(ICharacterService characters) : Control
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> CropAvatar(int imageId)
|
||||
{
|
||||
var model = await characters.GetAvatarCropAsync(imageId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Save(CharacterEditViewModel model)
|
||||
@ -100,6 +106,48 @@ public sealed class CharactersController(ICharacterService characters) : Control
|
||||
return RedirectToAction(nameof(Details), new { id = characterId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> UploadImage(CharacterImageUploadViewModel model)
|
||||
{
|
||||
var result = await characters.UploadCharacterImageAsync(model);
|
||||
TempData[result.Succeeded ? "ImageMessage" : "ImageError"] = result.Succeeded
|
||||
? "Image added to gallery."
|
||||
: result.ErrorMessage ?? "Character image could not be uploaded.";
|
||||
return RedirectToAction(nameof(Details), new { id = model.CharacterID });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveAvatar(CharacterAvatarCropViewModel model)
|
||||
{
|
||||
var result = await characters.SaveCharacterAvatarAsync(model);
|
||||
TempData[result.Succeeded ? "ImageMessage" : "ImageError"] = result.Succeeded
|
||||
? "Avatar updated."
|
||||
: result.ErrorMessage ?? "Avatar could not be saved.";
|
||||
return result.Succeeded
|
||||
? RedirectToAction(nameof(Details), new { id = model.CharacterID })
|
||||
: RedirectToAction(nameof(CropAvatar), new { imageId = model.CharacterImageID });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RemoveAvatar(int id)
|
||||
{
|
||||
await characters.RemoveCharacterAvatarAsync(id);
|
||||
TempData["ImageMessage"] = "Avatar removed.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteImage(int imageId, int characterId)
|
||||
{
|
||||
await characters.DeleteCharacterImageAsync(imageId);
|
||||
TempData["ImageMessage"] = "Image removed from gallery.";
|
||||
return RedirectToAction(nameof(Details), new { id = characterId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Archive(int id, int projectId)
|
||||
|
||||
@ -183,6 +183,11 @@ public interface ICharacterRepository
|
||||
Task<Character?> GetCharacterAsync(int characterId);
|
||||
Task<int> SaveCharacterAsync(Character character);
|
||||
Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath);
|
||||
Task UpdateCharacterAvatarAsync(int characterId, int? sourceCharacterImageId, string? avatarImagePath, string? avatarThumbnailPath);
|
||||
Task<IReadOnlyList<CharacterImage>> ListCharacterImagesAsync(int characterId);
|
||||
Task<CharacterImage?> GetCharacterImageAsync(int characterImageId);
|
||||
Task<int> CreateCharacterImageAsync(CharacterImage image);
|
||||
Task DeleteCharacterImageAsync(int characterImageId);
|
||||
Task ArchiveCharacterAsync(int characterId);
|
||||
Task<IReadOnlyList<SceneCharacter>> ListSceneCharactersAsync(int sceneId);
|
||||
Task<int> SaveSceneCharacterAsync(SceneCharacter sceneCharacter);
|
||||
@ -3561,6 +3566,83 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task UpdateCharacterAvatarAsync(int characterId, int? sourceCharacterImageId, string? avatarImagePath, string? avatarThumbnailPath)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.Character_UpdateAvatar",
|
||||
new
|
||||
{
|
||||
CharacterID = characterId,
|
||||
AvatarSourceCharacterImageID = sourceCharacterImageId,
|
||||
AvatarImagePath = avatarImagePath,
|
||||
AvatarThumbnailPath = avatarThumbnailPath
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CharacterImage>> ListCharacterImagesAsync(int characterId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<CharacterImage>(
|
||||
"""
|
||||
SELECT CharacterImageID, CharacterID, ProjectID, ImagePath, ThumbnailPath, Caption, SortOrder, UploadedDateUTC, IsDeleted
|
||||
FROM dbo.CharacterImages
|
||||
WHERE CharacterID = @CharacterID AND IsDeleted = 0
|
||||
ORDER BY SortOrder, UploadedDateUTC, CharacterImageID;
|
||||
""",
|
||||
new { CharacterID = characterId });
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<CharacterImage?> GetCharacterImageAsync(int characterImageId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<CharacterImage>(
|
||||
"""
|
||||
SELECT CharacterImageID, CharacterID, ProjectID, ImagePath, ThumbnailPath, Caption, SortOrder, UploadedDateUTC, IsDeleted
|
||||
FROM dbo.CharacterImages
|
||||
WHERE CharacterImageID = @CharacterImageID AND IsDeleted = 0;
|
||||
""",
|
||||
new { CharacterImageID = characterImageId });
|
||||
}
|
||||
|
||||
public async Task<int> CreateCharacterImageAsync(CharacterImage image)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"""
|
||||
DECLARE @SortOrder int = COALESCE(@InputSortOrder, (SELECT ISNULL(MAX(SortOrder), 0) + 1 FROM dbo.CharacterImages WHERE CharacterID = @CharacterID));
|
||||
|
||||
INSERT dbo.CharacterImages (CharacterID, ProjectID, ImagePath, ThumbnailPath, Caption, SortOrder)
|
||||
VALUES (@CharacterID, @ProjectID, @ImagePath, @ThumbnailPath, @Caption, @SortOrder);
|
||||
|
||||
SELECT CAST(SCOPE_IDENTITY() AS int);
|
||||
""",
|
||||
new
|
||||
{
|
||||
image.CharacterID,
|
||||
image.ProjectID,
|
||||
image.ImagePath,
|
||||
image.ThumbnailPath,
|
||||
image.Caption,
|
||||
InputSortOrder = image.SortOrder == 0 ? (int?)null : image.SortOrder
|
||||
});
|
||||
}
|
||||
|
||||
public async Task DeleteCharacterImageAsync(int characterImageId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE dbo.CharacterImages
|
||||
SET IsDeleted = 1,
|
||||
DeletedDateUTC = SYSUTCDATETIME()
|
||||
WHERE CharacterImageID = @CharacterImageID;
|
||||
""",
|
||||
new { CharacterImageID = characterImageId });
|
||||
}
|
||||
|
||||
public async Task ArchiveCharacterAsync(int characterId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -228,7 +228,12 @@ public interface ICharacterService
|
||||
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
|
||||
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
|
||||
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
|
||||
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
|
||||
Task<int> SaveCharacterAsync(CharacterEditViewModel model);
|
||||
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
|
||||
Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model);
|
||||
Task RemoveCharacterAvatarAsync(int characterId);
|
||||
Task DeleteCharacterImageAsync(int characterImageId);
|
||||
Task ArchiveCharacterAsync(int characterId);
|
||||
Task ArchiveRelationshipAsync(int characterRelationshipId);
|
||||
Task<int> SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model);
|
||||
@ -8813,6 +8818,8 @@ public sealed class CharacterService(
|
||||
DefaultDescription = character.DefaultDescription,
|
||||
ImagePath = character.ImagePath,
|
||||
ThumbnailPath = character.ThumbnailPath,
|
||||
AvatarImagePath = character.AvatarImagePath,
|
||||
AvatarThumbnailPath = character.AvatarThumbnailPath,
|
||||
Project = await projects.GetAsync(character.ProjectID)
|
||||
};
|
||||
}
|
||||
@ -8846,6 +8853,7 @@ public sealed class CharacterService(
|
||||
{
|
||||
Project = project,
|
||||
Character = character,
|
||||
Images = await characters.ListCharacterImagesAsync(character.CharacterID),
|
||||
DisplayAge = DisplayAge(character),
|
||||
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
|
||||
AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID),
|
||||
@ -8886,6 +8894,30 @@ public sealed class CharacterService(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId)
|
||||
{
|
||||
var image = await characters.GetCharacterImageAsync(characterImageId);
|
||||
if (image is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var character = await characters.GetCharacterAsync(image.CharacterID);
|
||||
if (character is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CharacterAvatarCropViewModel
|
||||
{
|
||||
CharacterID = character.CharacterID,
|
||||
CharacterImageID = image.CharacterImageID,
|
||||
CharacterName = character.CharacterName,
|
||||
ImagePath = image.ImagePath,
|
||||
Caption = image.Caption
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> SaveCharacterAsync(CharacterEditViewModel model)
|
||||
{
|
||||
if (model.CharacterID == 0)
|
||||
@ -8931,6 +8963,67 @@ public sealed class CharacterService(
|
||||
return characterId;
|
||||
}
|
||||
|
||||
public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model)
|
||||
{
|
||||
var character = await characters.GetCharacterAsync(model.CharacterID);
|
||||
if (character is null)
|
||||
{
|
||||
return new(false, "Character not found.");
|
||||
}
|
||||
|
||||
return model.ImageUpload is null
|
||||
? new(false, "Choose an image to upload.")
|
||||
: await visualIdentityImages.UploadCharacterGalleryImageAsync(character, model.ImageUpload, model.Caption);
|
||||
}
|
||||
|
||||
public async Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model)
|
||||
{
|
||||
var character = await characters.GetCharacterAsync(model.CharacterID);
|
||||
var image = await characters.GetCharacterImageAsync(model.CharacterImageID);
|
||||
if (character is null || image is null || image.CharacterID != character.CharacterID)
|
||||
{
|
||||
return new(false, "Character image not found.");
|
||||
}
|
||||
|
||||
return await visualIdentityImages.CreateCharacterAvatarAsync(character, image, model.CropX, model.CropY, model.CropSize);
|
||||
}
|
||||
|
||||
public async Task RemoveCharacterAvatarAsync(int characterId)
|
||||
{
|
||||
var character = await characters.GetCharacterAsync(characterId);
|
||||
if (character is not null)
|
||||
{
|
||||
await visualIdentityImages.RemoveCharacterAvatarAsync(character);
|
||||
await activity.RecordAsync(character.ProjectID, "Updated", "Character", characterId, character.CharacterName);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteCharacterImageAsync(int characterImageId)
|
||||
{
|
||||
var image = await characters.GetCharacterImageAsync(characterImageId);
|
||||
if (image is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var character = await characters.GetCharacterAsync(image.CharacterID);
|
||||
if (character is not null && string.Equals(character.ImagePath, image.ImagePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await characters.UpdateCharacterImageAsync(character.CharacterID, null, null);
|
||||
}
|
||||
|
||||
await visualIdentityImages.DeleteCharacterGalleryImageAsync(image);
|
||||
if (character?.AvatarSourceCharacterImageID == image.CharacterImageID)
|
||||
{
|
||||
await visualIdentityImages.RemoveCharacterAvatarAsync(character);
|
||||
}
|
||||
|
||||
if (character is not null)
|
||||
{
|
||||
await activity.RecordAsync(character.ProjectID, "Updated", "Character", character.CharacterID, character.CharacterName);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ArchiveCharacterAsync(int characterId)
|
||||
{
|
||||
var character = await characters.GetCharacterAsync(characterId);
|
||||
|
||||
@ -8,12 +8,16 @@ namespace PlotLine.Services;
|
||||
public interface IVisualIdentityImageService
|
||||
{
|
||||
Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload);
|
||||
Task<VisualIdentityImageResult> UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption);
|
||||
Task<VisualIdentityImageResult> CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize);
|
||||
Task RemoveCharacterImageAsync(Character character);
|
||||
Task RemoveCharacterAvatarAsync(Character character);
|
||||
Task DeleteCharacterGalleryImageAsync(CharacterImage image);
|
||||
Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload);
|
||||
Task RemoveAssetImageAsync(StoryAsset asset);
|
||||
}
|
||||
|
||||
public sealed record VisualIdentityImageResult(bool Succeeded, string? ErrorMessage = null);
|
||||
public sealed record VisualIdentityImageResult(bool Succeeded, string? ErrorMessage = null, int? CharacterImageID = null);
|
||||
|
||||
public sealed class VisualIdentityImageService(
|
||||
IUploadStorageService uploadStorage,
|
||||
@ -30,17 +34,11 @@ public sealed class VisualIdentityImageService(
|
||||
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> EntityLocks = new();
|
||||
|
||||
public Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload)
|
||||
=> UploadAsync(
|
||||
$"character:{character.CharacterID}",
|
||||
character.ProjectID,
|
||||
character.CharacterID,
|
||||
character.ImagePath,
|
||||
character.ThumbnailPath,
|
||||
"characters",
|
||||
"CharacterImage",
|
||||
(imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath),
|
||||
upload);
|
||||
public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload)
|
||||
=> await UploadCharacterGalleryImageCoreAsync(character, upload, null, updateLegacyImage: true);
|
||||
|
||||
public async Task<VisualIdentityImageResult> UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption)
|
||||
=> await UploadCharacterGalleryImageCoreAsync(character, upload, caption, updateLegacyImage: false);
|
||||
|
||||
public Task RemoveCharacterImageAsync(Character character)
|
||||
=> RemoveAsync(
|
||||
@ -49,6 +47,81 @@ public sealed class VisualIdentityImageService(
|
||||
"uploads/characters",
|
||||
(imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath));
|
||||
|
||||
public async Task RemoveCharacterAvatarAsync(Character character)
|
||||
{
|
||||
await characters.UpdateCharacterAvatarAsync(character.CharacterID, null, null, null);
|
||||
await MarkPreviousLedgerRowsAsync(character.AvatarImagePath);
|
||||
await MarkPreviousLedgerRowsAsync(character.AvatarThumbnailPath);
|
||||
DeletePreviousPhysicalFile(character.AvatarImagePath, null, "uploads/characters");
|
||||
DeletePreviousPhysicalFile(character.AvatarThumbnailPath, null, "uploads/characters");
|
||||
}
|
||||
|
||||
public async Task DeleteCharacterGalleryImageAsync(CharacterImage image)
|
||||
{
|
||||
await characters.DeleteCharacterImageAsync(image.CharacterImageID);
|
||||
await MarkPreviousLedgerRowsAsync(image.ImagePath);
|
||||
await MarkPreviousLedgerRowsAsync(image.ThumbnailPath);
|
||||
DeletePreviousPhysicalFile(image.ImagePath, null, "uploads/characters");
|
||||
DeletePreviousPhysicalFile(image.ThumbnailPath, null, "uploads/characters");
|
||||
}
|
||||
|
||||
public async Task<VisualIdentityImageResult> CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize)
|
||||
{
|
||||
if (sourceImage.CharacterID != character.CharacterID)
|
||||
{
|
||||
return new(false, "Choose an image from this character's gallery.");
|
||||
}
|
||||
|
||||
var sourcePath = uploadStorage.TryResolvePublicPath(sourceImage.ImagePath, "uploads/characters");
|
||||
if (sourcePath is null || !File.Exists(sourcePath))
|
||||
{
|
||||
return new(false, "The source image could not be found.");
|
||||
}
|
||||
|
||||
var ownerUserId = await collaboration.GetOwnerUserIdAsync(character.ProjectID);
|
||||
if (!ownerUserId.HasValue)
|
||||
{
|
||||
return new(false, "Project owner could not be found.");
|
||||
}
|
||||
|
||||
var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, 256 * 256);
|
||||
if (!storageLimit.Allowed)
|
||||
{
|
||||
return new(false, storageLimit.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var bitmap = SKBitmap.Decode(sourcePath) ?? throw new InvalidDataException();
|
||||
var size = cropSize <= 0 ? Math.Min(bitmap.Width, bitmap.Height) : cropSize;
|
||||
size = Math.Min(size, Math.Min(bitmap.Width, bitmap.Height));
|
||||
cropX = Math.Clamp(cropX, 0, Math.Max(0, bitmap.Width - size));
|
||||
cropY = Math.Clamp(cropY, 0, Math.Max(0, bitmap.Height - size));
|
||||
|
||||
var uploadRoot = uploadStorage.GetDirectory("characters", character.CharacterID.ToString());
|
||||
var version = Guid.NewGuid().ToString("N");
|
||||
var avatarTemp = Path.Combine(uploadRoot, $"avatar-{version}.tmp");
|
||||
var avatarPath = Path.Combine(uploadRoot, "avatar.webp");
|
||||
var avatarStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "avatar.webp");
|
||||
|
||||
SaveWebp(bitmap, avatarTemp, new SKRect(cropX, cropY, cropX + size, cropY + size), 256, 256, 82);
|
||||
ReplaceFile(avatarTemp, avatarPath);
|
||||
|
||||
await characters.UpdateCharacterAvatarAsync(character.CharacterID, sourceImage.CharacterImageID, avatarStoragePath, avatarStoragePath);
|
||||
await MarkPreviousLedgerRowsAsync(character.AvatarImagePath);
|
||||
await MarkPreviousLedgerRowsAsync(character.AvatarThumbnailPath);
|
||||
await TrackImageAsync(ownerUserId.Value, character.ProjectID, character.CharacterID, "CharacterAvatar", avatarStoragePath, "Character avatar", new FileInfo(avatarPath).Length);
|
||||
DeletePreviousPhysicalFile(character.AvatarImagePath, avatarStoragePath, "uploads/characters");
|
||||
DeletePreviousPhysicalFile(character.AvatarThumbnailPath, avatarStoragePath, "uploads/characters");
|
||||
return new(true);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or ArgumentException)
|
||||
{
|
||||
logger.LogWarning(ex, "Character avatar crop failed for Character {CharacterID}.", character.CharacterID);
|
||||
return new(false, "Avatar could not be generated. Try another crop or source image.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload)
|
||||
=> UploadAsync(
|
||||
$"asset:{asset.StoryAssetID}",
|
||||
@ -193,6 +266,118 @@ public sealed class VisualIdentityImageService(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<VisualIdentityImageResult> UploadCharacterGalleryImageCoreAsync(Character character, IFormFile upload, string? caption, bool updateLegacyImage)
|
||||
{
|
||||
var uploadLock = EntityLocks.GetOrAdd($"character-gallery:{character.CharacterID}", _ => new SemaphoreSlim(1, 1));
|
||||
await uploadLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (upload.Length <= 0)
|
||||
{
|
||||
return new(false, "Choose an image to upload.");
|
||||
}
|
||||
|
||||
if (upload.Length > MaxUploadBytes)
|
||||
{
|
||||
return new(false, "Image must be 10 MB or smaller.");
|
||||
}
|
||||
|
||||
if (!SupportedExtensions.Contains(Path.GetExtension(upload.FileName)))
|
||||
{
|
||||
return new(false, "Image must be a JPG, PNG or WebP file.");
|
||||
}
|
||||
|
||||
var ownerUserId = await collaboration.GetOwnerUserIdAsync(character.ProjectID);
|
||||
if (!ownerUserId.HasValue)
|
||||
{
|
||||
return new(false, "Project owner could not be found.");
|
||||
}
|
||||
|
||||
var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, EstimatedGeneratedSize(upload.Length));
|
||||
if (!storageLimit.Allowed)
|
||||
{
|
||||
return new(false, storageLimit.Message);
|
||||
}
|
||||
|
||||
SKBitmap bitmap;
|
||||
try
|
||||
{
|
||||
await using var uploadStream = upload.OpenReadStream();
|
||||
using var memory = new MemoryStream();
|
||||
await uploadStream.CopyToAsync(memory);
|
||||
memory.Position = 0;
|
||||
|
||||
using var codec = SKCodec.Create(memory) ?? throw new InvalidDataException();
|
||||
if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp))
|
||||
{
|
||||
return new(false, "Image must be a valid JPG, PNG or WebP file.");
|
||||
}
|
||||
|
||||
memory.Position = 0;
|
||||
bitmap = SKBitmap.Decode(memory) ?? throw new InvalidDataException();
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or ArgumentException)
|
||||
{
|
||||
return new(false, "Image must be a valid JPG, PNG or WebP file.");
|
||||
}
|
||||
|
||||
using (bitmap)
|
||||
{
|
||||
if (bitmap.Width <= 0 || bitmap.Height <= 0)
|
||||
{
|
||||
return new(false, "Image could not be read as a valid image.");
|
||||
}
|
||||
|
||||
var version = Guid.NewGuid().ToString("N");
|
||||
var uploadRoot = uploadStorage.GetDirectory("characters", character.CharacterID.ToString(), "gallery", version);
|
||||
var imageTemp = Path.Combine(uploadRoot, "image.tmp");
|
||||
var thumbnailTemp = Path.Combine(uploadRoot, "thumb.tmp");
|
||||
var imagePath = Path.Combine(uploadRoot, "image.webp");
|
||||
var thumbnailPath = Path.Combine(uploadRoot, "thumb.webp");
|
||||
var imageStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "gallery", version, "image.webp");
|
||||
var thumbnailStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "gallery", version, "thumb.webp");
|
||||
|
||||
try
|
||||
{
|
||||
SaveContainedWebp(bitmap, imageTemp, StandardMaxSize, 82);
|
||||
SaveCroppedWebp(bitmap, thumbnailTemp, ThumbnailSize, ThumbnailSize, 76);
|
||||
|
||||
ReplaceFile(imageTemp, imagePath);
|
||||
ReplaceFile(thumbnailTemp, thumbnailPath);
|
||||
|
||||
var characterImageId = await characters.CreateCharacterImageAsync(new CharacterImage
|
||||
{
|
||||
CharacterID = character.CharacterID,
|
||||
ProjectID = character.ProjectID,
|
||||
ImagePath = imageStoragePath,
|
||||
ThumbnailPath = thumbnailStoragePath,
|
||||
Caption = caption
|
||||
});
|
||||
|
||||
if (updateLegacyImage || string.IsNullOrWhiteSpace(character.ImagePath))
|
||||
{
|
||||
await characters.UpdateCharacterImageAsync(character.CharacterID, imageStoragePath, thumbnailStoragePath);
|
||||
}
|
||||
|
||||
await TrackImageAsync(ownerUserId.Value, character.ProjectID, characterImageId, "CharacterImage", imageStoragePath, upload.FileName, new FileInfo(imagePath).Length);
|
||||
await TrackImageAsync(ownerUserId.Value, character.ProjectID, characterImageId, "CharacterImage", thumbnailStoragePath, "Thumbnail: " + upload.FileName, new FileInfo(thumbnailPath).Length);
|
||||
return new(true, CharacterImageID: characterImageId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TryDeleteFile(imageTemp);
|
||||
TryDeleteFile(thumbnailTemp);
|
||||
logger.LogWarning(ex, "Character gallery image upload failed for Character {CharacterID}.", character.CharacterID);
|
||||
return new(false, "Image could not be processed. Try another image.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
uploadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveAsync(string? imagePath, string? thumbnailPath, string requiredPrefix, Func<string?, string?, Task> updateImage)
|
||||
{
|
||||
await updateImage(null, null);
|
||||
|
||||
183
PlotLine/Sql/086_Phase14A_CharacterGalleriesAvatars.sql
Normal file
183
PlotLine/Sql/086_Phase14A_CharacterGalleriesAvatars.sql
Normal file
@ -0,0 +1,183 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Characters', N'AvatarSourceCharacterImageID') IS NULL
|
||||
ALTER TABLE dbo.Characters ADD AvatarSourceCharacterImageID int NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Characters', N'AvatarImagePath') IS NULL
|
||||
ALTER TABLE dbo.Characters ADD AvatarImagePath nvarchar(500) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Characters', N'AvatarThumbnailPath') IS NULL
|
||||
ALTER TABLE dbo.Characters ADD AvatarThumbnailPath nvarchar(500) NULL;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.CharacterImages', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.CharacterImages
|
||||
(
|
||||
CharacterImageID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterImages PRIMARY KEY,
|
||||
CharacterID int NOT NULL,
|
||||
ProjectID int NOT NULL,
|
||||
ImagePath nvarchar(500) NOT NULL,
|
||||
ThumbnailPath nvarchar(500) NOT NULL,
|
||||
Caption nvarchar(500) NULL,
|
||||
SortOrder int NOT NULL CONSTRAINT DF_CharacterImages_SortOrder DEFAULT (0),
|
||||
UploadedDateUTC datetime2 NOT NULL CONSTRAINT DF_CharacterImages_UploadedDateUTC DEFAULT (SYSUTCDATETIME()),
|
||||
IsDeleted bit NOT NULL CONSTRAINT DF_CharacterImages_IsDeleted DEFAULT (0),
|
||||
DeletedDateUTC datetime2 NULL,
|
||||
CONSTRAINT FK_CharacterImages_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID),
|
||||
CONSTRAINT FK_CharacterImages_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterImages_CharacterID_IsDeleted_SortOrder' AND object_id = OBJECT_ID(N'dbo.CharacterImages'))
|
||||
CREATE INDEX IX_CharacterImages_CharacterID_IsDeleted_SortOrder ON dbo.CharacterImages(CharacterID, IsDeleted, SortOrder, UploadedDateUTC, CharacterImageID);
|
||||
GO
|
||||
|
||||
INSERT dbo.CharacterImages (CharacterID, ProjectID, ImagePath, ThumbnailPath, Caption, SortOrder, UploadedDateUTC)
|
||||
SELECT c.CharacterID, c.ProjectID, c.ImagePath, COALESCE(c.ThumbnailPath, c.ImagePath), N'Original character image', 0, COALESCE(c.UpdatedDate, SYSUTCDATETIME())
|
||||
FROM dbo.Characters c
|
||||
WHERE NULLIF(LTRIM(RTRIM(c.ImagePath)), N'') IS NOT NULL
|
||||
AND NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.CharacterImages ci
|
||||
WHERE ci.CharacterID = c.CharacterID
|
||||
AND ci.ImagePath = c.ImagePath
|
||||
AND ci.IsDeleted = 0
|
||||
);
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_ListByProject
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
|
||||
Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath,
|
||||
AvatarSourceCharacterImageID, AvatarImagePath, AvatarThumbnailPath,
|
||||
CreatedDate, UpdatedDate, IsArchived, ArchivedDate, ArchivedReason
|
||||
FROM dbo.Characters
|
||||
WHERE ProjectID = @ProjectID AND IsArchived = 0
|
||||
ORDER BY CharacterName;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_Get
|
||||
@CharacterID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
|
||||
Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath,
|
||||
AvatarSourceCharacterImageID, AvatarImagePath, AvatarThumbnailPath,
|
||||
CreatedDate, UpdatedDate, IsArchived, ArchivedDate, ArchivedReason
|
||||
FROM dbo.Characters
|
||||
WHERE CharacterID = @CharacterID AND IsArchived = 0;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_Save
|
||||
@CharacterID int = NULL,
|
||||
@ProjectID int,
|
||||
@CharacterName nvarchar(200),
|
||||
@ShortName nvarchar(100) = NULL,
|
||||
@Sex nvarchar(50) = NULL,
|
||||
@BirthDate date = NULL,
|
||||
@AgeAtSeriesStart int = NULL,
|
||||
@AgeReferenceSceneID int = NULL,
|
||||
@Height nvarchar(50) = NULL,
|
||||
@EyeColour nvarchar(50) = NULL,
|
||||
@CharacterImportance int = NULL,
|
||||
@ShowInQuickAddBar bit = 0,
|
||||
@DefaultDescription nvarchar(max) = NULL,
|
||||
@ImagePath nvarchar(500) = NULL,
|
||||
@ThumbnailPath nvarchar(500) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF @CharacterID IS NULL OR @CharacterID = 0
|
||||
BEGIN
|
||||
INSERT dbo.Characters (ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath)
|
||||
VALUES (@ProjectID, @CharacterName, @ShortName, @Sex, @BirthDate, @AgeAtSeriesStart, @AgeReferenceSceneID, @Height, @EyeColour, @CharacterImportance, @ShowInQuickAddBar, @DefaultDescription, @ImagePath, @ThumbnailPath);
|
||||
SET @CharacterID = CAST(SCOPE_IDENTITY() AS int);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
UPDATE dbo.Characters
|
||||
SET CharacterName = @CharacterName, ShortName = @ShortName, Sex = @Sex, BirthDate = @BirthDate,
|
||||
AgeAtSeriesStart = @AgeAtSeriesStart, AgeReferenceSceneID = @AgeReferenceSceneID,
|
||||
Height = @Height, EyeColour = @EyeColour, CharacterImportance = @CharacterImportance,
|
||||
ShowInQuickAddBar = @ShowInQuickAddBar, DefaultDescription = @DefaultDescription,
|
||||
ImagePath = @ImagePath, ThumbnailPath = @ThumbnailPath,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE CharacterID = @CharacterID;
|
||||
END
|
||||
SELECT @CharacterID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_UpdateImage
|
||||
@CharacterID int,
|
||||
@ImagePath nvarchar(500) = NULL,
|
||||
@ThumbnailPath nvarchar(500) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
UPDATE dbo.Characters
|
||||
SET ImagePath = @ImagePath,
|
||||
ThumbnailPath = @ThumbnailPath,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE CharacterID = @CharacterID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_UpdateAvatar
|
||||
@CharacterID int,
|
||||
@AvatarSourceCharacterImageID int = NULL,
|
||||
@AvatarImagePath nvarchar(500) = NULL,
|
||||
@AvatarThumbnailPath nvarchar(500) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
UPDATE dbo.Characters
|
||||
SET AvatarSourceCharacterImageID = @AvatarSourceCharacterImageID,
|
||||
AvatarImagePath = @AvatarImagePath,
|
||||
AvatarThumbnailPath = @AvatarThumbnailPath,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE CharacterID = @CharacterID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SceneCharacter_ListByScene
|
||||
@SceneID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT sc.SceneCharacterID, sc.SceneID, sc.CharacterID, c.ProjectID,
|
||||
CASE WHEN c.IsArchived = 1 THEN CONCAT(c.CharacterName, N' (archived)') ELSE c.CharacterName END AS CharacterName,
|
||||
c.ShortName,
|
||||
COALESCE(c.AvatarImagePath, c.ImagePath) AS CharacterImagePath,
|
||||
COALESCE(c.AvatarThumbnailPath, c.ThumbnailPath) AS CharacterThumbnailPath,
|
||||
c.BirthDate, c.AgeAtSeriesStart,
|
||||
sc.RoleInSceneTypeID, role.TypeName AS RoleInSceneTypeName, sc.PresenceTypeID, presence.TypeName AS PresenceTypeName,
|
||||
sc.LocationID, location.LocationName, location.LocationPath, sc.EntryLocationID, entryLocation.LocationName AS EntryLocationName,
|
||||
sc.ExitLocationID, exitLocation.LocationName AS ExitLocationName,
|
||||
sc.AppearanceNotes, sc.OutfitDescription, sc.PhysicalCondition, sc.EmotionalState, sc.KnowledgeNotes,
|
||||
sc.CreatedDate, sc.UpdatedDate
|
||||
FROM dbo.SceneCharacters sc
|
||||
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID
|
||||
LEFT JOIN dbo.CharacterRoleInSceneTypes role ON role.CharacterRoleInSceneTypeID = sc.RoleInSceneTypeID
|
||||
LEFT JOIN dbo.PresenceTypes presence ON presence.PresenceTypeID = sc.PresenceTypeID
|
||||
LEFT JOIN dbo.LocationPaths location ON location.LocationID = sc.LocationID
|
||||
LEFT JOIN dbo.LocationPaths entryLocation ON entryLocation.LocationID = sc.EntryLocationID
|
||||
LEFT JOIN dbo.LocationPaths exitLocation ON exitLocation.LocationID = sc.ExitLocationID
|
||||
WHERE sc.SceneID = @SceneID
|
||||
ORDER BY role.SortOrder, c.CharacterName;
|
||||
END;
|
||||
GO
|
||||
@ -1630,6 +1630,8 @@ public sealed class CharacterEditViewModel
|
||||
|
||||
public string? ImagePath { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
public string? AvatarImagePath { get; set; }
|
||||
public string? AvatarThumbnailPath { get; set; }
|
||||
|
||||
[Display(Name = "Upload image")]
|
||||
public IFormFile? ImageUpload { get; set; }
|
||||
@ -1652,6 +1654,7 @@ public sealed class CharacterDetailViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
public Character Character { get; set; } = new();
|
||||
public IReadOnlyList<CharacterImage> Images { get; set; } = [];
|
||||
public string DisplayAge { get; set; } = "Unknown";
|
||||
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];
|
||||
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
|
||||
@ -1666,6 +1669,29 @@ public sealed class CharacterDetailViewModel
|
||||
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CharacterImageUploadViewModel
|
||||
{
|
||||
public int CharacterID { get; set; }
|
||||
|
||||
[Display(Name = "Image")]
|
||||
public IFormFile? ImageUpload { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Caption { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CharacterAvatarCropViewModel
|
||||
{
|
||||
public int CharacterID { get; set; }
|
||||
public int CharacterImageID { get; set; }
|
||||
public string CharacterName { get; set; } = string.Empty;
|
||||
public string ImagePath { get; set; } = string.Empty;
|
||||
public string? Caption { get; set; }
|
||||
public int CropX { get; set; }
|
||||
public int CropY { get; set; }
|
||||
public int CropSize { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CurrentRelationshipViewModel
|
||||
{
|
||||
public CharacterRelationship Relationship { get; set; } = new();
|
||||
|
||||
152
PlotLine/Views/Characters/CropAvatar.cshtml
Normal file
152
PlotLine/Views/Characters/CropAvatar.cshtml
Normal file
@ -0,0 +1,152 @@
|
||||
@model CharacterAvatarCropViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Create Avatar";
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
<a asp-action="Details" asp-route-id="@Model.CharacterID">@Model.CharacterName</a>
|
||||
<span>Create Avatar</span>
|
||||
</nav>
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Character Avatar</p>
|
||||
<h1>Create Avatar</h1>
|
||||
<p class="lead-text">Choose a square crop. The original gallery image will be preserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["ImageError"] is string imageError)
|
||||
{
|
||||
<div class="alert alert-warning">@imageError</div>
|
||||
}
|
||||
|
||||
<form asp-action="SaveAvatar" method="post" class="edit-panel character-avatar-crop-form" data-avatar-crop-form>
|
||||
<input asp-for="CharacterID" type="hidden" />
|
||||
<input asp-for="CharacterImageID" type="hidden" />
|
||||
<input asp-for="CropX" type="hidden" data-crop-x />
|
||||
<input asp-for="CropY" type="hidden" data-crop-y />
|
||||
<input asp-for="CropSize" type="hidden" data-crop-size />
|
||||
|
||||
<div class="character-avatar-crop-layout">
|
||||
<div class="character-avatar-crop-stage" data-crop-stage>
|
||||
<img src="@Model.ImagePath" alt="" data-crop-image />
|
||||
<div class="character-avatar-crop-box" data-crop-box>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="character-avatar-crop-side">
|
||||
<p class="eyebrow">Preview</p>
|
||||
<div class="character-avatar-preview" data-avatar-preview>
|
||||
<img src="@Model.ImagePath" alt="" data-preview-image />
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Caption))
|
||||
{
|
||||
<p class="muted">@Model.Caption</p>
|
||||
}
|
||||
<label class="form-label" for="cropZoom">Crop size</label>
|
||||
<input class="form-range" id="cropZoom" type="range" min="20" max="100" value="70" data-crop-size-slider />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Save avatar</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="Details" asp-route-id="@Model.CharacterID">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.querySelector("[data-avatar-crop-form]");
|
||||
if (!form) return;
|
||||
|
||||
const stage = form.querySelector("[data-crop-stage]");
|
||||
const image = form.querySelector("[data-crop-image]");
|
||||
const box = form.querySelector("[data-crop-box]");
|
||||
const slider = form.querySelector("[data-crop-size-slider]");
|
||||
const preview = form.querySelector("[data-avatar-preview]");
|
||||
const previewImage = form.querySelector("[data-preview-image]");
|
||||
const cropX = form.querySelector("[data-crop-x]");
|
||||
const cropY = form.querySelector("[data-crop-y]");
|
||||
const cropSize = form.querySelector("[data-crop-size]");
|
||||
let crop = { x: 0, y: 0, size: 0 };
|
||||
let dragging = false;
|
||||
let dragOffset = { x: 0, y: 0 };
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
||||
|
||||
const imageRect = () => {
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const imgRect = image.getBoundingClientRect();
|
||||
return {
|
||||
x: imgRect.left - stageRect.left,
|
||||
y: imgRect.top - stageRect.top,
|
||||
width: imgRect.width,
|
||||
height: imgRect.height
|
||||
};
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
const rect = imageRect();
|
||||
const minSide = Math.min(rect.width, rect.height);
|
||||
const percent = Number(slider.value) / 100;
|
||||
crop.size = Math.max(24, minSide * percent);
|
||||
crop.x = clamp(crop.x, rect.x, rect.x + rect.width - crop.size);
|
||||
crop.y = clamp(crop.y, rect.y, rect.y + rect.height - crop.size);
|
||||
|
||||
box.style.width = `${crop.size}px`;
|
||||
box.style.height = `${crop.size}px`;
|
||||
box.style.left = `${crop.x}px`;
|
||||
box.style.top = `${crop.y}px`;
|
||||
|
||||
const naturalScaleX = image.naturalWidth / rect.width;
|
||||
const naturalScaleY = image.naturalHeight / rect.height;
|
||||
cropX.value = Math.round((crop.x - rect.x) * naturalScaleX);
|
||||
cropY.value = Math.round((crop.y - rect.y) * naturalScaleY);
|
||||
cropSize.value = Math.round(crop.size * Math.min(naturalScaleX, naturalScaleY));
|
||||
|
||||
const previewSize = preview.clientWidth || 180;
|
||||
const scale = previewSize / crop.size;
|
||||
previewImage.style.width = `${rect.width * scale}px`;
|
||||
previewImage.style.height = `${rect.height * scale}px`;
|
||||
previewImage.style.transform = `translate(${-(crop.x - rect.x) * scale}px, ${-(crop.y - rect.y) * scale}px)`;
|
||||
};
|
||||
|
||||
const resetCrop = () => {
|
||||
const rect = imageRect();
|
||||
const minSide = Math.min(rect.width, rect.height);
|
||||
crop.size = minSide * (Number(slider.value) / 100);
|
||||
crop.x = rect.x + (rect.width - crop.size) / 2;
|
||||
crop.y = rect.y + (rect.height - crop.size) / 2;
|
||||
sync();
|
||||
};
|
||||
|
||||
image.addEventListener("load", resetCrop);
|
||||
if (image.complete) window.requestAnimationFrame(resetCrop);
|
||||
window.addEventListener("resize", resetCrop);
|
||||
slider.addEventListener("input", sync);
|
||||
|
||||
box.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
box.setPointerCapture(event.pointerId);
|
||||
dragOffset = { x: event.offsetX, y: event.offsetY };
|
||||
});
|
||||
|
||||
box.addEventListener("pointermove", (event) => {
|
||||
if (!dragging) return;
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const rect = imageRect();
|
||||
crop.x = clamp(event.clientX - stageRect.left - dragOffset.x, rect.x, rect.x + rect.width - crop.size);
|
||||
crop.y = clamp(event.clientY - stageRect.top - dragOffset.y, rect.y, rect.y + rect.height - crop.size);
|
||||
sync();
|
||||
});
|
||||
|
||||
box.addEventListener("pointerup", () => {
|
||||
dragging = false;
|
||||
});
|
||||
|
||||
form.addEventListener("submit", sync);
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
@ -22,7 +22,7 @@
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading pw-page-heading">
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.Character.CharacterName, ImagePath = Model.Character.ImagePath, ThumbnailPath = Model.Character.ThumbnailPath, Size = "xl", Lazy = false })" />
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.Character.CharacterName, ImagePath = Model.Character.AvatarImagePath ?? Model.Character.ImagePath, ThumbnailPath = Model.Character.AvatarThumbnailPath ?? Model.Character.ThumbnailPath, Size = "xl", Lazy = false })" />
|
||||
<div>
|
||||
<p class="eyebrow">Character</p>
|
||||
<h1>@Model.Character.CharacterName <help-icon key="characters.details" /></h1>
|
||||
@ -55,9 +55,18 @@
|
||||
<a class="pw-character-nav__link" href="#character-relationships">Relationships</a>
|
||||
<a class="pw-character-nav__link" asp-controller="Dynamics" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Arc</a>
|
||||
<a class="pw-character-nav__link" asp-controller="Dynamics" asp-action="Knowledge" asp-route-id="@Model.Character.CharacterID">Knowledge</a>
|
||||
<a class="pw-character-nav__link" href="#character-gallery">Gallery</a>
|
||||
<a class="pw-character-nav__link" href="#character-appearances">Appearances</a>
|
||||
</nav>
|
||||
|
||||
@if (TempData["ImageMessage"] is string imageMessage)
|
||||
{
|
||||
<div class="alert alert-success">@imageMessage</div>
|
||||
}
|
||||
@if (TempData["ImageError"] is string imageError)
|
||||
{
|
||||
<div class="alert alert-warning">@imageError</div>
|
||||
}
|
||||
@if (TempData["RelationshipMessage"] is string relationshipMessage)
|
||||
{
|
||||
<div class="alert alert-info">@relationshipMessage</div>
|
||||
@ -248,6 +257,69 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="character-gallery" class="list-section mt-3">
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<h2>Character Images <help-icon key="characters.images" /></h2>
|
||||
<p class="muted mb-0">Keep portraits, full-body references, clothing notes, expressions, and inspiration images together.</p>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Character.AvatarImagePath))
|
||||
{
|
||||
<form asp-action="RemoveAvatar" method="post" class="archive-button-form" data-confirm-message="Remove this character avatar? The gallery images will be kept.">
|
||||
<input type="hidden" name="id" value="@Model.Character.CharacterID" />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Remove avatar</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<form asp-action="UploadImage" method="post" enctype="multipart/form-data" class="character-gallery-upload mb-3">
|
||||
<input type="hidden" name="CharacterID" value="@Model.Character.CharacterID" />
|
||||
<div>
|
||||
<label class="form-label" for="ImageUpload">Add image</label>
|
||||
<input class="form-control" id="ImageUpload" name="ImageUpload" type="file" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="Caption">Caption</label>
|
||||
<input class="form-control" id="Caption" name="Caption" maxlength="500" placeholder="Optional description" />
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Upload</button>
|
||||
</form>
|
||||
|
||||
@if (!Model.Images.Any())
|
||||
{
|
||||
<p class="muted">No gallery images yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="character-gallery-grid">
|
||||
@foreach (var image in Model.Images)
|
||||
{
|
||||
var isAvatarSource = Model.Character.AvatarSourceCharacterImageID == image.CharacterImageID;
|
||||
<article class="character-gallery-card">
|
||||
<img src="@image.ThumbnailPath" alt="" loading="lazy" />
|
||||
<div class="character-gallery-card__body">
|
||||
<strong>@(string.IsNullOrWhiteSpace(image.Caption) ? "Character reference" : image.Caption)</strong>
|
||||
<span class="muted">Uploaded @image.UploadedDateUTC.ToLocalTime().ToString("yyyy-MM-dd")</span>
|
||||
@if (isAvatarSource)
|
||||
{
|
||||
<span class="status-pill">Avatar source</span>
|
||||
}
|
||||
</div>
|
||||
<div class="button-row character-gallery-card__actions">
|
||||
<a class="btn btn-outline-primary btn-sm" asp-action="CropAvatar" asp-route-imageId="@image.CharacterImageID">Create Avatar</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="@image.ImagePath" target="_blank" rel="noopener">Open</a>
|
||||
<form asp-action="DeleteImage" method="post" class="archive-button-form" data-confirm-message="Remove this image from the character gallery?">
|
||||
<input type="hidden" name="imageId" value="@image.CharacterImageID" />
|
||||
<input type="hidden" name="characterId" value="@Model.Character.CharacterID" />
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section id="character-relationships" class="list-section mt-3">
|
||||
<h2>Current Relationships <help-icon key="characters.currentRelationships" /></h2>
|
||||
@if (!Model.CurrentRelationships.Any())
|
||||
|
||||
@ -20,6 +20,8 @@
|
||||
<input asp-for="ProjectID" type="hidden" />
|
||||
<input asp-for="ImagePath" type="hidden" />
|
||||
<input asp-for="ThumbnailPath" type="hidden" />
|
||||
<input asp-for="AvatarImagePath" type="hidden" />
|
||||
<input asp-for="AvatarThumbnailPath" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
@ -72,12 +74,12 @@
|
||||
<div class="visual-image-editor">
|
||||
<div>
|
||||
<p class="eyebrow">Character Image</p>
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.CharacterName, ImagePath = Model.ImagePath, ThumbnailPath = Model.ThumbnailPath, Size = "lg", Lazy = false })" />
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.CharacterName, ImagePath = Model.AvatarImagePath ?? Model.ImagePath, ThumbnailPath = Model.AvatarThumbnailPath ?? Model.ThumbnailPath, Size = "lg", Lazy = false })" />
|
||||
</div>
|
||||
<div class="visual-image-editor__controls">
|
||||
<label asp-for="ImageUpload" class="form-label"></label>
|
||||
<input asp-for="ImageUpload" class="form-control" type="file" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" />
|
||||
<div class="form-text">JPG, PNG or WebP. A 128 x 128 thumbnail is generated automatically.</div>
|
||||
<div class="form-text">JPG, PNG or WebP. The image is added to the character gallery and a thumbnail is generated automatically.</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath))
|
||||
{
|
||||
<label class="form-check mt-2">
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
{
|
||||
<article class="asset-card character-card" draggable="true" data-drag-type="character" data-drag-id="@character.CharacterID" data-drag-label="@character.CharacterName">
|
||||
<div class="visual-card-heading">
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.ImagePath, ThumbnailPath = character.ThumbnailPath })" />
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.AvatarImagePath ?? character.ImagePath, ThumbnailPath = character.AvatarThumbnailPath ?? character.ThumbnailPath })" />
|
||||
<div>
|
||||
<p class="eyebrow">Character</p>
|
||||
<h2><a asp-action="Details" asp-route-id="@character.CharacterID">@character.CharacterName</a></h2>
|
||||
|
||||
@ -373,6 +373,7 @@
|
||||
var ageDisplay = CharacterAgeDisplayService.GetAgeForScene(character, Model.TimeModeName, Model.StartDateTime);
|
||||
<article class="character-appearance-card">
|
||||
<div class="character-appearance-summary">
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.CharacterImagePath, ThumbnailPath = character.CharacterThumbnailPath })" />
|
||||
<div>
|
||||
<strong>@character.CharacterName</strong>
|
||||
@if (!string.IsNullOrWhiteSpace(ageDisplay.DisplayText))
|
||||
|
||||
@ -631,6 +631,114 @@ a:hover {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.character-gallery-upload {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 1fr) minmax(14rem, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
border: 1px solid var(--plotline-border-soft);
|
||||
border-radius: var(--plotline-radius-sm);
|
||||
padding: 0.85rem;
|
||||
background: rgba(255, 250, 241, 0.56);
|
||||
}
|
||||
|
||||
.character-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.character-gallery-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--plotline-border-soft);
|
||||
border-radius: var(--plotline-radius-sm);
|
||||
background: rgba(255, 253, 248, 0.72);
|
||||
box-shadow: var(--plotline-shadow-subtle);
|
||||
}
|
||||
|
||||
.character-gallery-card > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--plotline-soft);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.character-gallery-card__body {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.character-gallery-card__actions {
|
||||
border-top: 1px solid rgba(84, 68, 48, 0.1);
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.character-avatar-crop-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(12rem, 16rem);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.character-avatar-crop-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 24rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--plotline-border-soft);
|
||||
border-radius: var(--plotline-radius-sm);
|
||||
background: var(--plotline-soft);
|
||||
}
|
||||
|
||||
.character-avatar-crop-stage img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.character-avatar-crop-box {
|
||||
position: absolute;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
border: 2px solid #fff8ec;
|
||||
box-shadow: 0 0 0 9999px rgba(20, 17, 15, 0.42), 0 0 0 1px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.character-avatar-crop-box span {
|
||||
position: absolute;
|
||||
right: 0.35rem;
|
||||
bottom: 0.35rem;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-right: 2px solid #fff8ec;
|
||||
border-bottom: 2px solid #fff8ec;
|
||||
}
|
||||
|
||||
.character-avatar-crop-side {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.character-avatar-preview {
|
||||
width: min(100%, 12rem);
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--plotline-border-soft);
|
||||
border-radius: 999px;
|
||||
background: var(--plotline-soft);
|
||||
box-shadow: var(--plotline-shadow-subtle);
|
||||
}
|
||||
|
||||
.character-avatar-preview img {
|
||||
display: block;
|
||||
max-width: none;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.pw-page-heading {
|
||||
align-items: stretch;
|
||||
@ -659,6 +767,11 @@ a:hover {
|
||||
.project-card__actions .dropdown {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.character-gallery-upload,
|
||||
.character-avatar-crop-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-workspace {
|
||||
@ -2129,6 +2242,25 @@ a:focus-visible {
|
||||
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .character-gallery-upload,
|
||||
[data-theme="dark"] .character-gallery-card,
|
||||
[data-theme="dark"] .character-avatar-crop-stage,
|
||||
[data-theme="dark"] .character-avatar-preview {
|
||||
color: var(--app-text);
|
||||
border-color: var(--app-border);
|
||||
background: rgba(42, 35, 30, 0.9);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .character-gallery-card__actions {
|
||||
border-top-color: rgba(231, 214, 190, 0.14);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .character-gallery-card > img,
|
||||
[data-theme="dark"] .character-avatar-crop-stage,
|
||||
[data-theme="dark"] .character-avatar-preview {
|
||||
background: #211b17;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-filter,
|
||||
[data-theme="dark"] .workspace-heading .button-row,
|
||||
[data-theme="dark"] .timeline-control-panel {
|
||||
|
||||
2
PlotLine/wwwroot/css/plotline-theme.min.css
vendored
2
PlotLine/wwwroot/css/plotline-theme.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user