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

@@ -24,7 +24,8 @@ public class AlbumPhoto
public bool IsDeleted { get; private set; } // private set → EF updates it public bool IsDeleted { get; private set; } // private set → EF updates it
public Guid AlbumId { get; set; } public Guid AlbumId { get; set; }
public Album Album { get; init; } = null!; 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; } [MaxLength(255)] public string? Caption { get; set; }
public int Order { get; set; } public int Order { get; set; }
} }

View File

@@ -0,0 +1,403 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AlbumId")
.HasColumnType("uuid");
b.Property<string>("Caption")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<string>("OriginalUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.PrimitiveCollection<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("AcceptDonation")
.HasColumnType("boolean");
b.Property<string>("BannerUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("Verified")
.HasColumnType("boolean");
b.HasKey("Id");
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Slugs", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComputedColumnSql("LOWER( \"Content\".\"Slugs\".\"Name\")", true);
b.Property<DateTimeOffset>("ReservedUntil")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("PhoneNumber")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("RedditUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("XUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("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
}
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddThumbnailUrlToPhoto : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "PhotoUrl",
schema: "Content",
table: "AlbumPhotos",
newName: "OriginalUrl");
migrationBuilder.AddColumn<string>(
name: "ThumbnailUrl",
schema: "Content",
table: "AlbumPhotos",
type: "character varying(2048)",
maxLength: 2048,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ThumbnailUrl",
schema: "Content",
table: "AlbumPhotos");
migrationBuilder.RenameColumn(
name: "OriginalUrl",
schema: "Content",
table: "AlbumPhotos",
newName: "PhotoUrl");
}
}
}

View File

