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);
|
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]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Save(CharacterEditViewModel model)
|
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 });
|
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]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Archive(int id, int projectId)
|
public async Task<IActionResult> Archive(int id, int projectId)
|
||||||
|
|||||||
@ -183,6 +183,11 @@ public interface ICharacterRepository
|
|||||||
Task<Character?> GetCharacterAsync(int characterId);
|
Task<Character?> GetCharacterAsync(int characterId);
|
||||||
Task<int> SaveCharacterAsync(Character character);
|
Task<int> SaveCharacterAsync(Character character);
|
||||||
Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath);
|
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 ArchiveCharacterAsync(int characterId);
|
||||||
Task<IReadOnlyList<SceneCharacter>> ListSceneCharactersAsync(int sceneId);
|
Task<IReadOnlyList<SceneCharacter>> ListSceneCharactersAsync(int sceneId);
|
||||||
Task<int> SaveSceneCharacterAsync(SceneCharacter sceneCharacter);
|
Task<int> SaveSceneCharacterAsync(SceneCharacter sceneCharacter);
|
||||||
@ -3561,6 +3566,83 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
commandType: CommandType.StoredProcedure);
|
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)
|
public async Task ArchiveCharacterAsync(int characterId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -843,6 +843,9 @@ public sealed class Character
|
|||||||
public string? DefaultDescription { get; set; }
|
public string? DefaultDescription { get; set; }
|
||||||
public string? ImagePath { get; set; }
|
public string? ImagePath { get; set; }
|
||||||
public string? ThumbnailPath { 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 CreatedDate { get; set; }
|
||||||
public DateTime UpdatedDate { get; set; }
|
public DateTime UpdatedDate { get; set; }
|
||||||
public bool IsArchived { get; set; }
|
public bool IsArchived { get; set; }
|
||||||
@ -850,6 +853,19 @@ public sealed class Character
|
|||||||
public string? ArchivedReason { get; set; }
|
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 sealed class CharacterRoleInSceneType
|
||||||
{
|
{
|
||||||
public int CharacterRoleInSceneTypeID { get; set; }
|
public int CharacterRoleInSceneTypeID { get; set; }
|
||||||
@ -874,6 +890,8 @@ public sealed class SceneCharacter
|
|||||||
public int ProjectID { get; set; }
|
public int ProjectID { get; set; }
|
||||||
public string CharacterName { get; set; } = string.Empty;
|
public string CharacterName { get; set; } = string.Empty;
|
||||||
public string? ShortName { get; set; }
|
public string? ShortName { get; set; }
|
||||||
|
public string? CharacterImagePath { get; set; }
|
||||||
|
public string? CharacterThumbnailPath { get; set; }
|
||||||
public DateTime? BirthDate { get; set; }
|
public DateTime? BirthDate { get; set; }
|
||||||
public int? AgeAtSeriesStart { get; set; }
|
public int? AgeAtSeriesStart { get; set; }
|
||||||
public int? RoleInSceneTypeID { get; set; }
|
public int? RoleInSceneTypeID { get; set; }
|
||||||
|
|||||||
@ -228,7 +228,12 @@ public interface ICharacterService
|
|||||||
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
|
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
|
||||||
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
|
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
|
||||||
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
|
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
|
||||||
|
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
|
||||||
Task<int> SaveCharacterAsync(CharacterEditViewModel model);
|
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 ArchiveCharacterAsync(int characterId);
|
||||||
Task ArchiveRelationshipAsync(int characterRelationshipId);
|
Task ArchiveRelationshipAsync(int characterRelationshipId);
|
||||||
Task<int> SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model);
|
Task<int> SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model);
|
||||||
@ -8813,6 +8818,8 @@ public sealed class CharacterService(
|
|||||||
DefaultDescription = character.DefaultDescription,
|
DefaultDescription = character.DefaultDescription,
|
||||||
ImagePath = character.ImagePath,
|
ImagePath = character.ImagePath,
|
||||||
ThumbnailPath = character.ThumbnailPath,
|
ThumbnailPath = character.ThumbnailPath,
|
||||||
|
AvatarImagePath = character.AvatarImagePath,
|
||||||
|
AvatarThumbnailPath = character.AvatarThumbnailPath,
|
||||||
Project = await projects.GetAsync(character.ProjectID)
|
Project = await projects.GetAsync(character.ProjectID)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -8846,6 +8853,7 @@ public sealed class CharacterService(
|
|||||||
{
|
{
|
||||||
Project = project,
|
Project = project,
|
||||||
Character = character,
|
Character = character,
|
||||||
|
Images = await characters.ListCharacterImagesAsync(character.CharacterID),
|
||||||
DisplayAge = DisplayAge(character),
|
DisplayAge = DisplayAge(character),
|
||||||
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
|
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
|
||||||
AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(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)
|
public async Task<int> SaveCharacterAsync(CharacterEditViewModel model)
|
||||||
{
|
{
|
||||||
if (model.CharacterID == 0)
|
if (model.CharacterID == 0)
|
||||||
@ -8931,6 +8963,67 @@ public sealed class CharacterService(
|
|||||||
return characterId;
|
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)
|
public async Task ArchiveCharacterAsync(int characterId)
|
||||||
{
|
{
|
||||||
var character = await characters.GetCharacterAsync(characterId);
|
var character = await characters.GetCharacterAsync(characterId);
|
||||||
|
|||||||
@ -8,12 +8,16 @@ namespace PlotLine.Services;
|
|||||||
public interface IVisualIdentityImageService
|
public interface IVisualIdentityImageService
|
||||||
{
|
{
|
||||||
Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload);
|
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 RemoveCharacterImageAsync(Character character);
|
||||||
|
Task RemoveCharacterAvatarAsync(Character character);
|
||||||
|
Task DeleteCharacterGalleryImageAsync(CharacterImage image);
|
||||||
Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload);
|
Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload);
|
||||||
Task RemoveAssetImageAsync(StoryAsset asset);
|
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(
|
public sealed class VisualIdentityImageService(
|
||||||
IUploadStorageService uploadStorage,
|
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 HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
|
||||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> EntityLocks = new();
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> EntityLocks = new();
|
||||||
|
|
||||||
public Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload)
|
public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload)
|
||||||
=> UploadAsync(
|
=> await UploadCharacterGalleryImageCoreAsync(character, upload, null, updateLegacyImage: true);
|
||||||
$"character:{character.CharacterID}",
|
|
||||||
character.ProjectID,
|
public async Task<VisualIdentityImageResult> UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption)
|
||||||
character.CharacterID,
|
=> await UploadCharacterGalleryImageCoreAsync(character, upload, caption, updateLegacyImage: false);
|
||||||
character.ImagePath,
|
|
||||||
character.ThumbnailPath,
|
|
||||||
"characters",
|
|
||||||
"CharacterImage",
|
|
||||||
(imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath),
|
|
||||||
upload);
|
|
||||||
|
|
||||||
public Task RemoveCharacterImageAsync(Character character)
|
public Task RemoveCharacterImageAsync(Character character)
|
||||||
=> RemoveAsync(
|
=> RemoveAsync(
|
||||||
@ -49,6 +47,81 @@ public sealed class VisualIdentityImageService(
|
|||||||
"uploads/characters",
|
"uploads/characters",
|
||||||
(imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath));
|
(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)
|
public Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload)
|
||||||
=> UploadAsync(
|
=> UploadAsync(
|
||||||
$"asset:{asset.StoryAssetID}",
|
$"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)
|
private async Task RemoveAsync(string? imagePath, string? thumbnailPath, string requiredPrefix, Func<string?, string?, Task> updateImage)
|
||||||
{
|
{
|
||||||
await updateImage(null, null);
|
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? ImagePath { get; set; }
|
||||||
public string? ThumbnailPath { get; set; }
|
public string? ThumbnailPath { get; set; }
|
||||||
|
public string? AvatarImagePath { get; set; }
|
||||||
|
public string? AvatarThumbnailPath { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Upload image")]
|
[Display(Name = "Upload image")]
|
||||||
public IFormFile? ImageUpload { get; set; }
|
public IFormFile? ImageUpload { get; set; }
|
||||||
@ -1652,6 +1654,7 @@ public sealed class CharacterDetailViewModel
|
|||||||
{
|
{
|
||||||
public Project Project { get; set; } = new();
|
public Project Project { get; set; } = new();
|
||||||
public Character Character { get; set; } = new();
|
public Character Character { get; set; } = new();
|
||||||
|
public IReadOnlyList<CharacterImage> Images { get; set; } = [];
|
||||||
public string DisplayAge { get; set; } = "Unknown";
|
public string DisplayAge { get; set; } = "Unknown";
|
||||||
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];
|
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];
|
||||||
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
|
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
|
||||||
@ -1666,6 +1669,29 @@ public sealed class CharacterDetailViewModel
|
|||||||
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
|
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 sealed class CurrentRelationshipViewModel
|
||||||
{
|
{
|
||||||
public CharacterRelationship Relationship { get; set; } = new();
|
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" />
|
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||||
|
|
||||||
<div class="page-heading pw-page-heading">
|
<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>
|
<div>
|
||||||
<p class="eyebrow">Character</p>
|
<p class="eyebrow">Character</p>
|
||||||
<h1>@Model.Character.CharacterName <help-icon key="characters.details" /></h1>
|
<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" 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="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" 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>
|
<a class="pw-character-nav__link" href="#character-appearances">Appearances</a>
|
||||||
</nav>
|
</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)
|
@if (TempData["RelationshipMessage"] is string relationshipMessage)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">@relationshipMessage</div>
|
<div class="alert alert-info">@relationshipMessage</div>
|
||||||
@ -248,6 +257,69 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</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">
|
<section id="character-relationships" class="list-section mt-3">
|
||||||
<h2>Current Relationships <help-icon key="characters.currentRelationships" /></h2>
|
<h2>Current Relationships <help-icon key="characters.currentRelationships" /></h2>
|
||||||
@if (!Model.CurrentRelationships.Any())
|
@if (!Model.CurrentRelationships.Any())
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
<input asp-for="ProjectID" type="hidden" />
|
<input asp-for="ProjectID" type="hidden" />
|
||||||
<input asp-for="ImagePath" type="hidden" />
|
<input asp-for="ImagePath" type="hidden" />
|
||||||
<input asp-for="ThumbnailPath" 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 asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
@ -72,12 +74,12 @@
|
|||||||
<div class="visual-image-editor">
|
<div class="visual-image-editor">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Character Image</p>
|
<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>
|
||||||
<div class="visual-image-editor__controls">
|
<div class="visual-image-editor__controls">
|
||||||
<label asp-for="ImageUpload" class="form-label"></label>
|
<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" />
|
<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))
|
@if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath))
|
||||||
{
|
{
|
||||||
<label class="form-check mt-2">
|
<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">
|
<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">
|
<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>
|
<div>
|
||||||
<p class="eyebrow">Character</p>
|
<p class="eyebrow">Character</p>
|
||||||
<h2><a asp-action="Details" asp-route-id="@character.CharacterID">@character.CharacterName</a></h2>
|
<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);
|
var ageDisplay = CharacterAgeDisplayService.GetAgeForScene(character, Model.TimeModeName, Model.StartDateTime);
|
||||||
<article class="character-appearance-card">
|
<article class="character-appearance-card">
|
||||||
<div class="character-appearance-summary">
|
<div class="character-appearance-summary">
|
||||||
|
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.CharacterImagePath, ThumbnailPath = character.CharacterThumbnailPath })" />
|
||||||
<div>
|
<div>
|
||||||
<strong>@character.CharacterName</strong>
|
<strong>@character.CharacterName</strong>
|
||||||
@if (!string.IsNullOrWhiteSpace(ageDisplay.DisplayText))
|
@if (!string.IsNullOrWhiteSpace(ageDisplay.DisplayText))
|
||||||
|
|||||||
@ -631,6 +631,114 @@ a:hover {
|
|||||||
margin: 0;
|
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) {
|
@media (max-width: 767.98px) {
|
||||||
.pw-page-heading {
|
.pw-page-heading {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@ -659,6 +767,11 @@ a:hover {
|
|||||||
.project-card__actions .dropdown {
|
.project-card__actions .dropdown {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.character-gallery-upload,
|
||||||
|
.character-avatar-crop-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-workspace {
|
.timeline-workspace {
|
||||||
@ -2129,6 +2242,25 @@ a:focus-visible {
|
|||||||
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.34);
|
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"] .timeline-filter,
|
||||||
[data-theme="dark"] .workspace-heading .button-row,
|
[data-theme="dark"] .workspace-heading .button-row,
|
||||||
[data-theme="dark"] .timeline-control-panel {
|
[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