feat(album): add thumbnails and AlbumViewer.vue
This commit is contained in:
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
403
backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.Designer.cs
generated
Normal file
403
backend/src/Web/Features/Contents/Data/Migrations/20250526174825_AddThumbnailUrlToPhoto.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
195
frontend/src/views/creators/AlbumViewer.vue
Normal file
195
frontend/src/views/creators/AlbumViewer.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user