From a08b384495251ee16b81768ce1debc85f0c7ad8e Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Mon, 26 May 2025 15:11:57 -0400 Subject: [PATCH] feat(album): add thumbnails and AlbumViewer.vue --- .../src/Web/Features/Contents/Data/Album.cs | 5 +- ...6174825_AddThumbnailUrlToPhoto.Designer.cs | 403 ++++++++++++++++++ .../20250526174825_AddThumbnailUrlToPhoto.cs | 44 ++ .../ContentDbContextModelSnapshot.cs | 7 +- .../Contents/Handlers/AddPhotoToAlbum.cs | 137 ++++-- .../Features/Contents/Handlers/GetAlbum.cs | 6 +- backend/src/Web/Web.csproj | 1 + frontend/src/views/creators/AboutCreator.vue | 69 +-- frontend/src/views/creators/AlbumEditor.vue | 40 +- frontend/src/views/creators/AlbumView.vue | 8 +- frontend/src/views/creators/AlbumViewer.vue | 195 +++++++++ 11 files changed, 847 insertions(+), 68 deletions(-) create mode 100644 backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.Designer.cs create mode 100644 backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.cs create mode 100644 frontend/src/views/creators/AlbumViewer.vue diff --git a/backend/src/Web/Features/Contents/Data/Album.cs b/backend/src/Web/Features/Contents/Data/Album.cs index cd8f089..d538001 100644 --- a/backend/src/Web/Features/Contents/Data/Album.cs +++ b/backend/src/Web/Features/Contents/Data/Album.cs @@ -24,7 +24,8 @@ public class AlbumPhoto public bool IsDeleted { get; private set; } // private set → EF updates it public Guid AlbumId { get; set; } public Album Album { get; init; } = null!; - [MaxLength(2048)] public required string PhotoUrl { get; set; } + [MaxLength(2048)] public required string OriginalUrl { get; set; } + [MaxLength(2048)] public required string ThumbnailUrl { get; set; } [MaxLength(255)] public string? Caption { get; set; } public int Order { get; set; } -} +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.Designer.cs new file mode 100644 index 0000000..a06f704 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.Designer.cs @@ -0,0 +1,403 @@ +// +using System; +using Hutopy.Web.Features.Contents.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + [DbContext(typeof(ContentDbContext))] + [Migration("20250526174825_AddThumbnailUrlToPhoto")] + partial class AddThumbnailUrlToPhoto + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Content") + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("Albums", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.AlbumPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("OriginalUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ThumbnailUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AlbumId"); + + b.ToTable("AlbumPhotos", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("HtmlFileUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ThumbnailUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection("Urls") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Contents", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptDonation") + .HasColumnType("boolean"); + + b.Property("BannerUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Verified") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Creators", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Slugs", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NormalizedName") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComputedColumnSql("LOWER( \"Content\".\"Slugs\".\"Name\")", true); + + b.Property("ReservedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Slugs", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.AlbumPhoto", b => + { + b.HasOne("Hutopy.Web.Features.Contents.Data.Album", "Album") + .WithMany("Photos") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b => + { + b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId"); + + b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 => + { + b1.Property("ContentId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Reaction") + .HasColumnType("integer"); + + b1.Property("UserId") + .HasColumnType("uuid"); + + b1.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b1.HasKey("ContentId", "Id"); + + b1.ToTable("Reactions", "Content"); + + b1.WithOwner() + .HasForeignKey("ContentId"); + }); + + b.Navigation("Creator"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => + { + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Presentation", "Presentation", b1 => + { + b1.Property("CreatorId") + .HasColumnType("uuid"); + + b1.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("PhoneNumber") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("VideoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b1.HasKey("CreatorId"); + + b1.ToTable("Presentation", "Content"); + + b1.WithOwner() + .HasForeignKey("CreatorId"); + }); + + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 => + { + b1.Property("CreatorId") + .HasColumnType("uuid"); + + b1.Property("FacebookUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b1.Property("InstagramUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b1.Property("LinkedInUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b1.Property("RedditUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b1.Property("TikTokUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b1.Property("WebsiteUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b1.Property("XUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b1.Property("YoutubeUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b1.HasKey("CreatorId"); + + b1.ToTable("Socials", "Content"); + + b1.WithOwner() + .HasForeignKey("CreatorId"); + }); + + b.Navigation("Presentation") + .IsRequired(); + + b.Navigation("Socials") + .IsRequired(); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Album", b => + { + b.Navigation("Photos"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.cs new file mode 100644 index 0000000..32c0e4d --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class AddThumbnailUrlToPhoto : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "PhotoUrl", + schema: "Content", + table: "AlbumPhotos", + newName: "OriginalUrl"); + + migrationBuilder.AddColumn( + name: "ThumbnailUrl", + schema: "Content", + table: "AlbumPhotos", + type: "character varying(2048)", + maxLength: 2048, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ThumbnailUrl", + schema: "Content", + table: "AlbumPhotos"); + + migrationBuilder.RenameColumn( + name: "OriginalUrl", + schema: "Content", + table: "AlbumPhotos", + newName: "PhotoUrl"); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs b/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs index 3e84c3e..e60b022 100644 --- a/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs +++ b/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs @@ -93,7 +93,12 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations b.Property("Order") .HasColumnType("integer"); - b.Property("PhotoUrl") + b.Property("OriginalUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ThumbnailUrl") .IsRequired() .HasMaxLength(2048) .HasColumnType("character varying(2048)"); diff --git a/backend/src/Web/Features/Contents/Handlers/AddPhotoToAlbum.cs b/backend/src/Web/Features/Contents/Handlers/AddPhotoToAlbum.cs index c333a63..19af320 100644 --- a/backend/src/Web/Features/Contents/Handlers/AddPhotoToAlbum.cs +++ b/backend/src/Web/Features/Contents/Handlers/AddPhotoToAlbum.cs @@ -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 { + 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 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 { + 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); } -} +} diff --git a/backend/src/Web/Features/Contents/Handlers/GetAlbum.cs b/backend/src/Web/Features/Contents/Handlers/GetAlbum.cs index 6aee04f..591a05a 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetAlbum.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetAlbum.cs @@ -13,7 +13,8 @@ public record GetAlbumRequest( [PublicAPI] public record AlbumPhotoDto( Guid Id, - string PhotoUrl, + string OriginalUrl, + string ThumbnailUrl, string? Caption, int Order, DateTimeOffset CreatedAt); @@ -68,7 +69,8 @@ public class GetAlbumHandler( var photos = album.Photos .Select(p => new AlbumPhotoDto( p.Id, - p.PhotoUrl, + p.OriginalUrl, + p.ThumbnailUrl, p.Caption, p.Order, p.CreatedAt)) diff --git a/backend/src/Web/Web.csproj b/backend/src/Web/Web.csproj index 3d906ce..13c4ef0 100644 --- a/backend/src/Web/Web.csproj +++ b/backend/src/Web/Web.csproj @@ -26,6 +26,7 @@ all + diff --git a/frontend/src/views/creators/AboutCreator.vue b/frontend/src/views/creators/AboutCreator.vue index 884e7aa..7853558 100644 --- a/frontend/src/views/creators/AboutCreator.vue +++ b/frontend/src/views/creators/AboutCreator.vue @@ -10,9 +10,9 @@ @@ -20,10 +20,10 @@ @@ -31,9 +31,9 @@ @@ -56,15 +56,15 @@ @@ -88,11 +88,11 @@
@@ -101,18 +101,23 @@
+ }]" + :images="thumbnailUrls" + @photo-click="handlePhotoClick"/> + +
- +
@@ -136,10 +141,11 @@ import {useClient} from "@/plugins/api.js"; import {useBrandingStore} from "@/stores/brandingStore.js"; import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js"; import {useI18n} from 'vue-i18n'; -import Album from './Album.vue'; import {buildEmbedUrl, isValidYouTubeUrlOrId, extractVideoId} from '@/utils/youtube'; import AlbumEditor from "@/views/creators/AlbumEditor.vue"; import AlbumView from "@/views/creators/AlbumView.vue"; +// Add these imports at the top with your other imports +import AlbumViewer from './AlbumViewer.vue'; const {t} = useI18n(); const creatorProfileStore = useCreatorProfileStore(); @@ -156,9 +162,12 @@ const description = ref(""); const videoUrl = ref(""); const phoneNumber = ref(""); const email = ref(""); -const imageUrls = ref([]); +const thumbnailUrls = ref([]); const albumId = ref(null); const originalPhotos = ref([]); +// Add these refs with your other refs +const showAlbumViewer = ref(false); +const selectedPhotoIndex = ref(0); // Editable fields const editableDescription = ref(""); @@ -189,7 +198,7 @@ const canSave = computed(() => { // Computed property to check if there are images const hasImages = computed(() => { // Only consider it has images if there are actual image URLs (not empty strings) - return imageUrls.value.length > 0 && imageUrls.value.some(img => img && img.trim() !== ""); + return thumbnailUrls.value.length > 0 && thumbnailUrls.value.some(img => img && img.trim() !== ""); }); // Computed property for YouTube embed URL @@ -244,17 +253,17 @@ async function fetchAlbumData() { // Store original photos for comparison originalPhotos.value = response.data.photos; // Extract photo URLs from the album photos - imageUrls.value = response.data.photos.map(photo => photo.photoUrl); + thumbnailUrls.value = response.data.photos.map(photo => photo.thumbnailUrl); } else { // Initialize with empty array instead of empty slots - imageUrls.value = []; + thumbnailUrls.value = []; originalPhotos.value = []; } } catch (error) { // Album might not exist yet, which is fine console.log("Album might not exist yet:", error); // Initialize with empty array instead of empty slots - imageUrls.value = []; + thumbnailUrls.value = []; originalPhotos.value = []; } } @@ -274,7 +283,7 @@ onMounted(async () => { // Update images from Album component function updateImages(newImages) { - imageUrls.value = newImages; + thumbnailUrls.value = newImages; } async function saveChanges() { @@ -317,7 +326,7 @@ async function saveChanges() { videoUrl.value = extractVideoId(editableVideoUrl.value) || ""; // Save album photos if they've changed - if (imageUrls.value.length > 0) { + if (thumbnailUrls.value.length > 0) { // Create or update the album const albumId = brandingStore.value.id; @@ -336,7 +345,7 @@ async function saveChanges() { // Check for deleted photos const deletedPhotos = originalPhotos.value.filter(originalPhoto => { // If the photo URL is not in the current images array, it was deleted - return !imageUrls.value.includes(originalPhoto.photoUrl); + return !thumbnailUrls.value.includes(originalPhoto.thumbnailUrl); }); // Delete removed photos @@ -349,8 +358,8 @@ async function saveChanges() { } // Now add or update photos - for (let i = 0; i < imageUrls.value.length; i++) { - const imageUrl = imageUrls.value[i]; + for (let i = 0; i < thumbnailUrls.value.length; i++) { + const imageUrl = thumbnailUrls.value[i]; if (imageUrl && imageUrl.startsWith('data:')) { // This is a new image that needs to be uploaded const photoId = crypto.randomUUID(); @@ -396,6 +405,16 @@ function cancelEdit() { isEditMode.value = false; } +// Add this computed property to get the original image URLs +const originalUrls = computed(() => { + return originalPhotos.value.map(photo => photo.originalUrl); +}); + +// Add this function to handle photo clicks +function handlePhotoClick(index) { + selectedPhotoIndex.value = index; + showAlbumViewer.value = true; +} \ No newline at end of file + + + +{ + "en": { + "title": "Album", + "dropzoneText": "Drop a photo here to add it to your album", + "processing": "Processing...", + "uploading": "Uploading...", + "moveLeft": "Move Left", + "moveRight": "Move Right", + "delete": "Delete" + }, + "fr": { + "title": "Album", + "dropzoneText": "Déposez une photo ici pour l'ajouter à l'album", + "processing": "Traitement en cours...", + "uploading": "Téléchargement...", + "moveLeft": "Déplacer à gauche", + "moveRight": "Déplacer à droite", + "delete": "Supprimer" + }, + "es": { + "title": "Album", + "dropzoneText": "Suelta una foto aquí para añadirla al álbum", + "processing": "Procesando...", + "uploading": "Subiendo...", + "moveLeft": "Mover a la izquierda", + "moveRight": "Mover a la derecha", + "delete": "Eliminar" + } +} + \ No newline at end of file diff --git a/frontend/src/views/creators/AlbumView.vue b/frontend/src/views/creators/AlbumView.vue index 869a4bf..c942c7e 100644 --- a/frontend/src/views/creators/AlbumView.vue +++ b/frontend/src/views/creators/AlbumView.vue @@ -4,7 +4,8 @@
+ class="image-wrapper" + @click="$emit('photo-click', index)"> @@ -14,6 +15,9 @@ + + + + +{ + "en": { + "viewer": { + "previous": "Previous image", + "next": "Next image", + "close": "Close viewer", + "imageAlt": "Image {index}" + } + }, + "fr": { + "viewer": { + "previous": "Image précédente", + "next": "Image suivante", + "close": "Fermer", + "imageAlt": "Image {index}" + } + }, + "es": { + "viewer": { + "previous": "Imagen anterior", + "next": "Imagen siguiente", + "close": "Cerrar", + "imageAlt": "Imagen {index}" + } + } +} +