@@ -93,7 +93,12 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b.Property<int>("Order") b.Property<int>("Order")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("PhotoUrl") b.Property<string>("OriginalUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.IsRequired() .IsRequired()
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");

View File

@@ -1,9 +1,8 @@
using FastEndpoints;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Contents.Data; using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security; using Hutopy.Web.Common.Security;
using Hutopy.Web.Common.BlobStorage; using Hutopy.Web.Common.BlobStorage;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Web.Features.Contents.Handlers;
@@ -17,11 +16,21 @@ public record AddPhotoToAlbumRequest(
[PublicAPI] [PublicAPI]
public record AddPhotoToAlbumResponse( public record AddPhotoToAlbumResponse(
Guid PhotoId, Guid PhotoId,
string PhotoUrl); string OriginalUrl,
string ThumbnailUrl);
[PublicAPI] [PublicAPI]
public sealed class AddPhotoToAlbumRequestValidator : Validator<AddPhotoToAlbumRequest> 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() public AddPhotoToAlbumRequestValidator()
{ {
RuleFor(x => x.AlbumId) RuleFor(x => x.AlbumId)
@@ -35,8 +44,10 @@ public sealed class AddPhotoToAlbumRequestValidator : Validator<AddPhotoToAlbumR
RuleFor(x => x.File) RuleFor(x => x.File)
.NotNull() .NotNull()
.NotEmpty() .NotEmpty()
.Must(file => file.ContentType.StartsWith("image/")) .Must(file => AllowedImageTypes.Contains(file.ContentType))
.WithMessage("File must be an image"); .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) RuleFor(x => x.Caption)
.MaximumLength(255); .MaximumLength(255);
@@ -49,6 +60,9 @@ public class AddPhotoToAlbumHandler(
AzureBlobStorage blobStorage) AzureBlobStorage blobStorage)
: Endpoint<AddPhotoToAlbumRequest, AddPhotoToAlbumResponse> : Endpoint<AddPhotoToAlbumRequest, AddPhotoToAlbumResponse>
{ {
private const int MaxThumbnailWidth = 500;
private const int MaxThumbnailHeight = 500;
public override void Configure() public override void Configure()
{ {
Post("/api/albums/{AlbumId}/photos"); Post("/api/albums/{AlbumId}/photos");
@@ -62,6 +76,7 @@ public class AddPhotoToAlbumHandler(
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();
// Fetch the album we want to add photos to
var album = await context var album = await context
.Albums .Albums
.SingleOrDefaultAsync( .SingleOrDefaultAsync(
@@ -85,37 +100,95 @@ public class AddPhotoToAlbumHandler(
return; return;
} }
// Get the next order number try
var nextOrder = await context {
.AlbumPhotos var (originalUrl, thumbnailUrl) = await ProcessAndUploadImage(request, ct);
.Where(p => p.AlbumId == request.AlbumId)
.MaxAsync(p => (int?)p.Order, ct) ?? 0;
// Upload the photo to blob storage // Get the next order number
var photoUrl = await blobStorage.UploadFileAsync( 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, ContainerNames.Creators,
$"{SubDirectoryNames.Albums}/{request.AlbumId}/{request.PhotoId}", blobOriginal,
request.File.OpenReadStream(), request.File.OpenReadStream(),
request.File.ContentType, request.File.ContentType,
ct); ct);
// Create the album photo var thumbnailUrl = await blobStorage.UploadFileAsync(
var photo = new AlbumPhoto ContainerNames.Creators,
{ blobThumbnail,
Id = request.PhotoId, thumbnailStream,
CreatedBy = userId, request.File.ContentType,
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),
ct); ct);
return (originalUrl, thumbnailUrl);
} }
} }

View File

@@ -13,7 +13,8 @@ public record GetAlbumRequest(
[PublicAPI] [PublicAPI]
public record AlbumPhotoDto( public record AlbumPhotoDto(
Guid Id, Guid Id,
string PhotoUrl, string OriginalUrl,
string ThumbnailUrl,
string? Caption, string? Caption,
int Order, int Order,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);
@@ -68,7 +69,8 @@ public class GetAlbumHandler(
var photos = album.Photos var photos = album.Photos
.Select(p => new AlbumPhotoDto( .Select(p => new AlbumPhotoDto(
p.Id, p.Id,
p.PhotoUrl, p.OriginalUrl,
p.ThumbnailUrl,
p.Caption, p.Caption,
p.Order, p.Order,
p.CreatedAt)) p.CreatedAt))

View File

@@ -26,6 +26,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="Stripe.net" Version="47.4.0" /> <PackageReference Include="Stripe.net" Version="47.4.0" />
</ItemGroup> </ItemGroup>

View File

@@ -10,9 +10,9 @@
<!-- Edit button with pencil icon --> <!-- Edit button with pencil icon -->
<button <button
v-if="!isEditMode" v-if="!isEditMode"
:title="t('edit')"
class="w-12 h-12 bg-hutopyPrimary rounded-full flex items-center justify-center shadow-lg" class="w-12 h-12 bg-hutopyPrimary rounded-full flex items-center justify-center shadow-lg"
@click="toggleEditMode()" @click="toggleEditMode()"
:title="t('edit')"
> >
<v-icon large>mdi-pencil</v-icon> <v-icon large>mdi-pencil</v-icon>
</button> </button>
@@ -20,10 +20,10 @@
<!-- Save button --> <!-- Save button -->
<button <button
v-if="isEditMode" v-if="isEditMode"
:disabled="!canSave"
:title="t('save')"
class="w-12 h-12 bg-hutopyPrimary rounded-full flex items-center justify-center shadow-lg" class="w-12 h-12 bg-hutopyPrimary rounded-full flex items-center justify-center shadow-lg"
@click="saveChanges()" @click="saveChanges()"
:title="t('save')"
:disabled="!canSave"
> >
<v-icon large>mdi-check</v-icon> <v-icon large>mdi-check</v-icon>
</button> </button>
@@ -31,9 +31,9 @@
<!-- Cancel button --> <!-- Cancel button -->
<button <button
v-if="isEditMode" v-if="isEditMode"
:title="t('cancel')"
class="w-12 h-12 bg-red-500 rounded-full flex items-center justify-center shadow-lg" class="w-12 h-12 bg-red-500 rounded-full flex items-center justify-center shadow-lg"
@click="cancelEdit" @click="cancelEdit"
:title="t('cancel')"
> >
<v-icon large>mdi-close</v-icon> <v-icon large>mdi-close</v-icon>
</button> </button>
@@ -56,15 +56,15 @@
</div> </div>
<v-textarea v-if="isEditMode" <v-textarea v-if="isEditMode"
v-model="editableDescription" v-model="editableDescription"
class="w-full p-2 py-6"
:label="t('creator.sections.about.description')"
:error-messages="descriptionError"
:counter="2000" :counter="2000"
:error-messages="descriptionError"
:label="t('creator.sections.about.description')"
:rules="[ :rules="[
v => !!v || t('creator.validation.descriptionRequired'), v => !!v || t('creator.validation.descriptionRequired'),
v => v.length <= 2000 || t('creator.validation.descriptionTooLong') v => v.length <= 2000 || t('creator.validation.descriptionTooLong')
]" ]"
auto-grow auto-grow
class="w-full p-2 py-6"
rows="5" rows="5"
variant="outlined"></v-textarea> variant="outlined"></v-textarea>
</div> </div>
@@ -88,11 +88,11 @@
<div v-if="isEditMode"> <div v-if="isEditMode">
<v-text-field <v-text-field
v-model="editableVideoUrl" v-model="editableVideoUrl"
class="w-full p-2" :error-messages="videoUrlError"
:label="t('creator.fields.videoUrl')" :label="t('creator.fields.videoUrl')"
class="w-full p-2"
type="text" type="text"
variant="outlined" variant="outlined"
:error-messages="videoUrlError"
/> />
</div> </div>
</div> </div>
@@ -101,18 +101,23 @@
<div> <div>
<!-- Use AlbumView for display mode --> <!-- Use AlbumView for display mode -->
<AlbumView v-if="!isEditMode && hasImages" <AlbumView v-if="!isEditMode && hasImages"
:images="imageUrls"
:class="['content-section', { :class="['content-section', {
'rounded-b-xl': videoUrl && !isEditMode, 'rounded-b-xl': videoUrl && !isEditMode,
'rounded-xl': !videoUrl && !isEditMode 'rounded-xl': !videoUrl && !isEditMode
}]"/> }]"
:images="thumbnailUrls"
@photo-click="handlePhotoClick"/>
<AlbumViewer v-model="showAlbumViewer"
:images="originalUrls"
:start-index="selectedPhotoIndex"/>
<!-- Use AlbumEditor for edit mode --> <!-- Use AlbumEditor for edit mode -->
<AlbumEditor v-if="isEditMode" <AlbumEditor v-if="isEditMode"
:images="imageUrls" :images="thumbnailUrls"
@update:images="updateImages"/> @update:images="updateImages"/>
</div> </div>
<!-- Contact Information Section --> <!-- Contact Information Section -->
<div v-if="phoneNumber || email" class="contact-info mt-6"> <div v-if="phoneNumber || email" class="contact-info mt-6">
<!-- Phone Number --> <!-- Phone Number -->
@@ -136,10 +141,11 @@ import {useClient} from "@/plugins/api.js";
import {useBrandingStore} from "@/stores/brandingStore.js"; import {useBrandingStore} from "@/stores/brandingStore.js";
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js"; import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
import {useI18n} from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import Album from './Album.vue';
import {buildEmbedUrl, isValidYouTubeUrlOrId, extractVideoId} from '@/utils/youtube'; import {buildEmbedUrl, isValidYouTubeUrlOrId, extractVideoId} from '@/utils/youtube';
import AlbumEditor from "@/views/creators/AlbumEditor.vue"; import AlbumEditor from "@/views/creators/AlbumEditor.vue";
import AlbumView from "@/views/creators/AlbumView.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 {t} = useI18n();
const creatorProfileStore = useCreatorProfileStore(); const creatorProfileStore = useCreatorProfileStore();
@@ -156,9 +162,12 @@ const description = ref("");
const videoUrl = ref(""); const videoUrl = ref("");
const phoneNumber = ref(""); const phoneNumber = ref("");
const email = ref(""); const email = ref("");
const imageUrls = ref([]); const thumbnailUrls = ref([]);
const albumId = ref(null); const albumId = ref(null);
const originalPhotos = ref([]); const originalPhotos = ref([]);
// Add these refs with your other refs
const showAlbumViewer = ref(false);
const selectedPhotoIndex = ref(0);
// Editable fields // Editable fields
const editableDescription = ref(""); const editableDescription = ref("");
@@ -189,7 +198,7 @@ const canSave = computed(() => {
// Computed property to check if there are images // Computed property to check if there are images
const hasImages = computed(() => { const hasImages = computed(() => {
// Only consider it has images if there are actual image URLs (not empty strings) // 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 // Computed property for YouTube embed URL
@@ -244,17 +253,17 @@ async function fetchAlbumData() {
// Store original photos for comparison // Store original photos for comparison
originalPhotos.value = response.data.photos; originalPhotos.value = response.data.photos;
// Extract photo URLs from the album 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 { } else {
// Initialize with empty array instead of empty slots // Initialize with empty array instead of empty slots
imageUrls.value = []; thumbnailUrls.value = [];
originalPhotos.value = []; originalPhotos.value = [];
} }
} catch (error) { } catch (error) {
// Album might not exist yet, which is fine // Album might not exist yet, which is fine
console.log("Album might not exist yet:", error); console.log("Album might not exist yet:", error);
// Initialize with empty array instead of empty slots // Initialize with empty array instead of empty slots
imageUrls.value = []; thumbnailUrls.value = [];
originalPhotos.value = []; originalPhotos.value = [];
} }
} }
@@ -274,7 +283,7 @@ onMounted(async () => {
// Update images from Album component // Update images from Album component
function updateImages(newImages) { function updateImages(newImages) {
imageUrls.value = newImages; thumbnailUrls.value = newImages;
} }
async function saveChanges() { async function saveChanges() {
@@ -317,7 +326,7 @@ async function saveChanges() {
videoUrl.value = extractVideoId(editableVideoUrl.value) || ""; videoUrl.value = extractVideoId(editableVideoUrl.value) || "";
// Save album photos if they've changed // Save album photos if they've changed
if (imageUrls.value.length > 0) { if (thumbnailUrls.value.length > 0) {
// Create or update the album // Create or update the album
const albumId = brandingStore.value.id; const albumId = brandingStore.value.id;
@@ -336,7 +345,7 @@ async function saveChanges() {
// Check for deleted photos // Check for deleted photos
const deletedPhotos = originalPhotos.value.filter(originalPhoto => { const deletedPhotos = originalPhotos.value.filter(originalPhoto => {
// If the photo URL is not in the current images array, it was deleted // 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 // Delete removed photos
@@ -349,8 +358,8 @@ async function saveChanges() {
} }
// Now add or update photos // Now add or update photos
for (let i = 0; i < imageUrls.value.length; i++) { for (let i = 0; i < thumbnailUrls.value.length; i++) {
const imageUrl = imageUrls.value[i]; const imageUrl = thumbnailUrls.value[i];
if (imageUrl && imageUrl.startsWith('data:')) { if (imageUrl && imageUrl.startsWith('data:')) {
// This is a new image that needs to be uploaded // This is a new image that needs to be uploaded
const photoId = crypto.randomUUID(); const photoId = crypto.randomUUID();
@@ -396,6 +405,16 @@ function cancelEdit() {
isEditMode.value = false; 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;
}
</script> </script>
<style scoped> <style scoped>
@@ -547,4 +566,4 @@ function cancelEdit() {
} }
} }
} }
</i18n> </i18n>

View File

@@ -2,7 +2,7 @@
<div class="album-editor"> <div class="album-editor">
<h2 class="text-xl font-semibold mb-4"> <h2 class="text-xl font-semibold mb-4">
{{ t('creator.sections.album.title') }} {{ t('title') }}
</h2> </h2>
<!-- Drop zone with photos --> <!-- Drop zone with photos -->
@@ -52,7 +52,7 @@
<button @click.stop="moveImage(index, 'up')" <button @click.stop="moveImage(index, 'up')"
class="action-btn left-btn" class="action-btn left-btn"
:disabled="index === 0" :disabled="index === 0"
:title="t('moveUp')" :title="t('moveLeft')"
:class="{'mobile-active': activePhotoIndex === index}"> :class="{'mobile-active': activePhotoIndex === index}">
<v-icon>mdi-arrow-left</v-icon> <v-icon>mdi-arrow-left</v-icon>
</button> </button>
@@ -60,7 +60,7 @@
<button @click.stop="moveImage(index, 'down')" <button @click.stop="moveImage(index, 'down')"
class="action-btn right-btn" class="action-btn right-btn"
:disabled="index === localImages.length - 1" :disabled="index === localImages.length - 1"
:title="t('moveDown')" :title="t('moveRight')"
:class="{'mobile-active': activePhotoIndex === index}"> :class="{'mobile-active': activePhotoIndex === index}">
<v-icon>mdi-arrow-right</v-icon> <v-icon>mdi-arrow-right</v-icon>
</button> </button>
@@ -365,4 +365,36 @@ function moveImage(index, direction) {
.loading-overlay.uploading { .loading-overlay.uploading {
@apply bg-opacity-75; @apply bg-opacity-75;
} }
</style> </style>
<i18n>
{
"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"
}
}
</i18n>

View File

@@ -4,7 +4,8 @@
<div class="image-grid"> <div class="image-grid">
<div v-for="(url, index) in displayedImages" <div v-for="(url, index) in displayedImages"
:key="index" :key="index"
class="image-wrapper"> class="image-wrapper"
@click="$emit('photo-click', index)">
<img :src="url" <img :src="url"
:alt="t('creator.sections.album.image')" :alt="t('creator.sections.album.image')"
class="image"/> class="image"/>
@@ -14,6 +15,9 @@
</template> </template>
<script setup> <script setup>
// Add 'photo-click' to emits
const emit = defineEmits(['photo-click']);
import { computed, ref, onMounted, onUnmounted } from "vue"; import { computed, ref, onMounted, onUnmounted } from "vue";
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -131,4 +135,4 @@ const gridColumns = computed(() => {
} }
} }
} }
</i18n> </i18n>

View File

@@ -0,0 +1,195 @@
<template>
<v-dialog
v-model="dialog"
fullscreen
:scrim="true"
transition="dialog-bottom-transition"
@click:outside="closeViewer"
>
<div class="album-viewer" @click.self="closeViewer">
<!-- Main image container -->
<div class="image-container">
<img
:src="currentImage"
:alt="t('viewer.imageAlt', { index: currentIndex + 1 })"
class="main-image"
/>
<!-- Navigation buttons -->
<button
class="nav-btn left-btn"
@click.stop="previousImage"
:disabled="currentIndex === 0"
:title="t('viewer.previous')"
>
<v-icon size="large" color="white">mdi-chevron-left</v-icon>
</button>
<button
class="nav-btn right-btn"
@click.stop="nextImage"
:disabled="currentIndex === images.length - 1"
:title="t('viewer.next')"
>
<v-icon size="large" color="white">mdi-chevron-right</v-icon>
</button>
<!-- Close button -->
<button
class="close-btn"
@click.stop="closeViewer"
:title="t('viewer.close')"
>
<v-icon size="large" color="white">mdi-close</v-icon>
</button>
<!-- Image counter -->
<div class="image-counter">
{{ currentIndex + 1 }} / {{ images.length }}
</div>
</div>
</div>
</v-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
images: {
type: Array,
required: true
},
startIndex: {
type: Number,
default: 0
}
});
const emit = defineEmits(['update:modelValue']);
const dialog = ref(false);
const currentIndex = ref(0);
const currentImage = computed(() => props.images[currentIndex.value]);
watch(() => props.modelValue, (newVal) => {
dialog.value = newVal;
if (newVal) {
currentIndex.value = props.startIndex;
}
});
watch(() => dialog.value, (newVal) => {
emit('update:modelValue', newVal);
});
function nextImage() {
if (currentIndex.value < props.images.length - 1) {
currentIndex.value++;
}
}
function previousImage() {
if (currentIndex.value > 0) {
currentIndex.value--;
}
}
function closeViewer() {
dialog.value = false;
}
</script>
<style scoped>
.album-viewer {
@apply fixed inset-0;
@apply flex items-center justify-center;
@apply bg-black bg-opacity-90;
@apply z-50;
}
.image-container {
@apply relative;
@apply max-w-[90vw];
@apply max-h-[90vh];
}
.main-image {
@apply max-w-full;
@apply max-h-[90vh];
@apply object-contain;
}
.nav-btn {
@apply absolute top-1/2 -translate-y-1/2;
@apply p-4;
@apply rounded-full;
@apply bg-black bg-opacity-50;
@apply transition-all duration-200;
@apply hover:bg-opacity-75;
@apply disabled:opacity-30 disabled:cursor-not-allowed;
}
.left-btn {
@apply left-4;
}
.right-btn {
@apply right-4;
}
.close-btn {
@apply absolute top-4 right-4;
@apply p-2;
@apply rounded-full;
@apply bg-black bg-opacity-50;
@apply transition-all duration-200;
@apply hover:bg-opacity-75;
}
.image-counter {
@apply absolute bottom-4 left-1/2 -translate-x-1/2;
@apply px-4 py-2;
@apply bg-black bg-opacity-50;
@apply text-white;
@apply rounded-full;
@apply text-sm;
}
</style>
<i18n>
{
"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}"
}
}
}
</i18n>