Implemented the Visual Identity infrastructure pass.
Changed Added image fields for Characters and Story Assets in models, view models, repositories, services, and save flows. Added reusable avatar/initials rendering via [AvatarHelper.cs](C:/Source/PlotLine/PlotLine/Services/AvatarHelper.cs) and [_Avatar.cshtml](C:/Source/PlotLine/PlotLine/Views/Shared/_Avatar.cshtml). Added upload/remove processing through existing upload storage in [VisualIdentityImageService.cs](C:/Source/PlotLine/PlotLine/Services/VisualIdentityImageService.cs). Updated Character and Story Asset list/detail/edit views with thumbnails, initials fallback, upload, replace, and remove controls. Added styling in [site.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.css) and refreshed site.min.css. Database Added new migration [079_VisualIdentityCharacterAssetImages.sql](C:/Source/PlotLine/PlotLine/Sql/079_VisualIdentityCharacterAssetImages.sql). Adds nullable ImagePath and ThumbnailPath to Characters and StoryAssets. Applied locally and confirmed the columns exist. Stored Procedures Updated active procedure definitions only: Character_ListByProject Character_Get Character_Save Character_UpdateImage StoryAsset_ListByProject StoryAsset_Get StoryAsset_Save StoryAsset_UpdateImage
This commit is contained in:
parent
b91bdeb138
commit
34a8d94cb4
@ -38,6 +38,7 @@ public sealed class CharactersController(ICharacterService characters) : Control
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
model.Project ??= (await characters.GetCreateCharacterAsync(model.ProjectID))?.Project;
|
||||
return View("Edit", model);
|
||||
}
|
||||
|
||||
@ -67,6 +68,34 @@ public sealed class CharactersController(ICharacterService characters) : Control
|
||||
createModel.DefaultDescription = model.DefaultDescription;
|
||||
return View("Edit", createModel);
|
||||
}
|
||||
catch (EntityImageUploadException ex)
|
||||
{
|
||||
TempData["ImageError"] = ex.Message;
|
||||
return RedirectToAction(nameof(Edit), new { id = ex.EntityId });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, ex.Message);
|
||||
var editModel = model.CharacterID == 0
|
||||
? await characters.GetCreateCharacterAsync(model.ProjectID)
|
||||
: await characters.GetEditCharacterAsync(model.CharacterID);
|
||||
if (editModel is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
editModel.CharacterName = model.CharacterName;
|
||||
editModel.ShortName = model.ShortName;
|
||||
editModel.Sex = model.Sex;
|
||||
editModel.BirthDate = model.BirthDate;
|
||||
editModel.AgeAtSeriesStart = model.AgeAtSeriesStart;
|
||||
editModel.Height = model.Height;
|
||||
editModel.EyeColour = model.EyeColour;
|
||||
editModel.CharacterImportance = model.CharacterImportance;
|
||||
editModel.ShowInQuickAddBar = model.ShowInQuickAddBar;
|
||||
editModel.DefaultDescription = model.DefaultDescription;
|
||||
return View("Edit", editModel);
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id = characterId });
|
||||
}
|
||||
|
||||
@ -38,10 +38,27 @@ public sealed class StoryAssetsController(IAssetService assets) : Controller
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View("Edit", model);
|
||||
var invalidModel = await RehydrateEditModel(model);
|
||||
return invalidModel is null ? NotFound() : View("Edit", invalidModel);
|
||||
}
|
||||
|
||||
int assetId;
|
||||
try
|
||||
{
|
||||
assetId = await assets.SaveAssetAsync(model);
|
||||
}
|
||||
catch (EntityImageUploadException ex)
|
||||
{
|
||||
TempData["ImageError"] = ex.Message;
|
||||
return RedirectToAction(nameof(Edit), new { id = ex.EntityId });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, ex.Message);
|
||||
var editModel = await RehydrateEditModel(model);
|
||||
return editModel is null ? NotFound() : View("Edit", editModel);
|
||||
}
|
||||
|
||||
var assetId = await assets.SaveAssetAsync(model);
|
||||
return RedirectToAction(nameof(Details), new { id = assetId });
|
||||
}
|
||||
|
||||
@ -81,4 +98,25 @@ public sealed class StoryAssetsController(IAssetService assets) : Controller
|
||||
await assets.DeleteDependencyAsync(id);
|
||||
return RedirectToAction(nameof(Details), new { id = returnAssetId });
|
||||
}
|
||||
|
||||
private async Task<StoryAssetEditViewModel?> RehydrateEditModel(StoryAssetEditViewModel posted)
|
||||
{
|
||||
var model = posted.StoryAssetID == 0
|
||||
? await assets.GetCreateAssetAsync(posted.ProjectID)
|
||||
: await assets.GetEditAssetAsync(posted.StoryAssetID);
|
||||
if (model is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
model.AssetName = posted.AssetName;
|
||||
model.AssetKindID = posted.AssetKindID;
|
||||
model.Description = posted.Description;
|
||||
model.Importance = posted.Importance;
|
||||
model.CurrentStateID = posted.CurrentStateID;
|
||||
model.CurrentLocationID = posted.CurrentLocationID;
|
||||
model.IsResolved = posted.IsResolved;
|
||||
model.ShowInQuickAddBar = posted.ShowInQuickAddBar;
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,6 +147,7 @@ public interface IAssetRepository
|
||||
Task<IReadOnlyList<StoryAsset>> ListAssetsAsync(int projectId);
|
||||
Task<StoryAsset?> GetAssetAsync(int storyAssetId);
|
||||
Task<int> SaveAssetAsync(StoryAsset asset);
|
||||
Task UpdateAssetImageAsync(int storyAssetId, string? imagePath, string? thumbnailPath);
|
||||
Task ArchiveAssetAsync(int storyAssetId);
|
||||
Task<IReadOnlyList<AssetEvent>> ListEventsBySceneAsync(int sceneId);
|
||||
Task<IReadOnlyList<AssetEvent>> ListEventsByAssetAsync(int storyAssetId);
|
||||
@ -170,6 +171,7 @@ public interface ICharacterRepository
|
||||
Task<IReadOnlyList<Character>> ListCharactersAsync(int projectId);
|
||||
Task<Character?> GetCharacterAsync(int characterId);
|
||||
Task<int> SaveCharacterAsync(Character character);
|
||||
Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath);
|
||||
Task ArchiveCharacterAsync(int characterId);
|
||||
Task<IReadOnlyList<SceneCharacter>> ListSceneCharactersAsync(int sceneId);
|
||||
Task<int> SaveSceneCharacterAsync(SceneCharacter sceneCharacter);
|
||||
@ -3104,11 +3106,22 @@ public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : I
|
||||
asset.CurrentStateID,
|
||||
asset.CurrentLocationID,
|
||||
asset.IsResolved,
|
||||
asset.ShowInQuickAddBar
|
||||
asset.ShowInQuickAddBar,
|
||||
asset.ImagePath,
|
||||
asset.ThumbnailPath
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task UpdateAssetImageAsync(int storyAssetId, string? imagePath, string? thumbnailPath)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryAsset_UpdateImage",
|
||||
new { StoryAssetID = storyAssetId, ImagePath = imagePath, ThumbnailPath = thumbnailPath },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task ArchiveAssetAsync(int storyAssetId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
@ -3369,11 +3382,22 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
|
||||
character.EyeColour,
|
||||
character.CharacterImportance,
|
||||
character.ShowInQuickAddBar,
|
||||
character.DefaultDescription
|
||||
character.DefaultDescription,
|
||||
character.ImagePath,
|
||||
character.ThumbnailPath
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task UpdateCharacterImageAsync(int characterId, string? imagePath, string? thumbnailPath)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.Character_UpdateImage",
|
||||
new { CharacterID = characterId, ImagePath = imagePath, ThumbnailPath = thumbnailPath },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task ArchiveCharacterAsync(int characterId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -669,6 +669,8 @@ public sealed class StoryAsset
|
||||
public string? CurrentLocationPath { get; set; }
|
||||
public bool IsResolved { get; set; }
|
||||
public bool ShowInQuickAddBar { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public DateTime UpdatedDate { get; set; }
|
||||
public bool IsArchived { get; set; }
|
||||
@ -828,6 +830,8 @@ public sealed class Character
|
||||
public int? CharacterImportance { get; set; }
|
||||
public bool ShowInQuickAddBar { get; set; }
|
||||
public string? DefaultDescription { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public DateTime UpdatedDate { get; set; }
|
||||
public bool IsArchived { get; set; }
|
||||
|
||||
@ -132,6 +132,7 @@ public class Program
|
||||
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
||||
builder.Services.AddScoped<IProjectActivityService, ProjectActivityService>();
|
||||
builder.Services.AddScoped<IBookCoverService, BookCoverService>();
|
||||
builder.Services.AddScoped<IVisualIdentityImageService, VisualIdentityImageService>();
|
||||
builder.Services.AddScoped<IFloorPlanBackgroundImageService, FloorPlanBackgroundImageService>();
|
||||
builder.Services.AddScoped<IBookService, BookService>();
|
||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||
|
||||
56
PlotLine/Services/AvatarHelper.cs
Normal file
56
PlotLine/Services/AvatarHelper.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using System.Text;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public static class AvatarHelper
|
||||
{
|
||||
public static string Initials(string? displayName)
|
||||
{
|
||||
var words = SignificantWords(displayName).ToList();
|
||||
if (words.Count >= 2)
|
||||
{
|
||||
return string.Concat(words.Take(2).Select(word => char.ToUpperInvariant(word[0])));
|
||||
}
|
||||
|
||||
if (words.Count == 1)
|
||||
{
|
||||
return char.ToUpperInvariant(words[0][0]).ToString();
|
||||
}
|
||||
|
||||
return "?";
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SignificantWords(string? displayName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
foreach (var ch in displayName)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch is '\'' or '\u2019')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
yield return builder.ToString();
|
||||
builder.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
yield return builder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,11 @@ public sealed class ProjectCollaborationResult
|
||||
public static ProjectCollaborationResult Inaccessible() => new() { NotFound = true, Message = "Project not found." };
|
||||
}
|
||||
|
||||
public sealed class EntityImageUploadException(string message, int entityId) : InvalidOperationException(message)
|
||||
{
|
||||
public int EntityId { get; } = entityId;
|
||||
}
|
||||
|
||||
public sealed class ProjectActivityService(
|
||||
IProjectRepository projects,
|
||||
IProjectActivityRepository activity,
|
||||
@ -7143,7 +7148,12 @@ public sealed class StorageService(
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations, IProjectActivityService activity) : IAssetService
|
||||
public sealed class AssetService(
|
||||
IProjectRepository projects,
|
||||
IAssetRepository assets,
|
||||
ILocationRepository locations,
|
||||
IProjectActivityService activity,
|
||||
IVisualIdentityImageService visualIdentityImages) : IAssetService
|
||||
{
|
||||
public async Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId)
|
||||
{
|
||||
@ -7205,6 +7215,8 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a
|
||||
CurrentLocationID = asset.CurrentLocationID,
|
||||
IsResolved = asset.IsResolved,
|
||||
ShowInQuickAddBar = asset.ShowInQuickAddBar,
|
||||
ImagePath = asset.ImagePath,
|
||||
ThumbnailPath = asset.ThumbnailPath,
|
||||
Project = project
|
||||
}, lookupData);
|
||||
}
|
||||
@ -7250,6 +7262,7 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a
|
||||
public async Task<int> SaveAssetAsync(StoryAssetEditViewModel model)
|
||||
{
|
||||
var isNew = model.StoryAssetID == 0;
|
||||
var existing = isNew ? null : await assets.GetAssetAsync(model.StoryAssetID);
|
||||
var assetId = await assets.SaveAssetAsync(new StoryAsset
|
||||
{
|
||||
StoryAssetID = model.StoryAssetID,
|
||||
@ -7261,8 +7274,25 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a
|
||||
CurrentStateID = model.CurrentStateID,
|
||||
CurrentLocationID = model.CurrentLocationID,
|
||||
IsResolved = model.IsResolved,
|
||||
ShowInQuickAddBar = model.ShowInQuickAddBar
|
||||
ShowInQuickAddBar = model.ShowInQuickAddBar,
|
||||
ImagePath = model.ImagePath ?? existing?.ImagePath,
|
||||
ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath
|
||||
});
|
||||
var imageHost = existing ?? new StoryAsset { StoryAssetID = assetId, ProjectID = model.ProjectID };
|
||||
imageHost.StoryAssetID = assetId;
|
||||
imageHost.ProjectID = model.ProjectID;
|
||||
if (model.ImageUpload is { Length: > 0 } upload)
|
||||
{
|
||||
var result = await visualIdentityImages.UploadAssetImageAsync(imageHost, upload);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
throw new EntityImageUploadException(result.ErrorMessage ?? "Asset image could not be uploaded.", assetId);
|
||||
}
|
||||
}
|
||||
else if (model.RemoveImage && existing is not null)
|
||||
{
|
||||
await visualIdentityImages.RemoveAssetImageAsync(existing);
|
||||
}
|
||||
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Asset", assetId, model.AssetName);
|
||||
return assetId;
|
||||
}
|
||||
@ -8338,7 +8368,8 @@ public sealed class CharacterService(
|
||||
ICharacterRepository characters,
|
||||
ISubscriptionService subscriptions,
|
||||
IProjectActivityService activity,
|
||||
ICurrentUserService currentUser) : ICharacterService
|
||||
ICurrentUserService currentUser,
|
||||
IVisualIdentityImageService visualIdentityImages) : ICharacterService
|
||||
{
|
||||
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
|
||||
{
|
||||
@ -8378,6 +8409,8 @@ public sealed class CharacterService(
|
||||
CharacterImportance = character.CharacterImportance,
|
||||
ShowInQuickAddBar = character.ShowInQuickAddBar,
|
||||
DefaultDescription = character.DefaultDescription,
|
||||
ImagePath = character.ImagePath,
|
||||
ThumbnailPath = character.ThumbnailPath,
|
||||
Project = await projects.GetAsync(character.ProjectID)
|
||||
};
|
||||
}
|
||||
@ -8459,6 +8492,7 @@ public sealed class CharacterService(
|
||||
}
|
||||
|
||||
var isNew = model.CharacterID == 0;
|
||||
var existing = isNew ? null : await characters.GetCharacterAsync(model.CharacterID);
|
||||
var characterId = await characters.SaveCharacterAsync(new Character
|
||||
{
|
||||
CharacterID = model.CharacterID,
|
||||
@ -8472,8 +8506,25 @@ public sealed class CharacterService(
|
||||
EyeColour = model.EyeColour,
|
||||
CharacterImportance = model.CharacterImportance,
|
||||
ShowInQuickAddBar = model.ShowInQuickAddBar,
|
||||
DefaultDescription = model.DefaultDescription
|
||||
DefaultDescription = model.DefaultDescription,
|
||||
ImagePath = model.ImagePath ?? existing?.ImagePath,
|
||||
ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath
|
||||
});
|
||||
var imageHost = existing ?? new Character { CharacterID = characterId, ProjectID = model.ProjectID };
|
||||
imageHost.CharacterID = characterId;
|
||||
imageHost.ProjectID = model.ProjectID;
|
||||
if (model.ImageUpload is { Length: > 0 } upload)
|
||||
{
|
||||
var result = await visualIdentityImages.UploadCharacterImageAsync(imageHost, upload);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
throw new EntityImageUploadException(result.ErrorMessage ?? "Character image could not be uploaded.", characterId);
|
||||
}
|
||||
}
|
||||
else if (model.RemoveImage && existing is not null)
|
||||
{
|
||||
await visualIdentityImages.RemoveCharacterImageAsync(existing);
|
||||
}
|
||||
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Character", characterId, model.CharacterName);
|
||||
return characterId;
|
||||
}
|
||||
|
||||
307
PlotLine/Services/VisualIdentityImageService.cs
Normal file
307
PlotLine/Services/VisualIdentityImageService.cs
Normal file
@ -0,0 +1,307 @@
|
||||
using System.Collections.Concurrent;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IVisualIdentityImageService
|
||||
{
|
||||
Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload);
|
||||
Task RemoveCharacterImageAsync(Character character);
|
||||
Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload);
|
||||
Task RemoveAssetImageAsync(StoryAsset asset);
|
||||
}
|
||||
|
||||
public sealed record VisualIdentityImageResult(bool Succeeded, string? ErrorMessage = null);
|
||||
|
||||
public sealed class VisualIdentityImageService(
|
||||
IUploadStorageService uploadStorage,
|
||||
IProjectCollaborationRepository collaboration,
|
||||
ISubscriptionService subscriptions,
|
||||
IUserFileRepository userFiles,
|
||||
ICharacterRepository characters,
|
||||
IAssetRepository assets,
|
||||
ILogger<VisualIdentityImageService> logger) : IVisualIdentityImageService
|
||||
{
|
||||
private const long MaxUploadBytes = 10L * 1024L * 1024L;
|
||||
private const int ThumbnailSize = 128;
|
||||
private const int StandardMaxSize = 1200;
|
||||
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> EntityLocks = new();
|
||||
|
||||
public Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload)
|
||||
=> UploadAsync(
|
||||
$"character:{character.CharacterID}",
|
||||
character.ProjectID,
|
||||
character.CharacterID,
|
||||
character.ImagePath,
|
||||
character.ThumbnailPath,
|
||||
"characters",
|
||||
"CharacterImage",
|
||||
(imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath),
|
||||
upload);
|
||||
|
||||
public Task RemoveCharacterImageAsync(Character character)
|
||||
=> RemoveAsync(
|
||||
character.ImagePath,
|
||||
character.ThumbnailPath,
|
||||
"uploads/characters",
|
||||
(imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath));
|
||||
|
||||
public Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload)
|
||||
=> UploadAsync(
|
||||
$"asset:{asset.StoryAssetID}",
|
||||
asset.ProjectID,
|
||||
asset.StoryAssetID,
|
||||
asset.ImagePath,
|
||||
asset.ThumbnailPath,
|
||||
"story-assets",
|
||||
"StoryAssetImage",
|
||||
(imagePath, thumbnailPath) => assets.UpdateAssetImageAsync(asset.StoryAssetID, imagePath, thumbnailPath),
|
||||
upload);
|
||||
|
||||
public Task RemoveAssetImageAsync(StoryAsset asset)
|
||||
=> RemoveAsync(
|
||||
asset.ImagePath,
|
||||
asset.ThumbnailPath,
|
||||
"uploads/story-assets",
|
||||
(imagePath, thumbnailPath) => assets.UpdateAssetImageAsync(asset.StoryAssetID, imagePath, thumbnailPath));
|
||||
|
||||
private async Task<VisualIdentityImageResult> UploadAsync(
|
||||
string lockKey,
|
||||
int projectId,
|
||||
int entityId,
|
||||
string? oldImagePath,
|
||||
string? oldThumbnailPath,
|
||||
string folderName,
|
||||
string entityType,
|
||||
Func<string?, string?, Task> updateImage,
|
||||
IFormFile upload)
|
||||
{
|
||||
var uploadLock = EntityLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1));
|
||||
await uploadLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
return await UploadCoreAsync(projectId, entityId, oldImagePath, oldThumbnailPath, folderName, entityType, updateImage, upload);
|
||||
}
|
||||
finally
|
||||
{
|
||||
uploadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<VisualIdentityImageResult> UploadCoreAsync(
|
||||
int projectId,
|
||||
int entityId,
|
||||
string? oldImagePath,
|
||||
string? oldThumbnailPath,
|
||||
string folderName,
|
||||
string entityType,
|
||||
Func<string?, string?, Task> updateImage,
|
||||
IFormFile upload)
|
||||
{
|
||||
if (upload.Length <= 0)
|
||||
{
|
||||
return new(false, "Choose an image to upload.");
|
||||
}
|
||||
|
||||
if (upload.Length > MaxUploadBytes)
|
||||
{
|
||||
return new(false, "Image must be 10 MB or smaller.");
|
||||
}
|
||||
|
||||
if (!SupportedExtensions.Contains(Path.GetExtension(upload.FileName)))
|
||||
{
|
||||
return new(false, "Image must be a JPG, PNG or WebP file.");
|
||||
}
|
||||
|
||||
var ownerUserId = await collaboration.GetOwnerUserIdAsync(projectId);
|
||||
if (!ownerUserId.HasValue)
|
||||
{
|
||||
return new(false, "Project owner could not be found.");
|
||||
}
|
||||
|
||||
var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, EstimatedGeneratedSize(upload.Length));
|
||||
if (!storageLimit.Allowed)
|
||||
{
|
||||
return new(false, storageLimit.Message);
|
||||
}
|
||||
|
||||
SKBitmap bitmap;
|
||||
try
|
||||
{
|
||||
await using var uploadStream = upload.OpenReadStream();
|
||||
using var memory = new MemoryStream();
|
||||
await uploadStream.CopyToAsync(memory);
|
||||
memory.Position = 0;
|
||||
|
||||
using var codec = SKCodec.Create(memory) ?? throw new InvalidDataException();
|
||||
if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp))
|
||||
{
|
||||
return new(false, "Image must be a valid JPG, PNG or WebP file.");
|
||||
}
|
||||
|
||||
memory.Position = 0;
|
||||
bitmap = SKBitmap.Decode(memory) ?? throw new InvalidDataException();
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or ArgumentException)
|
||||
{
|
||||
return new(false, "Image must be a valid JPG, PNG or WebP file.");
|
||||
}
|
||||
|
||||
using (bitmap)
|
||||
{
|
||||
if (bitmap.Width <= 0 || bitmap.Height <= 0)
|
||||
{
|
||||
return new(false, "Image could not be read as a valid image.");
|
||||
}
|
||||
|
||||
var uploadRoot = uploadStorage.GetDirectory(folderName, entityId.ToString());
|
||||
var version = Guid.NewGuid().ToString("N");
|
||||
var imageTemp = Path.Combine(uploadRoot, $"image-{version}.tmp");
|
||||
var thumbnailTemp = Path.Combine(uploadRoot, $"thumb-{version}.tmp");
|
||||
var imagePath = Path.Combine(uploadRoot, "image.webp");
|
||||
var thumbnailPath = Path.Combine(uploadRoot, "thumb.webp");
|
||||
var imageStoragePath = uploadStorage.GetPublicPath(folderName, entityId.ToString(), "image.webp");
|
||||
var thumbnailStoragePath = uploadStorage.GetPublicPath(folderName, entityId.ToString(), "thumb.webp");
|
||||
|
||||
try
|
||||
{
|
||||
SaveContainedWebp(bitmap, imageTemp, StandardMaxSize, 82);
|
||||
SaveCroppedWebp(bitmap, thumbnailTemp, ThumbnailSize, ThumbnailSize, 76);
|
||||
|
||||
ReplaceFile(imageTemp, imagePath);
|
||||
ReplaceFile(thumbnailTemp, thumbnailPath);
|
||||
|
||||
await updateImage(imageStoragePath, thumbnailStoragePath);
|
||||
await MarkPreviousLedgerRowsAsync(oldImagePath);
|
||||
await MarkPreviousLedgerRowsAsync(oldThumbnailPath);
|
||||
await TrackImageAsync(ownerUserId.Value, projectId, entityId, entityType, imageStoragePath, upload.FileName, new FileInfo(imagePath).Length);
|
||||
await TrackImageAsync(ownerUserId.Value, projectId, entityId, entityType, thumbnailStoragePath, "Thumbnail: " + upload.FileName, new FileInfo(thumbnailPath).Length);
|
||||
DeletePreviousPhysicalFile(oldImagePath, imageStoragePath, $"uploads/{folderName}");
|
||||
DeletePreviousPhysicalFile(oldThumbnailPath, thumbnailStoragePath, $"uploads/{folderName}");
|
||||
return new(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TryDeleteFile(imageTemp);
|
||||
TryDeleteFile(thumbnailTemp);
|
||||
logger.LogWarning(ex, "Visual identity image upload failed for {EntityType} {EntityID}.", entityType, entityId);
|
||||
return new(false, "Image could not be processed. Try another image.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveAsync(string? imagePath, string? thumbnailPath, string requiredPrefix, Func<string?, string?, Task> updateImage)
|
||||
{
|
||||
await updateImage(null, null);
|
||||
await MarkPreviousLedgerRowsAsync(imagePath);
|
||||
await MarkPreviousLedgerRowsAsync(thumbnailPath);
|
||||
DeletePreviousPhysicalFile(imagePath, null, requiredPrefix);
|
||||
DeletePreviousPhysicalFile(thumbnailPath, null, requiredPrefix);
|
||||
}
|
||||
|
||||
private static void SaveContainedWebp(SKBitmap source, string path, int maxSize, int quality)
|
||||
{
|
||||
var scale = Math.Min(maxSize / (float)source.Width, maxSize / (float)source.Height);
|
||||
scale = Math.Min(scale, 1f);
|
||||
var width = Math.Max(1, (int)Math.Round(source.Width * scale));
|
||||
var height = Math.Max(1, (int)Math.Round(source.Height * scale));
|
||||
SaveWebp(source, path, new SKRect(0, 0, source.Width, source.Height), width, height, quality);
|
||||
}
|
||||
|
||||
private static void SaveCroppedWebp(SKBitmap source, string path, int width, int height, int quality)
|
||||
{
|
||||
var sourceRatio = source.Width / (float)source.Height;
|
||||
var targetRatio = width / (float)height;
|
||||
SKRect sourceRect;
|
||||
|
||||
if (sourceRatio > targetRatio)
|
||||
{
|
||||
var cropWidth = source.Height * targetRatio;
|
||||
var left = (source.Width - cropWidth) / 2f;
|
||||
sourceRect = new SKRect(left, 0, left + cropWidth, source.Height);
|
||||
}
|
||||
else
|
||||
{
|
||||
var cropHeight = source.Width / targetRatio;
|
||||
var top = (source.Height - cropHeight) / 2f;
|
||||
sourceRect = new SKRect(0, top, source.Width, top + cropHeight);
|
||||
}
|
||||
|
||||
SaveWebp(source, path, sourceRect, width, height, quality);
|
||||
}
|
||||
|
||||
private static void SaveWebp(SKBitmap source, string path, SKRect sourceRect, int width, int height, int quality)
|
||||
{
|
||||
using var surface = SKSurface.Create(new SKImageInfo(width, height));
|
||||
var canvas = surface.Canvas;
|
||||
canvas.Clear(SKColors.Transparent);
|
||||
using var paint = new SKPaint { IsAntialias = true };
|
||||
using var sourceImage = SKImage.FromBitmap(source);
|
||||
canvas.DrawImage(sourceImage, sourceRect, new SKRect(0, 0, width, height), new SKSamplingOptions(SKCubicResampler.Mitchell), paint);
|
||||
canvas.Flush();
|
||||
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Webp, quality);
|
||||
using var output = File.Create(path);
|
||||
data.SaveTo(output);
|
||||
}
|
||||
|
||||
private async Task TrackImageAsync(int ownerUserId, int projectId, int entityId, string entityType, string storagePath, string originalFileName, long fileSizeBytes)
|
||||
{
|
||||
await userFiles.CreateAsync(new UserFile
|
||||
{
|
||||
UserID = ownerUserId,
|
||||
ProjectID = projectId,
|
||||
EntityType = entityType,
|
||||
EntityID = entityId,
|
||||
FileName = Path.GetFileName(storagePath),
|
||||
OriginalFileName = originalFileName,
|
||||
ContentType = "image/webp",
|
||||
FileSizeBytes = fileSizeBytes,
|
||||
StoragePath = storagePath
|
||||
});
|
||||
}
|
||||
|
||||
private async Task MarkPreviousLedgerRowsAsync(string? oldStoragePath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(oldStoragePath))
|
||||
{
|
||||
await userFiles.MarkDeletedByStoragePathAsync(oldStoragePath);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeletePreviousPhysicalFile(string? oldStoragePath, string? currentStoragePath, string requiredPrefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(oldStoragePath)
|
||||
|| string.Equals(oldStoragePath, currentStoragePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
uploadStorage.TryDeleteFile(oldStoragePath, requiredPrefix);
|
||||
}
|
||||
|
||||
private static void ReplaceFile(string tempPath, string finalPath)
|
||||
{
|
||||
if (File.Exists(finalPath))
|
||||
{
|
||||
File.Delete(finalPath);
|
||||
}
|
||||
|
||||
File.Move(tempPath, finalPath);
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static long EstimatedGeneratedSize(long uploadBytes)
|
||||
=> Math.Max(1, Math.Min(uploadBytes, MaxUploadBytes));
|
||||
}
|
||||
182
PlotLine/Sql/079_VisualIdentityCharacterAssetImages.sql
Normal file
182
PlotLine/Sql/079_VisualIdentityCharacterAssetImages.sql
Normal file
@ -0,0 +1,182 @@
|
||||
IF COL_LENGTH(N'dbo.Characters', N'ImagePath') IS NULL
|
||||
ALTER TABLE dbo.Characters ADD ImagePath nvarchar(500) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Characters', N'ThumbnailPath') IS NULL
|
||||
ALTER TABLE dbo.Characters ADD ThumbnailPath nvarchar(500) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.StoryAssets', N'ImagePath') IS NULL
|
||||
ALTER TABLE dbo.StoryAssets ADD ImagePath nvarchar(500) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.StoryAssets', N'ThumbnailPath') IS NULL
|
||||
ALTER TABLE dbo.StoryAssets ADD ThumbnailPath nvarchar(500) NULL;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_ListByProject
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
|
||||
Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath,
|
||||
CreatedDate, UpdatedDate, IsArchived
|
||||
FROM dbo.Characters
|
||||
WHERE ProjectID = @ProjectID AND IsArchived = 0
|
||||
ORDER BY CharacterName;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_Get
|
||||
@CharacterID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
|
||||
Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath,
|
||||
CreatedDate, UpdatedDate, IsArchived
|
||||
FROM dbo.Characters
|
||||
WHERE CharacterID = @CharacterID AND IsArchived = 0;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_Save
|
||||
@CharacterID int = NULL,
|
||||
@ProjectID int,
|
||||
@CharacterName nvarchar(200),
|
||||
@ShortName nvarchar(100) = NULL,
|
||||
@Sex nvarchar(50) = NULL,
|
||||
@BirthDate date = NULL,
|
||||
@AgeAtSeriesStart int = NULL,
|
||||
@AgeReferenceSceneID int = NULL,
|
||||
@Height nvarchar(50) = NULL,
|
||||
@EyeColour nvarchar(50) = NULL,
|
||||
@CharacterImportance int = NULL,
|
||||
@ShowInQuickAddBar bit = 0,
|
||||
@DefaultDescription nvarchar(max) = NULL,
|
||||
@ImagePath nvarchar(500) = NULL,
|
||||
@ThumbnailPath nvarchar(500) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF @CharacterID IS NULL OR @CharacterID = 0
|
||||
BEGIN
|
||||
INSERT dbo.Characters (ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath)
|
||||
VALUES (@ProjectID, @CharacterName, @ShortName, @Sex, @BirthDate, @AgeAtSeriesStart, @AgeReferenceSceneID, @Height, @EyeColour, @CharacterImportance, @ShowInQuickAddBar, @DefaultDescription, @ImagePath, @ThumbnailPath);
|
||||
SET @CharacterID = CAST(SCOPE_IDENTITY() AS int);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
UPDATE dbo.Characters
|
||||
SET CharacterName = @CharacterName, ShortName = @ShortName, Sex = @Sex, BirthDate = @BirthDate,
|
||||
AgeAtSeriesStart = @AgeAtSeriesStart, AgeReferenceSceneID = @AgeReferenceSceneID,
|
||||
Height = @Height, EyeColour = @EyeColour, CharacterImportance = @CharacterImportance,
|
||||
ShowInQuickAddBar = @ShowInQuickAddBar, DefaultDescription = @DefaultDescription,
|
||||
ImagePath = @ImagePath, ThumbnailPath = @ThumbnailPath,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE CharacterID = @CharacterID;
|
||||
END
|
||||
SELECT @CharacterID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_UpdateImage
|
||||
@CharacterID int,
|
||||
@ImagePath nvarchar(500) = NULL,
|
||||
@ThumbnailPath nvarchar(500) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
UPDATE dbo.Characters
|
||||
SET ImagePath = @ImagePath,
|
||||
ThumbnailPath = @ThumbnailPath,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE CharacterID = @CharacterID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryAsset_ListByProject
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT sa.StoryAssetID, sa.ProjectID, sa.AssetName, sa.AssetKindID, ak.KindName, sa.Description, sa.Importance,
|
||||
sa.CurrentStateID, ast.StateName AS CurrentStateName, sa.CurrentLocationID,
|
||||
location.LocationName AS CurrentLocationName, location.LocationPath AS CurrentLocationPath,
|
||||
sa.IsResolved, sa.ShowInQuickAddBar, sa.ImagePath, sa.ThumbnailPath, sa.CreatedDate, sa.UpdatedDate, sa.IsArchived
|
||||
FROM dbo.StoryAssets sa
|
||||
INNER JOIN dbo.AssetKinds ak ON ak.AssetKindID = sa.AssetKindID
|
||||
LEFT JOIN dbo.AssetStates ast ON ast.AssetStateID = sa.CurrentStateID
|
||||
LEFT JOIN dbo.LocationPaths location ON location.LocationID = sa.CurrentLocationID
|
||||
WHERE sa.ProjectID = @ProjectID AND sa.IsArchived = 0
|
||||
ORDER BY sa.Importance DESC, sa.AssetName;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryAsset_Get
|
||||
@StoryAssetID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT sa.StoryAssetID, sa.ProjectID, sa.AssetName, sa.AssetKindID, ak.KindName, sa.Description, sa.Importance,
|
||||
sa.CurrentStateID, ast.StateName AS CurrentStateName, sa.CurrentLocationID,
|
||||
location.LocationName AS CurrentLocationName, location.LocationPath AS CurrentLocationPath,
|
||||
sa.IsResolved, sa.ShowInQuickAddBar, sa.ImagePath, sa.ThumbnailPath, sa.CreatedDate, sa.UpdatedDate, sa.IsArchived
|
||||
FROM dbo.StoryAssets sa
|
||||
INNER JOIN dbo.AssetKinds ak ON ak.AssetKindID = sa.AssetKindID
|
||||
LEFT JOIN dbo.AssetStates ast ON ast.AssetStateID = sa.CurrentStateID
|
||||
LEFT JOIN dbo.LocationPaths location ON location.LocationID = sa.CurrentLocationID
|
||||
WHERE sa.StoryAssetID = @StoryAssetID AND sa.IsArchived = 0;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryAsset_Save
|
||||
@StoryAssetID int = NULL,
|
||||
@ProjectID int,
|
||||
@AssetName nvarchar(200),
|
||||
@AssetKindID int,
|
||||
@Description nvarchar(max) = NULL,
|
||||
@Importance int,
|
||||
@CurrentStateID int = NULL,
|
||||
@CurrentLocationID int = NULL,
|
||||
@IsResolved bit,
|
||||
@ShowInQuickAddBar bit = 0,
|
||||
@ImagePath nvarchar(500) = NULL,
|
||||
@ThumbnailPath nvarchar(500) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF @StoryAssetID IS NULL OR @StoryAssetID = 0
|
||||
BEGIN
|
||||
INSERT dbo.StoryAssets (ProjectID, AssetName, AssetKindID, Description, Importance, CurrentStateID, CurrentLocationID, IsResolved, ShowInQuickAddBar, ImagePath, ThumbnailPath)
|
||||
VALUES (@ProjectID, @AssetName, @AssetKindID, @Description, @Importance, @CurrentStateID, @CurrentLocationID, @IsResolved, @ShowInQuickAddBar, @ImagePath, @ThumbnailPath);
|
||||
SET @StoryAssetID = CAST(SCOPE_IDENTITY() AS int);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
UPDATE dbo.StoryAssets
|
||||
SET AssetName = @AssetName, AssetKindID = @AssetKindID, Description = @Description, Importance = @Importance,
|
||||
CurrentStateID = @CurrentStateID, CurrentLocationID = @CurrentLocationID, IsResolved = @IsResolved,
|
||||
ShowInQuickAddBar = @ShowInQuickAddBar, ImagePath = @ImagePath, ThumbnailPath = @ThumbnailPath,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE StoryAssetID = @StoryAssetID;
|
||||
END
|
||||
SELECT @StoryAssetID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryAsset_UpdateImage
|
||||
@StoryAssetID int,
|
||||
@ImagePath nvarchar(500) = NULL,
|
||||
@ThumbnailPath nvarchar(500) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
UPDATE dbo.StoryAssets
|
||||
SET ImagePath = @ImagePath,
|
||||
ThumbnailPath = @ThumbnailPath,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE StoryAssetID = @StoryAssetID;
|
||||
END;
|
||||
GO
|
||||
@ -1417,6 +1417,15 @@ public sealed class StoryAssetEditViewModel
|
||||
[Display(Name = "Show in Quick Add Bar")]
|
||||
public bool ShowInQuickAddBar { get; set; }
|
||||
|
||||
public string? ImagePath { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
|
||||
[Display(Name = "Upload image")]
|
||||
public IFormFile? ImageUpload { get; set; }
|
||||
|
||||
[Display(Name = "Remove image")]
|
||||
public bool RemoveImage { get; set; }
|
||||
|
||||
public Project? Project { get; set; }
|
||||
public IReadOnlyList<SelectListItem> AssetKindOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> AssetStateOptions { get; set; } = [];
|
||||
@ -1541,6 +1550,15 @@ public sealed class CharacterEditViewModel
|
||||
[Display(Name = "Default description")]
|
||||
public string? DefaultDescription { get; set; }
|
||||
|
||||
public string? ImagePath { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
|
||||
[Display(Name = "Upload image")]
|
||||
public IFormFile? ImageUpload { get; set; }
|
||||
|
||||
[Display(Name = "Remove image")]
|
||||
public bool RemoveImage { get; set; }
|
||||
|
||||
public IReadOnlyList<SelectListItem> ImportanceOptions { get; set; } =
|
||||
[
|
||||
new("Use timeline fallback", string.Empty),
|
||||
@ -1981,6 +1999,16 @@ public sealed class FloorPlanTransitionEditViewModel
|
||||
public string Label => string.IsNullOrWhiteSpace(DisplayLabel) ? TransitionType : DisplayLabel;
|
||||
}
|
||||
|
||||
public sealed class AvatarViewModel
|
||||
{
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? ImagePath { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
public string Size { get; set; } = "md";
|
||||
public string? CssClass { get; set; }
|
||||
public bool Lazy { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class WarningDashboardViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading pw-page-heading">
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.Character.CharacterName, ImagePath = Model.Character.ImagePath, ThumbnailPath = Model.Character.ThumbnailPath, Size = "xl", Lazy = false })" />
|
||||
<div>
|
||||
<p class="eyebrow">Character</p>
|
||||
<h1>@Model.Character.CharacterName <help-icon key="characters.details" /></h1>
|
||||
|
||||
@ -10,9 +10,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form asp-action="Save" method="post" class="edit-panel">
|
||||
@if (TempData["ImageError"] is string imageError)
|
||||
{
|
||||
<div class="alert alert-warning">@imageError</div>
|
||||
}
|
||||
|
||||
<form asp-action="Save" method="post" class="edit-panel" enctype="multipart/form-data">
|
||||
<input asp-for="CharacterID" type="hidden" />
|
||||
<input asp-for="ProjectID" type="hidden" />
|
||||
<input asp-for="ImagePath" type="hidden" />
|
||||
<input asp-for="ThumbnailPath" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
@ -61,6 +68,26 @@
|
||||
<label asp-for="DefaultDescription" class="form-label"></label>
|
||||
<textarea asp-for="DefaultDescription" class="form-control" rows="5"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="visual-image-editor">
|
||||
<div>
|
||||
<p class="eyebrow">Character Image</p>
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.CharacterName, ImagePath = Model.ImagePath, ThumbnailPath = Model.ThumbnailPath, Size = "lg", Lazy = false })" />
|
||||
</div>
|
||||
<div class="visual-image-editor__controls">
|
||||
<label asp-for="ImageUpload" class="form-label"></label>
|
||||
<input asp-for="ImageUpload" class="form-control" type="file" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" />
|
||||
<div class="form-text">JPG, PNG or WebP. A 128 x 128 thumbnail is generated automatically.</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath))
|
||||
{
|
||||
<label class="form-check mt-2">
|
||||
<input asp-for="RemoveImage" class="form-check-input" />
|
||||
<span class="form-check-label">Remove image</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
|
||||
@ -33,10 +33,13 @@
|
||||
@foreach (var character in Model.Characters)
|
||||
{
|
||||
<article class="asset-card character-card" draggable="true" data-drag-type="character" data-drag-id="@character.CharacterID" data-drag-label="@character.CharacterName">
|
||||
<div>
|
||||
<p class="eyebrow">Character</p>
|
||||
<h2><a asp-action="Details" asp-route-id="@character.CharacterID">@character.CharacterName</a></h2>
|
||||
<p class="muted">@character.DefaultDescription</p>
|
||||
<div class="visual-card-heading">
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.ImagePath, ThumbnailPath = character.ThumbnailPath })" />
|
||||
<div>
|
||||
<p class="eyebrow">Character</p>
|
||||
<h2><a asp-action="Details" asp-route-id="@character.CharacterID">@character.CharacterName</a></h2>
|
||||
<p class="muted">@character.DefaultDescription</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-card-footer">
|
||||
@if (!string.IsNullOrWhiteSpace(character.ShortName))
|
||||
|
||||
16
PlotLine/Views/Shared/_Avatar.cshtml
Normal file
16
PlotLine/Views/Shared/_Avatar.cshtml
Normal file
@ -0,0 +1,16 @@
|
||||
@model AvatarViewModel
|
||||
@{
|
||||
var imagePath = !string.IsNullOrWhiteSpace(Model.ThumbnailPath) ? Model.ThumbnailPath : Model.ImagePath;
|
||||
var cssClass = $"visual-avatar visual-avatar--{Model.Size} {Model.CssClass}".Trim();
|
||||
}
|
||||
|
||||
<span class="@cssClass" title="@Model.DisplayName">
|
||||
@if (!string.IsNullOrWhiteSpace(imagePath))
|
||||
{
|
||||
<img src="@imagePath" alt="" loading="@(Model.Lazy ? "lazy" : "eager")" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@AvatarHelper.Initials(Model.DisplayName)</span>
|
||||
}
|
||||
</span>
|
||||
@ -17,6 +17,7 @@
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading">
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.Asset.AssetName, ImagePath = Model.Asset.ImagePath, ThumbnailPath = Model.Asset.ThumbnailPath, Size = "xl", Lazy = false })" />
|
||||
<div>
|
||||
<p class="eyebrow">@Model.Asset.KindName</p>
|
||||
<h1>@Model.Asset.AssetName <help-icon key="storyAssets.details" /></h1>
|
||||
|
||||
@ -10,9 +10,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form asp-action="Save" method="post" class="edit-panel">
|
||||
@if (TempData["ImageError"] is string imageError)
|
||||
{
|
||||
<div class="alert alert-warning">@imageError</div>
|
||||
}
|
||||
|
||||
<form asp-action="Save" method="post" class="edit-panel" enctype="multipart/form-data">
|
||||
<input asp-for="StoryAssetID" type="hidden" />
|
||||
<input asp-for="ProjectID" type="hidden" />
|
||||
<input asp-for="ImagePath" type="hidden" />
|
||||
<input asp-for="ThumbnailPath" type="hidden" />
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
@ -63,6 +70,26 @@
|
||||
<label asp-for="Description" class="form-label"></label>
|
||||
<textarea asp-for="Description" class="form-control" rows="5"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="visual-image-editor">
|
||||
<div>
|
||||
<p class="eyebrow">Asset Image</p>
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = Model.AssetName, ImagePath = Model.ImagePath, ThumbnailPath = Model.ThumbnailPath, Size = "lg", Lazy = false })" />
|
||||
</div>
|
||||
<div class="visual-image-editor__controls">
|
||||
<label asp-for="ImageUpload" class="form-label"></label>
|
||||
<input asp-for="ImageUpload" class="form-control" type="file" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" />
|
||||
<div class="form-text">JPG, PNG or WebP. A 128 x 128 thumbnail is generated automatically.</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath))
|
||||
{
|
||||
<label class="form-check mt-2">
|
||||
<input asp-for="RemoveImage" class="form-check-input" />
|
||||
<span class="form-check-label">Remove image</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
|
||||
@ -60,10 +60,13 @@
|
||||
@foreach (var asset in Model.Assets)
|
||||
{
|
||||
<article class="asset-card" draggable="true" data-drag-type="asset" data-drag-id="@asset.StoryAssetID" data-drag-label="@asset.AssetName" data-asset-drop-target="true" data-asset-id="@asset.StoryAssetID" data-asset-label="@asset.AssetName">
|
||||
<div>
|
||||
<p class="eyebrow">@asset.KindName</p>
|
||||
<h2><a asp-action="Details" asp-route-id="@asset.StoryAssetID">@asset.AssetName</a></h2>
|
||||
<p class="muted">@asset.Description</p>
|
||||
<div class="visual-card-heading">
|
||||
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = asset.AssetName, ImagePath = asset.ImagePath, ThumbnailPath = asset.ThumbnailPath })" />
|
||||
<div>
|
||||
<p class="eyebrow">@asset.KindName</p>
|
||||
<h2><a asp-action="Details" asp-route-id="@asset.StoryAssetID">@asset.AssetName</a></h2>
|
||||
<p class="muted">@asset.Description</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-card-footer">
|
||||
<span class="status-pill">Importance @asset.Importance</span>
|
||||
|
||||
@ -4952,6 +4952,73 @@ body.dragging-location [data-drag-type="location"] {
|
||||
border-color: rgba(155, 52, 43, .6);
|
||||
}
|
||||
|
||||
.visual-card-heading {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visual-card-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.visual-avatar {
|
||||
align-items: center;
|
||||
aspect-ratio: 1;
|
||||
background: linear-gradient(135deg, rgba(47, 111, 99, .16), rgba(212, 154, 98, .16));
|
||||
border: 1px solid rgba(47, 111, 99, .22);
|
||||
border-radius: 999px;
|
||||
color: var(--plotline-accent-dark, #22534a);
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
font-weight: 900;
|
||||
justify-content: center;
|
||||
letter-spacing: .02em;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.visual-avatar img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.visual-avatar--sm {
|
||||
font-size: .72rem;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.visual-avatar--md {
|
||||
font-size: .9rem;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.visual-avatar--lg {
|
||||
font-size: 1.3rem;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.visual-avatar--xl {
|
||||
font-size: 1.6rem;
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
.visual-image-editor {
|
||||
align-items: center;
|
||||
background: rgba(47, 111, 99, .05);
|
||||
border: 1px solid rgba(47, 111, 99, .14);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.visual-image-editor__controls {
|
||||
flex: 1 1 auto;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.floor-plan-block--room {
|
||||
background: #d9eee7;
|
||||
}
|
||||
@ -5289,6 +5356,19 @@ body.dragging-location [data-drag-type="location"] {
|
||||
border-color: rgba(240, 138, 120, .66);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .visual-avatar,
|
||||
.dark .visual-avatar {
|
||||
background: linear-gradient(135deg, rgba(131, 199, 242, .14), rgba(212, 154, 98, .18));
|
||||
border-color: rgba(244, 234, 220, .18);
|
||||
color: #f4eadc;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .visual-image-editor,
|
||||
.dark .visual-image-editor {
|
||||
background: rgba(244, 234, 220, .04);
|
||||
border-color: rgba(244, 234, 220, .12);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-transition-item,
|
||||
.dark .floor-plan-transition-item {
|
||||
border-color: rgba(212, 154, 98, .22);
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user