feat(album): add thumbnails and AlbumViewer.vue

This commit is contained in:
2025-05-26 15:11:57 -04:00
parent ea0241dd8d
commit a08b384495
11 changed files with 847 additions and 68 deletions

View File

@@ -1,9 +1,8 @@
using FastEndpoints;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Common.BlobStorage;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Hutopy.Web.Features.Contents.Handlers;
@@ -17,11 +16,21 @@ public record AddPhotoToAlbumRequest(
[PublicAPI]
public record AddPhotoToAlbumResponse(
Guid PhotoId,
string PhotoUrl);
string OriginalUrl,
string ThumbnailUrl);
[PublicAPI]
public sealed class AddPhotoToAlbumRequestValidator : Validator<AddPhotoToAlbumRequest>
{
private const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
private static readonly string[] AllowedImageTypes =
{
"image/jpeg",
"image/png",
"image/gif",
"image/webp"
};
public AddPhotoToAlbumRequestValidator()
{
RuleFor(x => x.AlbumId)
@@ -35,8 +44,10 @@ public sealed class AddPhotoToAlbumRequestValidator : Validator<AddPhotoToAlbumR
RuleFor(x => x.File)
.NotNull()
.NotEmpty()
.Must(file => file.ContentType.StartsWith("image/"))
.WithMessage("File must be an image");
.Must(file => AllowedImageTypes.Contains(file.ContentType))
.WithMessage("File must be a valid image (JPEG, PNG, GIF, or WebP)")
.Must(file => file.Length <= MaxFileSizeBytes)
.WithMessage($"File size must not exceed {MaxFileSizeBytes / 1024 / 1024}MB");
RuleFor(x => x.Caption)
.MaximumLength(255);
@@ -49,6 +60,9 @@ public class AddPhotoToAlbumHandler(
AzureBlobStorage blobStorage)
: Endpoint<AddPhotoToAlbumRequest, AddPhotoToAlbumResponse>
{
private const int MaxThumbnailWidth = 500;
private const int MaxThumbnailHeight = 500;
public override void Configure()
{
Post("/api/albums/{AlbumId}/photos");
@@ -62,6 +76,7 @@ public class AddPhotoToAlbumHandler(
{
var userId = User.GetUserId();
// Fetch the album we want to add photos to
var album = await context
.Albums
.SingleOrDefaultAsync(
@@ -85,37 +100,95 @@ public class AddPhotoToAlbumHandler(
return;
}
// Get the next order number
var nextOrder = await context
.AlbumPhotos
.Where(p => p.AlbumId == request.AlbumId)
.MaxAsync(p => (int?)p.Order, ct) ?? 0;
try
{
var (originalUrl, thumbnailUrl) = await ProcessAndUploadImage(request, ct);
// Upload the photo to blob storage
var photoUrl = await blobStorage.UploadFileAsync(
// Get the next order number
var nextOrder = await context
.AlbumPhotos
.Where(p => p.AlbumId == request.AlbumId)
.MaxAsync(p => (int?)p.Order, ct) ?? 0;
// Create the album photo
var photo = new AlbumPhoto
{
Id = request.PhotoId,
CreatedBy = userId,
AlbumId = request.AlbumId,
OriginalUrl = originalUrl,
ThumbnailUrl = thumbnailUrl,
Caption = request.Caption,
Order = nextOrder + 1
};
context.AlbumPhotos.Add(photo);
await context.SaveChangesAsync(ct);
await SendOkAsync(
new AddPhotoToAlbumResponse(photo.Id, originalUrl, thumbnailUrl),
ct);
}
catch (UnknownImageFormatException)
{
await SendStringAsync("Invalid image format", 400, cancellation: ct);
}
catch (Exception ex)
{
await SendStringAsync("Error processing image", 500, cancellation: ct);
}
}
private async Task<(string originalUrl, string thumbnailUrl)> ProcessAndUploadImage(
AddPhotoToAlbumRequest request,
CancellationToken ct)
{
var originalFileName = Path.GetFileName(request.File.FileName);
var nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName);
var extension = Path.GetExtension(originalFileName);
var filenameOriginal = $"{nameWithoutExt}{extension}";
var filenameThumbnail = $"{nameWithoutExt}.thumbnail{extension}";
var blobOriginal = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameOriginal}";
var blobThumbnail = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameThumbnail}";
// Process the original image
await using var originalStream = request.File.OpenReadStream();
using var image = await Image.LoadAsync(originalStream, ct);
// Calculate target size while preserving the original aspect ratio
var originalWidth = image.Width;
var originalHeight = image.Height;
double ratioX = (double)MaxThumbnailWidth / originalWidth;
double ratioY = (double)MaxThumbnailHeight / originalHeight;
double ratio = Math.Min(ratioX, ratioY);
int newWidth = (int)(originalWidth * ratio);
int newHeight = (int)(originalHeight * ratio);
// Create thumbnail
using var thumbnailStream = new MemoryStream();
image.Mutate(x => x.Resize(newWidth, newHeight));
await image.SaveAsync(thumbnailStream, image.Metadata.DecodedImageFormat!, ct);
thumbnailStream.Position = 0;
// Upload both versions
var originalUrl = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
$"{SubDirectoryNames.Albums}/{request.AlbumId}/{request.PhotoId}",
blobOriginal,
request.File.OpenReadStream(),
request.File.ContentType,
ct);
// Create the album photo
var photo = new AlbumPhoto
{
Id = request.PhotoId,
CreatedBy = userId,
AlbumId = request.AlbumId,
PhotoUrl = photoUrl,
Caption = request.Caption,
Order = nextOrder + 1
};
context.AlbumPhotos.Add(photo);
await context.SaveChangesAsync(ct);
await SendOkAsync(
new AddPhotoToAlbumResponse(photo.Id, photoUrl),
var thumbnailUrl = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
blobThumbnail,
thumbnailStream,
request.File.ContentType,
ct);
return (originalUrl, thumbnailUrl);
}
}
}