feat(album): add thumbnails and AlbumViewer.vue
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user