diff --git a/backend/src/Web/Common/BlobStorage/SubDirectoryNames.cs b/backend/src/Web/Common/BlobStorage/SubDirectoryNames.cs index b99383e..72c777e 100644 --- a/backend/src/Web/Common/BlobStorage/SubDirectoryNames.cs +++ b/backend/src/Web/Common/BlobStorage/SubDirectoryNames.cs @@ -4,4 +4,5 @@ public static class SubDirectoryNames { public static string Profile = "profile"; public static string Contents = "contents"; + public static string Albums = "albums"; } diff --git a/backend/src/Web/Features/Contents/Data/Album.cs b/backend/src/Web/Features/Contents/Data/Album.cs new file mode 100644 index 0000000..cd8f089 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Album.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Hutopy.Web.Features.Contents.Data; + +public class Album +{ + public Guid Id { get; init; } + public Guid CreatedBy { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public Guid? DeletedBy { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public bool IsDeleted { get; private set; } // private set → EF updates it + [MaxLength(255)] public required string Title { get; set; } + public IList Photos { get; set; } = new List(); +} + +public class AlbumPhoto +{ + public Guid Id { get; init; } + public Guid CreatedBy { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public Guid? DeletedBy { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + 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(255)] public string? Caption { get; set; } + public int Order { get; set; } +} diff --git a/backend/src/Web/Features/Contents/Data/ContentDbContext.cs b/backend/src/Web/Features/Contents/Data/ContentDbContext.cs index dc94f4b..d65b4ce 100644 --- a/backend/src/Web/Features/Contents/Data/ContentDbContext.cs +++ b/backend/src/Web/Features/Contents/Data/ContentDbContext.cs @@ -9,6 +9,8 @@ public class ContentDbContext( public DbSet Contents => Set(); public DbSet Creators => Set(); public DbSet Slugs => Set(); + public DbSet Albums => Set(); + public DbSet AlbumPhotos => Set(); protected override void OnModelCreating( ModelBuilder modelBuilder) @@ -62,16 +64,50 @@ public class ContentDbContext( modelBuilder .Entity() - .OwnsOne(x => x.Images) - .ToTable(nameof(Images)); - - modelBuilder - .Entity() - .OwnsOne(x => x.PresentationInfos) - .ToTable(nameof(PresentationInfos)); + .OwnsOne(x => x.Presentation) + .ToTable(nameof(Presentation)); modelBuilder .Entity() .HasQueryFilter(c => !c.IsDeleted); + + // Album configuration + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + modelBuilder + .Entity() + .Property(c => c.IsDeleted) + .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", stored: true); + + modelBuilder + .Entity() + .HasQueryFilter(a => !a.IsDeleted); + + // AlbumPhoto configuration + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + modelBuilder + .Entity() + .Property(c => c.IsDeleted) + .HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", stored: true); + + modelBuilder + .Entity() + .HasOne(ap => ap.Album) + .WithMany(a => a.Photos) + .HasForeignKey(ap => ap.AlbumId) + .IsRequired(); + + modelBuilder + .Entity() + .HasQueryFilter(ap => !ap.IsDeleted); } } diff --git a/backend/src/Web/Features/Contents/Data/Creator.cs b/backend/src/Web/Features/Contents/Data/Creator.cs index 4eaf646..71f6438 100644 --- a/backend/src/Web/Features/Contents/Data/Creator.cs +++ b/backend/src/Web/Features/Contents/Data/Creator.cs @@ -6,7 +6,7 @@ public class Creator { public Guid Id { get; set; } - public Guid CreatedBy { get; set; } + public Guid CreatedBy { get; set; } public DateTimeOffset CreatedAt { get; init; } public Guid? DeletedBy { get; set; } public DateTimeOffset? DeletedAt { get; set; } @@ -16,14 +16,16 @@ public class Creator /// public bool IsDeleted { get; private set; } // private set → EF updates it - public bool AcceptDonation { get; set; } + [MaxLength(2048)] public string? BannerUrl { get; set; } + [MaxLength(2048)] public string? PortraitUrl { get; set; } public bool Verified { get; set; } [MaxLength(255)] public string Name { get; set; } [MaxLength(128)] public string Slug { get; set; } [MaxLength(255)] public string? Title { get; set; } + + public bool AcceptDonation { get; set; } public Socials Socials { get; set; } = new(); - public Images Images { get; set; } = new(); - public PresentationInfos PresentationInfos { get; set; } = new(); + public Presentation Presentation { get; set; } = new(); } public class Socials @@ -38,29 +40,10 @@ public class Socials [MaxLength(2048)] public string? WebsiteUrl { get; set; } } -public class Images +public class Presentation { - [MaxLength(2048)] public string? Banner { get; set; } - [MaxLength(2048)] public string? Logo { get; set; } -} - -public class PresentationInfos -{ - [MaxLength(255)] public string PhoneNumber { get; set; } = string.Empty; - [MaxLength(255)] public string Email { get; set; } = string.Empty; - [MaxLength(2000)] public string Title { get; set; } = string.Empty; - [MaxLength(2048)] public string MainImageUrl { get; set; } = string.Empty; - [MaxLength(10000)] public string MainImageText { get; set; } = string.Empty; - [MaxLength(10000)] public string MainVideoText { get; set; } = string.Empty; - [MaxLength(2000)] public string ImagesSubtitle { get; set; } = string.Empty; - [MaxLength(2048)] public string Image1Url { get; set; } = string.Empty; - [MaxLength(2048)] public string Image2Url { get; set; } = string.Empty; - [MaxLength(2048)] public string Image3Url { get; set; } = string.Empty; - [MaxLength(2048)] public string Image4Url { get; set; } = string.Empty; - [MaxLength(10000)] public string ImagesText { get; set; } = string.Empty; - [MaxLength(2000)] public string VideoSubtitle { get; set; } = string.Empty; - [MaxLength(2000)] public string VideoSubtitleMain { get; set; } = string.Empty; - [MaxLength(2048)] public string VideoUrlMain { get; set; } = string.Empty; - [MaxLength(2048)] public string VideoUrl { get; set; } = string.Empty; - [MaxLength(10000)] public string VideoText { get; set; } = string.Empty; + [MaxLength(2000)] public string Description { get; set; } = null!; + [MaxLength(2048)] public string? VideoUrl { get; set; } + [MaxLength(255)] public string? PhoneNumber { get; set; } + [MaxLength(255)] public string? Email { get; set; } } diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250423153323_AddPresentation.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250423153323_AddPresentation.Designer.cs new file mode 100644 index 0000000..82cd03e --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250423153323_AddPresentation.Designer.cs @@ -0,0 +1,301 @@ +// +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("20250423153323_AddPresentation")] + partial class AddPresentation + { + /// + 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.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.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() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250423153323_AddPresentation.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250423153323_AddPresentation.cs new file mode 100644 index 0000000..cc8676a --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250423153323_AddPresentation.cs @@ -0,0 +1,137 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class AddPresentation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Images", + schema: "Content"); + + migrationBuilder.DropTable( + name: "PresentationInfos", + schema: "Content"); + + migrationBuilder.AddColumn( + name: "BannerUrl", + schema: "Content", + table: "Creators", + type: "character varying(2048)", + maxLength: 2048, + nullable: true); + + migrationBuilder.AddColumn( + name: "PortraitUrl", + schema: "Content", + table: "Creators", + type: "character varying(2048)", + maxLength: 2048, + nullable: true); + + migrationBuilder.CreateTable( + name: "Presentation", + schema: "Content", + columns: table => new + { + CreatorId = table.Column(type: "uuid", nullable: false), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + VideoUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + PhoneNumber = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + Email = table.Column(type: "character varying(255)", maxLength: 255, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Presentation", x => x.CreatorId); + table.ForeignKey( + name: "FK_Presentation_Creators_CreatorId", + column: x => x.CreatorId, + principalSchema: "Content", + principalTable: "Creators", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Presentation", + schema: "Content"); + + migrationBuilder.DropColumn( + name: "BannerUrl", + schema: "Content", + table: "Creators"); + + migrationBuilder.DropColumn( + name: "PortraitUrl", + schema: "Content", + table: "Creators"); + + migrationBuilder.CreateTable( + name: "Images", + schema: "Content", + columns: table => new + { + CreatorId = table.Column(type: "uuid", nullable: false), + Banner = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + Logo = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Images", x => x.CreatorId); + table.ForeignKey( + name: "FK_Images_Creators_CreatorId", + column: x => x.CreatorId, + principalSchema: "Content", + principalTable: "Creators", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PresentationInfos", + schema: "Content", + columns: table => new + { + CreatorId = table.Column(type: "uuid", nullable: false), + Email = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Image1Url = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + Image2Url = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + Image3Url = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + Image4Url = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + ImagesSubtitle = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + ImagesText = table.Column(type: "character varying(10000)", maxLength: 10000, nullable: false), + MainImageText = table.Column(type: "character varying(10000)", maxLength: 10000, nullable: false), + MainImageUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + MainVideoText = table.Column(type: "character varying(10000)", maxLength: 10000, nullable: false), + PhoneNumber = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Title = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + VideoSubtitle = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + VideoSubtitleMain = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + VideoText = table.Column(type: "character varying(10000)", maxLength: 10000, nullable: false), + VideoUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + VideoUrlMain = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PresentationInfos", x => x.CreatorId); + table.ForeignKey( + name: "FK_PresentationInfos_Creators_CreatorId", + column: x => x.CreatorId, + principalSchema: "Content", + principalTable: "Creators", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250423173651_AddAlbumAndPhotos.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250423173651_AddAlbumAndPhotos.Designer.cs new file mode 100644 index 0000000..88afe4d --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250423173651_AddAlbumAndPhotos.Designer.cs @@ -0,0 +1,407 @@ +// +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("20250423173651_AddAlbumAndPhotos")] + partial class AddAlbumAndPhotos + { + /// + 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("CoverPhotoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + 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("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + 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("PhotoUrl") + .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() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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/20250423173651_AddAlbumAndPhotos.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250423173651_AddAlbumAndPhotos.cs new file mode 100644 index 0000000..5226759 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250423173651_AddAlbumAndPhotos.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class AddAlbumAndPhotos : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Albums", + schema: "Content", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + DeletedBy = table.Column(type: "uuid", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true), + Title = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + CoverPhotoUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Albums", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AlbumPhotos", + schema: "Content", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + DeletedBy = table.Column(type: "uuid", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true), + AlbumId = table.Column(type: "uuid", nullable: false), + PhotoUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + Caption = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + Order = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AlbumPhotos", x => x.Id); + table.ForeignKey( + name: "FK_AlbumPhotos_Albums_AlbumId", + column: x => x.AlbumId, + principalSchema: "Content", + principalTable: "Albums", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AlbumPhotos_AlbumId", + schema: "Content", + table: "AlbumPhotos", + column: "AlbumId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AlbumPhotos", + schema: "Content"); + + migrationBuilder.DropTable( + name: "Albums", + schema: "Content"); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250423180519_SimplifyAlbums.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250423180519_SimplifyAlbums.Designer.cs new file mode 100644 index 0000000..02647b8 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250423180519_SimplifyAlbums.Designer.cs @@ -0,0 +1,399 @@ +// +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("20250423180519_SimplifyAlbums")] + partial class SimplifyAlbums + { + /// + 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("PhotoUrl") + .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() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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/20250423180519_SimplifyAlbums.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250423180519_SimplifyAlbums.cs new file mode 100644 index 0000000..67633df --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250423180519_SimplifyAlbums.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class SimplifyAlbums : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoverPhotoUrl", + schema: "Content", + table: "Albums"); + + migrationBuilder.DropColumn( + name: "Description", + schema: "Content", + table: "Albums"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverPhotoUrl", + schema: "Content", + table: "Albums", + type: "character varying(2048)", + maxLength: 2048, + nullable: true); + + migrationBuilder.AddColumn( + name: "Description", + schema: "Content", + table: "Albums", + type: "character varying(1000)", + maxLength: 1000, + nullable: true); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs b/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs index 55d3d35..6aced8d 100644 --- a/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs +++ b/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs @@ -23,6 +23,88 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations 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("PhotoUrl") + .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") @@ -83,6 +165,10 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations b.Property("AcceptDonation") .HasColumnType("boolean"); + b.Property("BannerUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -105,6 +191,10 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations .HasMaxLength(255) .HasColumnType("character varying(255)"); + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + b.Property("Slug") .IsRequired() .HasMaxLength(128) @@ -160,6 +250,17 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations 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") @@ -203,120 +304,31 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => { - b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 => + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Presentation", "Presentation", b1 => { b1.Property("CreatorId") .HasColumnType("uuid"); - b1.Property("Banner") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("Logo") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.HasKey("CreatorId"); - - b1.ToTable("Images", "Content"); - - b1.WithOwner() - .HasForeignKey("CreatorId"); - }); - - b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 => - { - b1.Property("CreatorId") - .HasColumnType("uuid"); + b1.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); b1.Property("Email") - .IsRequired() .HasMaxLength(255) .HasColumnType("character varying(255)"); - b1.Property("Image1Url") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("Image2Url") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("Image3Url") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("Image4Url") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("ImagesSubtitle") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b1.Property("ImagesText") - .IsRequired() - .HasMaxLength(10000) - .HasColumnType("character varying(10000)"); - - b1.Property("MainImageText") - .IsRequired() - .HasMaxLength(10000) - .HasColumnType("character varying(10000)"); - - b1.Property("MainImageUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("MainVideoText") - .IsRequired() - .HasMaxLength(10000) - .HasColumnType("character varying(10000)"); - b1.Property("PhoneNumber") - .IsRequired() .HasMaxLength(255) .HasColumnType("character varying(255)"); - b1.Property("Title") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b1.Property("VideoSubtitle") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b1.Property("VideoSubtitleMain") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b1.Property("VideoText") - .IsRequired() - .HasMaxLength(10000) - .HasColumnType("character varying(10000)"); - b1.Property("VideoUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - - b1.Property("VideoUrlMain") - .IsRequired() .HasMaxLength(2048) .HasColumnType("character varying(2048)"); b1.HasKey("CreatorId"); - b1.ToTable("PresentationInfos", "Content"); + b1.ToTable("Presentation", "Content"); b1.WithOwner() .HasForeignKey("CreatorId"); @@ -367,15 +379,17 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations .HasForeignKey("CreatorId"); }); - b.Navigation("Images") - .IsRequired(); - - b.Navigation("PresentationInfos") + 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/Handlers/AddPhotoToAlbum.cs b/backend/src/Web/Features/Contents/Handlers/AddPhotoToAlbum.cs new file mode 100644 index 0000000..c333a63 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/AddPhotoToAlbum.cs @@ -0,0 +1,121 @@ +using FastEndpoints; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Common.BlobStorage; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record AddPhotoToAlbumRequest( + Guid AlbumId, + Guid PhotoId, + IFormFile File, + string? Caption = null); + +[PublicAPI] +public record AddPhotoToAlbumResponse( + Guid PhotoId, + string PhotoUrl); + +[PublicAPI] +public sealed class AddPhotoToAlbumRequestValidator : Validator +{ + public AddPhotoToAlbumRequestValidator() + { + RuleFor(x => x.AlbumId) + .NotNull() + .NotEmpty(); + + RuleFor(x => x.PhotoId) + .NotNull() + .NotEmpty(); + + RuleFor(x => x.File) + .NotNull() + .NotEmpty() + .Must(file => file.ContentType.StartsWith("image/")) + .WithMessage("File must be an image"); + + RuleFor(x => x.Caption) + .MaximumLength(255); + } +} + +[PublicAPI] +public class AddPhotoToAlbumHandler( + ContentDbContext context, + AzureBlobStorage blobStorage) + : Endpoint +{ + public override void Configure() + { + Post("/api/albums/{AlbumId}/photos"); + Options(o => o.WithTags("Albums")); + AllowFileUploads(); + } + + public override async Task HandleAsync( + AddPhotoToAlbumRequest request, + CancellationToken ct) + { + var userId = User.GetUserId(); + + var album = await context + .Albums + .SingleOrDefaultAsync( + a => a.Id == request.AlbumId && a.CreatedBy == userId, + cancellationToken: ct); + + if (album is null) + { + await SendNotFoundAsync(ct); + return; + } + + // Check if a photo with the same ID already exists + var existingPhoto = await context + .AlbumPhotos + .AnyAsync(p => p.Id == request.PhotoId, ct); + + if (existingPhoto) + { + await SendErrorsAsync(409, ct); + return; + } + + // Get the next order number + var nextOrder = await context + .AlbumPhotos + .Where(p => p.AlbumId == request.AlbumId) + .MaxAsync(p => (int?)p.Order, ct) ?? 0; + + // Upload the photo to blob storage + var photoUrl = await blobStorage.UploadFileAsync( + ContainerNames.Creators, + $"{SubDirectoryNames.Albums}/{request.AlbumId}/{request.PhotoId}", + 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), + ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/ChangeBanner.cs b/backend/src/Web/Features/Contents/Handlers/ChangeBanner.cs index dd283a9..22e6c9b 100644 --- a/backend/src/Web/Features/Contents/Handlers/ChangeBanner.cs +++ b/backend/src/Web/Features/Contents/Handlers/ChangeBanner.cs @@ -31,7 +31,6 @@ public class ChangeBannerHandler( { var creator = await context .Creators - .Include(c => c.Images) .SingleOrDefaultAsync( c => c.Id == request.CreatorId, cancellationToken: ct); @@ -49,7 +48,7 @@ public class ChangeBannerHandler( request.File.ContentType, ct); - creator.Images.Banner = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + creator.BannerUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; await context.SaveChangesAsync(ct); diff --git a/backend/src/Web/Features/Contents/Handlers/ChangeLogo.cs b/backend/src/Web/Features/Contents/Handlers/ChangeLogo.cs index 917ab0a..d45fd29 100644 --- a/backend/src/Web/Features/Contents/Handlers/ChangeLogo.cs +++ b/backend/src/Web/Features/Contents/Handlers/ChangeLogo.cs @@ -46,7 +46,6 @@ public class ChangeLogoHandler( { var creator = await context .Creators - .Include(c => c.Images) .SingleOrDefaultAsync( c => c.Id == request.CreatorId, cancellationToken: ct); @@ -57,7 +56,6 @@ public class ChangeLogoHandler( return; } - // TODO: this upload should be done to the Creators container var blobUrl = await blobStorage.UploadFileAsync( ContainerNames.Creators, $"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}", @@ -65,7 +63,7 @@ public class ChangeLogoHandler( request.File.ContentType, ct); - creator.Images.Logo = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + creator.PortraitUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; await context.SaveChangesAsync(ct); diff --git a/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs b/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs index 8573bf7..4df5042 100644 --- a/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs +++ b/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs @@ -6,28 +6,10 @@ namespace Hutopy.Web.Features.Contents.Handlers; [PublicAPI] public record ChangePresentationInfosRequest( Guid CreatorId, - string? PhoneNumber, - string? Email, - string? Title, - string? MainImageText, - string? MainVideoText, - string? ImagesSubtitle, - string? ImagesText, - string? VideoSubtitle, - string? VideoSubtitleMain, - string? VideoUrlMain, + string Description, string? VideoUrl, - string? VideoText, - string? MainImageUrl, - string? Image1Url, - string? Image2Url, - string? Image3Url, - string? Image4Url, - IFormFile? MainImage, - IFormFile? Image1, - IFormFile? Image2, - IFormFile? Image3, - IFormFile? Image4); + string? PhoneNumber, + string? Email); [PublicAPI] public class ChangePresentationInfosHandler( @@ -39,7 +21,6 @@ public class ChangePresentationInfosHandler( { Post("/api/creators/{CreatorId}/presentation-infos"); Options(o => o.WithTags("Creators")); - AllowFileUploads(); } public override async Task HandleAsync( @@ -48,7 +29,7 @@ public class ChangePresentationInfosHandler( { var creator = await context .Creators - .Include(c => c.PresentationInfos) + .Include(c => c.Presentation) .SingleOrDefaultAsync( c => c.Id == request.CreatorId, cancellationToken: ct); @@ -59,56 +40,12 @@ public class ChangePresentationInfosHandler( return; } - async Task UploadFileOrDefaultAsync( - IFormFile? file, - string subDirectory, - string fileName, - string? newUrl) - { - if (newUrl == "") - return ""; - - if (file != null) - { - return await blobStorage.UploadFileAsync( - ContainerNames.Creators, - $"{request.CreatorId}/{subDirectory}/{fileName}", - file.OpenReadStream(), - file.ContentType, - ct); - } - - return newUrl?.Trim() ?? ""; - } - - creator.PresentationInfos.MainImageUrl = await UploadFileOrDefaultAsync( - request.MainImage, "Profile", "MainImage", request.MainImageUrl); - - creator.PresentationInfos.Image1Url = await UploadFileOrDefaultAsync( - request.Image1, "Profile", "Image1", request.Image1Url); - - creator.PresentationInfos.Image2Url = await UploadFileOrDefaultAsync( - request.Image2, "Profile", "Image2", request.Image2Url); - - creator.PresentationInfos.Image3Url = await UploadFileOrDefaultAsync( - request.Image3, "Profile", "Image3", request.Image3Url); - - creator.PresentationInfos.Image4Url = await UploadFileOrDefaultAsync( - request.Image4, "Profile", "Image4", request.Image4Url); - - creator.PresentationInfos.PhoneNumber = request.PhoneNumber?.Trim() ?? ""; - creator.PresentationInfos.Email = request.Email?.Trim() ?? ""; - creator.PresentationInfos.Title = request.Title?.Trim() ?? ""; - creator.PresentationInfos.MainImageText = request.MainImageText?.Trim() ?? ""; - creator.PresentationInfos.MainVideoText = request.MainVideoText?.Trim() ?? ""; - creator.PresentationInfos.ImagesSubtitle = request.ImagesSubtitle?.Trim() ?? ""; - creator.PresentationInfos.ImagesText = request.ImagesText?.Trim() ?? ""; - creator.PresentationInfos.VideoSubtitle = request.VideoSubtitle?.Trim() ?? ""; - creator.PresentationInfos.VideoSubtitleMain = request.VideoSubtitleMain?.Trim() ?? ""; - creator.PresentationInfos.VideoUrlMain = request.VideoUrlMain?.Trim() ?? ""; - creator.PresentationInfos.VideoUrl = request.VideoUrl?.Trim() ?? ""; - creator.PresentationInfos.VideoText = request.VideoText?.Trim() ?? ""; - + // Update the presentation info with the new values + creator.Presentation.Description = request.Description.Trim(); + creator.Presentation.VideoUrl = request.VideoUrl?.Trim(); + creator.Presentation.PhoneNumber = request.PhoneNumber?.Trim(); + creator.Presentation.Email = request.Email?.Trim(); + await context.SaveChangesAsync(ct); await SendOkAsync(ct); } diff --git a/backend/src/Web/Features/Contents/Handlers/CreateAlbum.cs b/backend/src/Web/Features/Contents/Handlers/CreateAlbum.cs new file mode 100644 index 0000000..50b9f9b --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/CreateAlbum.cs @@ -0,0 +1,75 @@ +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record CreateAlbumRequest( + Guid AlbumId, + string Title, + string? Description = null); + +[PublicAPI] +public record CreateAlbumResponse( + Guid AlbumId); + +[PublicAPI] +public sealed class CreateAlbumRequestValidator : Validator +{ + public CreateAlbumRequestValidator() + { + RuleFor(x => x.AlbumId) + .NotNull() + .NotEmpty(); + + RuleFor(x => x.Title) + .NotNull() + .NotEmpty() + .MaximumLength(255); + + RuleFor(x => x.Description) + .MaximumLength(1000); + } +} + +[PublicAPI] +public class CreateAlbumHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/albums"); + Options(o => o.WithTags("Albums")); + } + + public override async Task HandleAsync( + CreateAlbumRequest request, + CancellationToken ct) + { + // Check if an album with the same ID already exists + var existingAlbum = await context + .Albums + .AnyAsync(a => a.Id == request.AlbumId, ct); + + if (existingAlbum) + { + await SendErrorsAsync(409, ct); + return; + } + + var album = new Album + { + Id = request.AlbumId, + CreatedBy = User.GetUserId(), + Title = request.Title + }; + + context.Albums.Add(album); + await context.SaveChangesAsync(ct); + + await SendOkAsync( + new CreateAlbumResponse(album.Id), + ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs b/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs index 7f97f5e..3ada19c 100644 --- a/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs +++ b/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs @@ -68,7 +68,7 @@ public sealed class PostContentHtml( Id = c.Id, CreatedBy = c.CreatedBy, CreatedByName = c.Creator.Name, - CreatedByPortraitUrl = c.Creator.Images.Logo, + CreatedByPortraitUrl = c.Creator.PortraitUrl, CreatedAt = c.CreatedAt, DeletedBy = c.DeletedBy, DeletedAt = c.DeletedAt, diff --git a/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs b/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs index 285ee1c..05ae23d 100644 --- a/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs +++ b/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs @@ -8,7 +8,7 @@ public record CreateCreatorRequest( Guid SlugReservationId, Guid CreatorId); -[UsedImplicitly] +[PublicAPI] public sealed class CreateCreatorRequestValidator : Validator { public CreateCreatorRequestValidator() @@ -64,7 +64,7 @@ public sealed class CreateCreatorHandler( Id = req.CreatorId, CreatedBy = User.GetUserId(), Name = slug.Name, - Slug = slug.NormalizedName, + Slug = slug.NormalizedName }, ct); diff --git a/backend/src/Web/Features/Contents/Handlers/GetAlbum.cs b/backend/src/Web/Features/Contents/Handlers/GetAlbum.cs new file mode 100644 index 0000000..4bb07e9 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/GetAlbum.cs @@ -0,0 +1,86 @@ +using FastEndpoints; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record GetAlbumRequest( + Guid AlbumId); + +[PublicAPI] +public record AlbumPhotoDto( + Guid Id, + string PhotoUrl, + string? Caption, + int Order, + DateTimeOffset CreatedAt); + +[PublicAPI] +public record GetAlbumResponse( + Guid Id, + string Title, + IReadOnlyList Photos, + DateTimeOffset CreatedAt); + +[PublicAPI] +public sealed class GetAlbumRequestValidator : Validator +{ + public GetAlbumRequestValidator() + { + RuleFor(x => x.AlbumId) + .NotNull() + .NotEmpty(); + } +} + +[PublicAPI] +public class GetAlbumHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Get("/api/albums/{AlbumId}"); + Options(o => o.WithTags("Albums")); + } + + public override async Task HandleAsync( + GetAlbumRequest request, + CancellationToken ct) + { + var userId = User.GetUserId(); + + var album = await context + .Albums + .Include(a => a.Photos.OrderBy(p => p.Order)) + .SingleOrDefaultAsync( + a => a.Id == request.AlbumId && a.CreatedBy == userId, + cancellationToken: ct); + + if (album is null) + { + await SendNotFoundAsync(ct); + return; + } + + var photos = album.Photos + .Select(p => new AlbumPhotoDto( + p.Id, + p.PhotoUrl, + p.Caption, + p.Order, + p.CreatedAt)) + .ToList(); + + await SendOkAsync( + new GetAlbumResponse( + album.Id, + album.Title, + photos, + album.CreatedAt), + ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/GetContent.cs b/backend/src/Web/Features/Contents/Handlers/GetContent.cs index 705d916..07ec5fb 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetContent.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetContent.cs @@ -32,7 +32,7 @@ public class GetContent( Id = c.Id, CreatedBy = c.CreatedBy, CreatedByName = c.Creator.Name, - CreatedByPortraitUrl = c.Creator.Images.Logo, + CreatedByPortraitUrl = c.Creator.PortraitUrl, CreatedAt = c.CreatedAt, DeletedBy = c.DeletedBy, DeletedAt = c.DeletedAt, diff --git a/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs b/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs index ab6051a..1f3e3db 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs @@ -43,7 +43,7 @@ public class GetContentsByCreatorHandler( Id = c.Id, CreatedBy = c.CreatedBy, CreatedByName = c.Creator.Name, - CreatedByPortraitUrl = c.Creator.Images.Logo, + CreatedByPortraitUrl = c.Creator.PortraitUrl, CreatedAt = c.CreatedAt, DeletedBy = c.DeletedBy, DeletedAt = c.DeletedAt, diff --git a/backend/src/Web/Features/Contents/Handlers/GetCreatorBySlug.cs b/backend/src/Web/Features/Contents/Handlers/GetCreatorBySlug.cs index 2e7b990..ba6f087 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetCreatorBySlug.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetCreatorBySlug.cs @@ -10,36 +10,23 @@ public sealed class GetCreatorBySlugRequest } [PublicAPI] -public class GetCreatorBySlugResponse( - Guid id, - Guid createdBy, - DateTimeOffset createdAt, - Guid? deletedBy, - DateTimeOffset? deletedAt, - bool isDeleted, - bool verified, - bool acceptDonation, - string name, - string slug, - string? title, - Socials socials, - PresentationInfos presentationInfos, - Images images) +public record GetCreatorBySlugResponse { - public Guid Id { get; } = id; - public Guid CreatedBy { get; } = createdBy; - public DateTimeOffset CreatedAt { get; } = createdAt; - public Guid? DeletedBy { get; } = deletedBy; - public DateTimeOffset? DeletedAt { get; } = deletedAt; - public bool IsDeleted { get; } = isDeleted; - public bool Verified { get; } = verified; - public bool AcceptDonation { get; } = acceptDonation; - public string Name { get; } = name; - public string Slug { get; } = slug; - public string? Title { get; } = title; - public Socials Socials { get; } = socials; - public PresentationInfos PresentationInfos { get; } = presentationInfos; - public Images Images { get; } = images; + public Guid Id { get; init; } + public Guid CreatedBy { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public Guid? DeletedBy { get; init; } + public DateTimeOffset? DeletedAt { get; init; } + public bool IsDeleted { get; init; } + public bool Verified { get; init; } + public bool AcceptDonation { get; init; } + public string? BannerUrl { get; init; } + public string? PortraitUrl { get; init; } + public string Slug { get; init; } + public string Name { get; init; } + public string? Title { get; init; } + public Socials Socials { get; init; } + public Presentation Presentation { get; init; } } [UsedImplicitly] @@ -72,45 +59,47 @@ public class GetCreatorBySlugHandler( { var creatorName = req.Name.ToLower(); - var creator = await context + var response = await context .Creators .IgnoreQueryFilters() .Where(c => EF.Functions.ILike(c.Slug, creatorName)) .AsNoTracking() .Select(c => new GetCreatorBySlugResponse - ( - c.Id, - c.CreatedBy, - c.CreatedAt, - c.DeletedBy, - c.DeletedAt, - c.IsDeleted, - c.Verified, - c.AcceptDonation, - c.Name, - c.Slug, - c.Title, - c.Socials, - c.PresentationInfos, - c.Images)) + { + Id = c.Id, + CreatedBy = c.CreatedBy, + CreatedAt = c.CreatedAt, + DeletedBy = c.DeletedBy, + DeletedAt = c.DeletedAt, + IsDeleted = c.IsDeleted, + Verified = c.Verified, + AcceptDonation = c.AcceptDonation, + BannerUrl = c.BannerUrl, + PortraitUrl = c.PortraitUrl, + Slug = c.Slug, + Name = c.Name, + Title = c.Title, + Socials = c.Socials, + Presentation = c.Presentation + }) .SingleOrDefaultAsync(ct); - if (creator is null) + if (response is null) { await SendNotFoundAsync(ct); return; } bool isOwner = User.Identity?.IsAuthenticated == true - && User.GetUserId() == creator.CreatedBy; + && User.GetUserId() == response.CreatedBy; - if (creator.IsDeleted && !isOwner) + if (response.IsDeleted && !isOwner) { await SendNotFoundAsync(ct); } else { - await SendAsync(creator, cancellation: ct); + await SendAsync(response, cancellation: ct); } } } diff --git a/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs b/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs index 7b62264..fcb61e3 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs @@ -17,8 +17,7 @@ public sealed class GetCreatorProfileResponse public string? Title { get; set; } public bool Verified { get; set; } public bool AcceptDonation { get; set; } - public required Images Images { get; set; } - public required PresentationInfos PresentationInfos { get; set; } + public required Presentation Presentation { get; set; } public required Socials Socials { get; set; } } @@ -54,8 +53,7 @@ public class GetCreatorProfileHandler( Title = c.Title, Verified = c.Verified, AcceptDonation = c.AcceptDonation, - Images = c.Images, - PresentationInfos = c.PresentationInfos, + Presentation = c.Presentation, Socials = c.Socials, }) .SingleOrDefaultAsync(ct); diff --git a/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs b/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs index 30cfc70..8d3ffb6 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs @@ -42,7 +42,7 @@ public class GetFeaturedContentsHandler( Id = c.Id, CreatedBy = c.CreatedBy, CreatedByName = c.Creator.Name, - CreatedByPortraitUrl = c.Creator.Images.Logo, + CreatedByPortraitUrl = c.Creator.PortraitUrl, CreatedAt = c.CreatedAt, DeletedBy = c.DeletedBy, DeletedAt = c.DeletedAt, diff --git a/backend/src/Web/Features/Contents/Handlers/RemoveAlbum.cs b/backend/src/Web/Features/Contents/Handlers/RemoveAlbum.cs new file mode 100644 index 0000000..d635642 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/RemoveAlbum.cs @@ -0,0 +1,69 @@ +using FastEndpoints; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record RemoveAlbumRequest( + Guid AlbumId); + +[PublicAPI] +public sealed class RemoveAlbumRequestValidator : Validator +{ + public RemoveAlbumRequestValidator() + { + RuleFor(x => x.AlbumId) + .NotNull() + .NotEmpty(); + } +} + +[PublicAPI] +public class RemoveAlbumHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Delete("/api/albums/{AlbumId}"); + Options(o => o.WithTags("Albums")); + } + + public override async Task HandleAsync( + RemoveAlbumRequest request, + CancellationToken ct) + { + var userId = User.GetUserId(); + + var album = await context + .Albums + .Include(a => a.Photos) + .SingleOrDefaultAsync( + a => a.Id == request.AlbumId && a.CreatedBy == userId, + cancellationToken: ct); + + if (album is null) + { + await SendNotFoundAsync(ct); + return; + } + + // Soft delete the album + album.DeletedBy = userId; + album.DeletedAt = DateTimeOffset.UtcNow; + + // Soft delete all photos in the album + foreach (var photo in album.Photos) + { + photo.DeletedBy = userId; + photo.DeletedAt = DateTimeOffset.UtcNow; + } + + await context.SaveChangesAsync(ct); + + await SendNoContentAsync(ct); + } +} \ No newline at end of file diff --git a/backend/src/Web/Features/Contents/Handlers/RemovePhotoFromAlbum.cs b/backend/src/Web/Features/Contents/Handlers/RemovePhotoFromAlbum.cs new file mode 100644 index 0000000..717bce5 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/RemovePhotoFromAlbum.cs @@ -0,0 +1,76 @@ +using FastEndpoints; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record RemovePhotoFromAlbumRequest( + Guid AlbumId, + Guid PhotoId); + +[PublicAPI] +public sealed class RemovePhotoFromAlbumRequestValidator : Validator +{ + public RemovePhotoFromAlbumRequestValidator() + { + RuleFor(x => x.AlbumId) + .NotNull() + .NotEmpty(); + + RuleFor(x => x.PhotoId) + .NotNull() + .NotEmpty(); + } +} + +[PublicAPI] +public class RemovePhotoFromAlbumHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Delete("/api/albums/{AlbumId}/photos/{PhotoId}"); + Options(o => o.WithTags("Albums")); + } + + public override async Task HandleAsync( + RemovePhotoFromAlbumRequest request, + CancellationToken ct) + { + var userId = User.GetUserId(); + + var album = await context + .Albums + .Include(a => a.Photos) + .SingleOrDefaultAsync( + a => a.Id == request.AlbumId && a.CreatedBy == userId, + cancellationToken: ct); + + if (album is null) + { + await SendNotFoundAsync(ct); + return; + } + + var photo = album.Photos + .SingleOrDefault(p => p.Id == request.PhotoId); + + if (photo is null) + { + await SendNotFoundAsync(ct); + return; + } + + // Soft delete the photo + photo.DeletedBy = userId; + photo.DeletedAt = DateTimeOffset.UtcNow; + + await context.SaveChangesAsync(ct); + + await SendNoContentAsync(ct); + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3f917b6..262cedd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -43,10 +43,7 @@ watch(() => languageStore.locale, (newLocale) => { } .shell-side { - @apply border border-[#3d3d3d]; - @apply lg:max-h-screen; - @apply lg:fixed; - @apply border-b lg:border-b-0 lg:border-r; + @apply lg:fixed lg:max-h-screen; @apply flex-shrink-0; } diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index d392764..67ed5e3 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -57,15 +57,23 @@ @apply bg-hSurface text-hOnSurface; } + /* Specific styling for dialog cards */ + div.card.dialog { + @apply bg-hSurface text-hOnSurface; + @apply rounded-xl; + @apply shadow-lg; + } + div.card-title { @apply font-sans font-bold text-2xl; @apply p-2; + @apply text-hOnSurface; } div.card-content { @apply flex flex-col gap-4; @apply p-2; - @apply overflow-y-auto; + @apply text-hOnSurface; } div.card-actions { diff --git a/frontend/src/views/creators/AboutCreator.vue b/frontend/src/views/creators/AboutCreator.vue index f202dd7..bc3ae6a 100644 --- a/frontend/src/views/creators/AboutCreator.vue +++ b/frontend/src/views/creators/AboutCreator.vue @@ -1,8 +1,8 @@ { "en": { - "common": { - "save": "Save", - "edit": "Edit", - "cancel": "Cancel", - "delete": "Delete" - }, + "edit": "Edit", + "save": "Save", + "cancel": "Cancel", "creator": { "sections": { "about": { "title": "About", - "description": "Description", - "mainImage": "Main image", - "image1": "Image 1", - "image2": "Image 2", - "image3": "Image 3", - "image4": "Image 4", - "images": "Images" + "description": "Description" }, - "support": { - "title": "Support", - "description": "Description", - "subtitle": "Subtitle" + "photos": { + "title": "Photos", + "image": "Image" } }, "fields": { - "videoUrl": "Video URL", - "phoneNumber": "Phone Number", - "email": "Email" + "videoUrl": "Video URL" } } }, "fr": { - "common": { - "save": "Enregistrer", - "edit": "Modifier", - "cancel": "Annuler", - "delete": "Supprimer" - }, + "edit": "Modifier", + "save": "Enregistrer", + "cancel": "Annuler", "creator": { "sections": { "about": { "title": "À propos", - "description": "Description", - "mainImage": "Image principale", - "image1": "Image 1", - "image2": "Image 2", - "image3": "Image 3", - "image4": "Image 4", - "images": "Images" + "description": "Description" }, - "support": { - "title": "Support", - "description": "Description", - "subtitle": "Sous-titre" + "photos": { + "title": "Photos", + "image": "Image" } }, "fields": { - "videoUrl": "URL de la vidéo", - "phoneNumber": "Numéro de téléphone", - "email": "Email" + "videoUrl": "URL de la vidéo" } } }, "es": { - "common": { - "save": "Guardar", - "edit": "Editar", - "cancel": "Cancelar", - "delete": "Eliminar" - }, + "edit": "Editar", + "save": "Guardar", + "cancel": "Cancelar", "creator": { "sections": { "about": { "title": "Acerca de", - "description": "Descripción", - "mainImage": "Imagen principal", - "image1": "Imagen 1", - "image2": "Imagen 2", - "image3": "Imagen 3", - "image4": "Imagen 4", - "images": "Imágenes" + "description": "Descripción" }, - "support": { - "title": "Soporte", - "description": "Descripción", - "subtitle": "Subtítulo" + "photos": { + "title": "Fotos", + "image": "Imagen" } }, "fields": { - "videoUrl": "URL del video", - "phoneNumber": "Número de teléfono", - "email": "Correo electrónico" + "videoUrl": "URL del video" } } } diff --git a/frontend/src/views/creators/ActualBanner.vue b/frontend/src/views/creators/ActualBanner.vue index 959b3da..fa2b2d1 100644 --- a/frontend/src/views/creators/ActualBanner.vue +++ b/frontend/src/views/creators/ActualBanner.vue @@ -9,7 +9,7 @@ > diff --git a/frontend/src/views/creators/AlbumEditor.vue b/frontend/src/views/creators/AlbumEditor.vue new file mode 100644 index 0000000..8c9eded --- /dev/null +++ b/frontend/src/views/creators/AlbumEditor.vue @@ -0,0 +1,262 @@ + + + + + + + +{ + "en": { + "upload": "Upload Photos", + "delete": "Delete", + "moveUp": "Move Up", + "moveDown": "Move Down" + }, + "fr": { + "upload": "Télécharger des photos", + "delete": "Supprimer", + "moveUp": "Déplacer vers le haut", + "moveDown": "Déplacer vers le bas" + }, + "es": { + "upload": "Subir fotos", + "delete": "Eliminar", + "moveUp": "Mover arriba", + "moveDown": "Mover abajo" + } +} + \ No newline at end of file diff --git a/frontend/src/views/creators/AlbumView.vue b/frontend/src/views/creators/AlbumView.vue new file mode 100644 index 0000000..2eb4ad0 --- /dev/null +++ b/frontend/src/views/creators/AlbumView.vue @@ -0,0 +1,144 @@ + + + + + + + +{ + "en": { + "creator": { + "sections": { + "album": { + "title": "Photo Album", + "image": "Album image" + } + } + } + }, + "fr": { + "creator": { + "sections": { + "album": { + "title": "Album photo", + "image": "Image de l'album" + } + } + } + }, + "es": { + "creator": { + "sections": { + "album": { + "title": "Álbum de fotos", + "image": "Imagen del álbum" + } + } + } + } +} + \ No newline at end of file diff --git a/frontend/src/views/creators/BannerEditor.vue b/frontend/src/views/creators/BannerEditor.vue index e7ac610..96f9d30 100644 --- a/frontend/src/views/creators/BannerEditor.vue +++ b/frontend/src/views/creators/BannerEditor.vue @@ -91,7 +91,7 @@ const emits = defineEmits(['closeRequested']) const fileInput = ref(null) const selectedFile = ref(null) -const fileUrl = ref(props.creator?.images?.banner) +const fileUrl = ref(props.creator?.bannerUrl) const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png' const errorMessage = ref('') const showCropper = ref(false) @@ -175,7 +175,8 @@ const publish = async () => { } ) - props.creator.images.banner = `${response.data.blobUrl}?t=${Date.now()}` + props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}` + fileUrl.value = props.creator.bannerUrl emits('closeRequested') } catch (error) { console.error(error) @@ -189,8 +190,8 @@ const publish = async () => { const cancel = () => { showCropper.value = false // Reset to original state if we were editing - if (props.creator?.images?.banner) { - fileUrl.value = props.creator.images.banner + if (props.creator?.bannerUrl) { + fileUrl.value = props.creator.bannerUrl selectedFile.value = null } else { fileUrl.value = fallbackUrl diff --git a/frontend/src/views/creators/CreatorAlbum.vue b/frontend/src/views/creators/CreatorAlbum.vue new file mode 100644 index 0000000..1449192 --- /dev/null +++ b/frontend/src/views/creators/CreatorAlbum.vue @@ -0,0 +1,102 @@ + + + + + + + +{ + "en": { + "common": { + "delete": "Delete" + }, + "creator": { + "sections": { + "album": { + "title": "Photo Album", + "image": "Album image" + } + } + } + }, + "fr": { + "common": { + "delete": "Supprimer" + }, + "creator": { + "sections": { + "album": { + "title": "Album photo", + "image": "Image de l'album" + } + } + } + }, + "es": { + "common": { + "delete": "Eliminar" + }, + "creator": { + "sections": { + "album": { + "title": "Álbum de fotos", + "image": "Imagen del álbum" + } + } + } + } +} + \ No newline at end of file diff --git a/frontend/src/views/creators/CreatorHome.vue b/frontend/src/views/creators/CreatorHome.vue index f68311c..bc58965 100644 --- a/frontend/src/views/creators/CreatorHome.vue +++ b/frontend/src/views/creators/CreatorHome.vue @@ -1,23 +1,22 @@