Adds PhotoAlbum, CreatorHome, AboutCreator.

This commit is contained in:
2025-04-23 17:45:09 -04:00
parent 247b2b023c
commit 6d3525c2ee
42 changed files with 3176 additions and 818 deletions

View File

@@ -4,4 +4,5 @@ public static class SubDirectoryNames
{ {
public static string Profile = "profile"; public static string Profile = "profile";
public static string Contents = "contents"; public static string Contents = "contents";
public static string Albums = "albums";
} }

View File

@@ -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<AlbumPhoto> Photos { get; set; } = new List<AlbumPhoto>();
}
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; }
}

View File

@@ -9,6 +9,8 @@ public class ContentDbContext(
public DbSet<Content> Contents => Set<Content>(); public DbSet<Content> Contents => Set<Content>();
public DbSet<Creator> Creators => Set<Creator>(); public DbSet<Creator> Creators => Set<Creator>();
public DbSet<Slugs> Slugs => Set<Slugs>(); public DbSet<Slugs> Slugs => Set<Slugs>();
public DbSet<Album> Albums => Set<Album>();
public DbSet<AlbumPhoto> AlbumPhotos => Set<AlbumPhoto>();
protected override void OnModelCreating( protected override void OnModelCreating(
ModelBuilder modelBuilder) ModelBuilder modelBuilder)
@@ -62,16 +64,50 @@ public class ContentDbContext(
modelBuilder modelBuilder
.Entity<Creator>() .Entity<Creator>()
.OwnsOne<Images>(x => x.Images) .OwnsOne<Presentation>(x => x.Presentation)
.ToTable(nameof(Images)); .ToTable(nameof(Presentation));
modelBuilder
.Entity<Creator>()
.OwnsOne<PresentationInfos>(x => x.PresentationInfos)
.ToTable(nameof(PresentationInfos));
modelBuilder modelBuilder
.Entity<Creator>() .Entity<Creator>()
.HasQueryFilter(c => !c.IsDeleted); .HasQueryFilter(c => !c.IsDeleted);
// Album configuration
modelBuilder
.Entity<Album>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Album>()
.Property(c => c.IsDeleted)
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", stored: true);
modelBuilder
.Entity<Album>()
.HasQueryFilter(a => !a.IsDeleted);
// AlbumPhoto configuration
modelBuilder
.Entity<AlbumPhoto>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<AlbumPhoto>()
.Property(c => c.IsDeleted)
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", stored: true);
modelBuilder
.Entity<AlbumPhoto>()
.HasOne(ap => ap.Album)
.WithMany(a => a.Photos)
.HasForeignKey(ap => ap.AlbumId)
.IsRequired();
modelBuilder
.Entity<AlbumPhoto>()
.HasQueryFilter(ap => !ap.IsDeleted);
} }
} }

View File

@@ -16,14 +16,16 @@ public class Creator
/// </summary> /// </summary>
public bool IsDeleted { get; private set; } // private set → EF updates it 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; } public bool Verified { get; set; }
[MaxLength(255)] public string Name { get; set; } [MaxLength(255)] public string Name { get; set; }
[MaxLength(128)] public string Slug { get; set; } [MaxLength(128)] public string Slug { get; set; }
[MaxLength(255)] public string? Title { get; set; } [MaxLength(255)] public string? Title { get; set; }
public bool AcceptDonation { get; set; }
public Socials Socials { get; set; } = new(); public Socials Socials { get; set; } = new();
public Images Images { get; set; } = new(); public Presentation Presentation { get; set; } = new();
public PresentationInfos PresentationInfos { get; set; } = new();
} }
public class Socials public class Socials
@@ -38,29 +40,10 @@ public class Socials
[MaxLength(2048)] public string? WebsiteUrl { get; set; } [MaxLength(2048)] public string? WebsiteUrl { get; set; }
} }
public class Images public class Presentation
{ {
[MaxLength(2048)] public string? Banner { get; set; } [MaxLength(2000)] public string Description { get; set; } = null!;
[MaxLength(2048)] public string? Logo { get; set; } [MaxLength(2048)] public string? VideoUrl { get; set; }
} [MaxLength(255)] public string? PhoneNumber { get; set; }
[MaxLength(255)] public string? Email { 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;
} }

View File

@@ -0,0 +1,301 @@
// <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("20250423153323_AddPresentation")]
partial class AddPresentation
{
/// <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.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.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()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
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();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,137 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddPresentation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Images",
schema: "Content");
migrationBuilder.DropTable(
name: "PresentationInfos",
schema: "Content");
migrationBuilder.AddColumn<string>(
name: "BannerUrl",
schema: "Content",
table: "Creators",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
migrationBuilder.AddColumn<string>(
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<Guid>(type: "uuid", nullable: false),
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
VideoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
PhoneNumber = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
Email = table.Column<string>(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);
});
}
/// <inheritdoc />
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<Guid>(type: "uuid", nullable: false),
Banner = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
Logo = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
Email = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Image1Url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
Image2Url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
Image3Url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
Image4Url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
ImagesSubtitle = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
ImagesText = table.Column<string>(type: "character varying(10000)", maxLength: 10000, nullable: false),
MainImageText = table.Column<string>(type: "character varying(10000)", maxLength: 10000, nullable: false),
MainImageUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
MainVideoText = table.Column<string>(type: "character varying(10000)", maxLength: 10000, nullable: false),
PhoneNumber = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Title = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
VideoSubtitle = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
VideoSubtitleMain = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
VideoText = table.Column<string>(type: "character varying(10000)", maxLength: 10000, nullable: false),
VideoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
VideoUrlMain = table.Column<string>(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);
});
}
}
}

View File

@@ -0,0 +1,407 @@
// <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("20250423173651_AddAlbumAndPhotos")]
partial class AddAlbumAndPhotos
{
/// <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<string>("CoverPhotoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
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<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
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>("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<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()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("PhoneNumber")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.HasKey("CreatorId");
b1.ToTable("Presentation", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("RedditUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("XUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Presentation")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Album", b =>
{
b.Navigation("Photos");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddAlbumAndPhotos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Albums",
schema: "Content",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
CoverPhotoUrl = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
AlbumId = table.Column<Guid>(type: "uuid", nullable: false),
PhotoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
Caption = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
Order = table.Column<int>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AlbumPhotos",
schema: "Content");
migrationBuilder.DropTable(
name: "Albums",
schema: "Content");
}
}
}

View File

@@ -0,0 +1,399 @@
// <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("20250423180519_SimplifyAlbums")]
partial class SimplifyAlbums
{
/// <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>("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<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()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("PhoneNumber")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.HasKey("CreatorId");
b1.ToTable("Presentation", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("RedditUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("XUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Presentation")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Album", b =>
{
b.Navigation("Photos");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class SimplifyAlbums : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CoverPhotoUrl",
schema: "Content",
table: "Albums");
migrationBuilder.DropColumn(
name: "Description",
schema: "Content",
table: "Albums");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CoverPhotoUrl",
schema: "Content",
table: "Albums",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Description",
schema: "Content",
table: "Albums",
type: "character varying(1000)",
maxLength: 1000,
nullable: true);
}
}
}

View File

@@ -23,6 +23,88 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 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>("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 => modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -83,6 +165,10 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b.Property<bool>("AcceptDonation") b.Property<bool>("AcceptDonation")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("BannerUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -105,6 +191,10 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("character varying(255)"); .HasColumnType("character varying(255)");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Slug") b.Property<string>("Slug")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
@@ -160,6 +250,17 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b.ToTable("Slugs", "Content"); 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 => modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{ {
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator") 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 => 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<Guid>("CreatorId") b1.Property<Guid>("CreatorId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b1.Property<string>("Banner") b1.Property<string>("Description")
.HasMaxLength(2048) .IsRequired()
.HasColumnType("character varying(2048)"); .HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email") b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("character varying(255)"); .HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber") b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("character varying(255)"); .HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl") b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b1.HasKey("CreatorId"); b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "Content"); b1.ToTable("Presentation", "Content");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("CreatorId"); .HasForeignKey("CreatorId");
@@ -367,15 +379,17 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.HasForeignKey("CreatorId"); .HasForeignKey("CreatorId");
}); });
b.Navigation("Images") b.Navigation("Presentation")
.IsRequired();
b.Navigation("PresentationInfos")
.IsRequired(); .IsRequired();
b.Navigation("Socials") b.Navigation("Socials")
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Album", b =>
{
b.Navigation("Photos");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

@@ -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<AddPhotoToAlbumRequest>
{
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<AddPhotoToAlbumRequest, AddPhotoToAlbumResponse>
{
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);
}
}

View File

@@ -31,7 +31,6 @@ public class ChangeBannerHandler(
{ {
var creator = await context var creator = await context
.Creators .Creators
.Include(c => c.Images)
.SingleOrDefaultAsync( .SingleOrDefaultAsync(
c => c.Id == request.CreatorId, c => c.Id == request.CreatorId,
cancellationToken: ct); cancellationToken: ct);
@@ -49,7 +48,7 @@ public class ChangeBannerHandler(
request.File.ContentType, request.File.ContentType,
ct); ct);
creator.Images.Banner = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; creator.BannerUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
await context.SaveChangesAsync(ct); await context.SaveChangesAsync(ct);

View File

@@ -46,7 +46,6 @@ public class ChangeLogoHandler(
{ {
var creator = await context var creator = await context
.Creators .Creators
.Include(c => c.Images)
.SingleOrDefaultAsync( .SingleOrDefaultAsync(
c => c.Id == request.CreatorId, c => c.Id == request.CreatorId,
cancellationToken: ct); cancellationToken: ct);
@@ -57,7 +56,6 @@ public class ChangeLogoHandler(
return; return;
} }
// TODO: this upload should be done to the Creators container
var blobUrl = await blobStorage.UploadFileAsync( var blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Creators, ContainerNames.Creators,
$"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}", $"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
@@ -65,7 +63,7 @@ public class ChangeLogoHandler(
request.File.ContentType, request.File.ContentType,
ct); ct);
creator.Images.Logo = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; creator.PortraitUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
await context.SaveChangesAsync(ct); await context.SaveChangesAsync(ct);

View File

@@ -6,28 +6,10 @@ namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI] [PublicAPI]
public record ChangePresentationInfosRequest( public record ChangePresentationInfosRequest(
Guid CreatorId, Guid CreatorId,
string? PhoneNumber, string Description,
string? Email,
string? Title,
string? MainImageText,
string? MainVideoText,
string? ImagesSubtitle,
string? ImagesText,
string? VideoSubtitle,
string? VideoSubtitleMain,
string? VideoUrlMain,
string? VideoUrl, string? VideoUrl,
string? VideoText, string? PhoneNumber,
string? MainImageUrl, string? Email);
string? Image1Url,
string? Image2Url,
string? Image3Url,
string? Image4Url,
IFormFile? MainImage,
IFormFile? Image1,
IFormFile? Image2,
IFormFile? Image3,
IFormFile? Image4);
[PublicAPI] [PublicAPI]
public class ChangePresentationInfosHandler( public class ChangePresentationInfosHandler(
@@ -39,7 +21,6 @@ public class ChangePresentationInfosHandler(
{ {
Post("/api/creators/{CreatorId}/presentation-infos"); Post("/api/creators/{CreatorId}/presentation-infos");
Options(o => o.WithTags("Creators")); Options(o => o.WithTags("Creators"));
AllowFileUploads();
} }
public override async Task HandleAsync( public override async Task HandleAsync(
@@ -48,7 +29,7 @@ public class ChangePresentationInfosHandler(
{ {
var creator = await context var creator = await context
.Creators .Creators
.Include(c => c.PresentationInfos) .Include(c => c.Presentation)
.SingleOrDefaultAsync( .SingleOrDefaultAsync(
c => c.Id == request.CreatorId, c => c.Id == request.CreatorId,
cancellationToken: ct); cancellationToken: ct);
@@ -59,55 +40,11 @@ public class ChangePresentationInfosHandler(
return; return;
} }
async Task<string> UploadFileOrDefaultAsync( // Update the presentation info with the new values
IFormFile? file, creator.Presentation.Description = request.Description.Trim();
string subDirectory, creator.Presentation.VideoUrl = request.VideoUrl?.Trim();
string fileName, creator.Presentation.PhoneNumber = request.PhoneNumber?.Trim();
string? newUrl) creator.Presentation.Email = request.Email?.Trim();
{
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() ?? "";
await context.SaveChangesAsync(ct); await context.SaveChangesAsync(ct);
await SendOkAsync(ct); await SendOkAsync(ct);

View File

@@ -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<CreateAlbumRequest>
{
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<CreateAlbumRequest, CreateAlbumResponse>
{
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);
}
}

View File

@@ -68,7 +68,7 @@ public sealed class PostContentHtml(
Id = c.Id, Id = c.Id,
CreatedBy = c.CreatedBy, CreatedBy = c.CreatedBy,
CreatedByName = c.Creator.Name, CreatedByName = c.Creator.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo, CreatedByPortraitUrl = c.Creator.PortraitUrl,
CreatedAt = c.CreatedAt, CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy, DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt, DeletedAt = c.DeletedAt,

View File

@@ -8,7 +8,7 @@ public record CreateCreatorRequest(
Guid SlugReservationId, Guid SlugReservationId,
Guid CreatorId); Guid CreatorId);
[UsedImplicitly] [PublicAPI]
public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorRequest> public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorRequest>
{ {
public CreateCreatorRequestValidator() public CreateCreatorRequestValidator()
@@ -64,7 +64,7 @@ public sealed class CreateCreatorHandler(
Id = req.CreatorId, Id = req.CreatorId,
CreatedBy = User.GetUserId(), CreatedBy = User.GetUserId(),
Name = slug.Name, Name = slug.Name,
Slug = slug.NormalizedName, Slug = slug.NormalizedName
}, },
ct); ct);

View File

@@ -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<AlbumPhotoDto> Photos,
DateTimeOffset CreatedAt);
[PublicAPI]
public sealed class GetAlbumRequestValidator : Validator<GetAlbumRequest>
{
public GetAlbumRequestValidator()
{
RuleFor(x => x.AlbumId)
.NotNull()
.NotEmpty();
}
}
[PublicAPI]
public class GetAlbumHandler(
ContentDbContext context)
: Endpoint<GetAlbumRequest, GetAlbumResponse>
{
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);
}
}

View File

@@ -32,7 +32,7 @@ public class GetContent(
Id = c.Id, Id = c.Id,
CreatedBy = c.CreatedBy, CreatedBy = c.CreatedBy,
CreatedByName = c.Creator.Name, CreatedByName = c.Creator.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo, CreatedByPortraitUrl = c.Creator.PortraitUrl,
CreatedAt = c.CreatedAt, CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy, DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt, DeletedAt = c.DeletedAt,

View File

@@ -43,7 +43,7 @@ public class GetContentsByCreatorHandler(
Id = c.Id, Id = c.Id,
CreatedBy = c.CreatedBy, CreatedBy = c.CreatedBy,
CreatedByName = c.Creator.Name, CreatedByName = c.Creator.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo, CreatedByPortraitUrl = c.Creator.PortraitUrl,
CreatedAt = c.CreatedAt, CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy, DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt, DeletedAt = c.DeletedAt,

View File

@@ -10,36 +10,23 @@ public sealed class GetCreatorBySlugRequest
} }
[PublicAPI] [PublicAPI]
public class GetCreatorBySlugResponse( public record 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 Guid Id { get; } = id; public Guid Id { get; init; }
public Guid CreatedBy { get; } = createdBy; public Guid CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; } = createdAt; public DateTimeOffset CreatedAt { get; init; }
public Guid? DeletedBy { get; } = deletedBy; public Guid? DeletedBy { get; init; }
public DateTimeOffset? DeletedAt { get; } = deletedAt; public DateTimeOffset? DeletedAt { get; init; }
public bool IsDeleted { get; } = isDeleted; public bool IsDeleted { get; init; }
public bool Verified { get; } = verified; public bool Verified { get; init; }
public bool AcceptDonation { get; } = acceptDonation; public bool AcceptDonation { get; init; }
public string Name { get; } = name; public string? BannerUrl { get; init; }
public string Slug { get; } = slug; public string? PortraitUrl { get; init; }
public string? Title { get; } = title; public string Slug { get; init; }
public Socials Socials { get; } = socials; public string Name { get; init; }
public PresentationInfos PresentationInfos { get; } = presentationInfos; public string? Title { get; init; }
public Images Images { get; } = images; public Socials Socials { get; init; }
public Presentation Presentation { get; init; }
} }
[UsedImplicitly] [UsedImplicitly]
@@ -72,45 +59,47 @@ public class GetCreatorBySlugHandler(
{ {
var creatorName = req.Name.ToLower(); var creatorName = req.Name.ToLower();
var creator = await context var response = await context
.Creators .Creators
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(c => EF.Functions.ILike(c.Slug, creatorName)) .Where(c => EF.Functions.ILike(c.Slug, creatorName))
.AsNoTracking() .AsNoTracking()
.Select(c => new GetCreatorBySlugResponse .Select(c => new GetCreatorBySlugResponse
( {
c.Id, Id = c.Id,
c.CreatedBy, CreatedBy = c.CreatedBy,
c.CreatedAt, CreatedAt = c.CreatedAt,
c.DeletedBy, DeletedBy = c.DeletedBy,
c.DeletedAt, DeletedAt = c.DeletedAt,
c.IsDeleted, IsDeleted = c.IsDeleted,
c.Verified, Verified = c.Verified,
c.AcceptDonation, AcceptDonation = c.AcceptDonation,
c.Name, BannerUrl = c.BannerUrl,
c.Slug, PortraitUrl = c.PortraitUrl,
c.Title, Slug = c.Slug,
c.Socials, Name = c.Name,
c.PresentationInfos, Title = c.Title,
c.Images)) Socials = c.Socials,
Presentation = c.Presentation
})
.SingleOrDefaultAsync(ct); .SingleOrDefaultAsync(ct);
if (creator is null) if (response is null)
{ {
await SendNotFoundAsync(ct); await SendNotFoundAsync(ct);
return; return;
} }
bool isOwner = User.Identity?.IsAuthenticated == true 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); await SendNotFoundAsync(ct);
} }
else else
{ {
await SendAsync(creator, cancellation: ct); await SendAsync(response, cancellation: ct);
} }
} }
} }

View File

@@ -17,8 +17,7 @@ public sealed class GetCreatorProfileResponse
public string? Title { get; set; } public string? Title { get; set; }
public bool Verified { get; set; } public bool Verified { get; set; }
public bool AcceptDonation { get; set; } public bool AcceptDonation { get; set; }
public required Images Images { get; set; } public required Presentation Presentation { get; set; }
public required PresentationInfos PresentationInfos { get; set; }
public required Socials Socials { get; set; } public required Socials Socials { get; set; }
} }
@@ -54,8 +53,7 @@ public class GetCreatorProfileHandler(
Title = c.Title, Title = c.Title,
Verified = c.Verified, Verified = c.Verified,
AcceptDonation = c.AcceptDonation, AcceptDonation = c.AcceptDonation,
Images = c.Images, Presentation = c.Presentation,
PresentationInfos = c.PresentationInfos,
Socials = c.Socials, Socials = c.Socials,
}) })
.SingleOrDefaultAsync(ct); .SingleOrDefaultAsync(ct);

View File

@@ -42,7 +42,7 @@ public class GetFeaturedContentsHandler(
Id = c.Id, Id = c.Id,
CreatedBy = c.CreatedBy, CreatedBy = c.CreatedBy,
CreatedByName = c.Creator.Name, CreatedByName = c.Creator.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo, CreatedByPortraitUrl = c.Creator.PortraitUrl,
CreatedAt = c.CreatedAt, CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy, DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt, DeletedAt = c.DeletedAt,

View File

@@ -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<RemoveAlbumRequest>
{
public RemoveAlbumRequestValidator()
{
RuleFor(x => x.AlbumId)
.NotNull()
.NotEmpty();
}
}
[PublicAPI]
public class RemoveAlbumHandler(
ContentDbContext context)
: Endpoint<RemoveAlbumRequest>
{
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);
}
}

View File

@@ -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<RemovePhotoFromAlbumRequest>
{
public RemovePhotoFromAlbumRequestValidator()
{
RuleFor(x => x.AlbumId)
.NotNull()
.NotEmpty();
RuleFor(x => x.PhotoId)
.NotNull()
.NotEmpty();
}
}
[PublicAPI]
public class RemovePhotoFromAlbumHandler(
ContentDbContext context)
: Endpoint<RemovePhotoFromAlbumRequest>
{
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);
}
}

View File

@@ -43,10 +43,7 @@ watch(() => languageStore.locale, (newLocale) => {
} }
.shell-side { .shell-side {
@apply border border-[#3d3d3d]; @apply lg:fixed lg:max-h-screen;
@apply lg:max-h-screen;
@apply lg:fixed;
@apply border-b lg:border-b-0 lg:border-r;
@apply flex-shrink-0; @apply flex-shrink-0;
} }

View File

@@ -57,15 +57,23 @@
@apply bg-hSurface text-hOnSurface; @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 { div.card-title {
@apply font-sans font-bold text-2xl; @apply font-sans font-bold text-2xl;
@apply p-2; @apply p-2;
@apply text-hOnSurface;
} }
div.card-content { div.card-content {
@apply flex flex-col gap-4; @apply flex flex-col gap-4;
@apply p-2; @apply p-2;
@apply overflow-y-auto; @apply text-hOnSurface;
} }
div.card-actions { div.card-actions {

View File

@@ -12,7 +12,7 @@
v-if="!isEditMode" v-if="!isEditMode"
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('common.edit')" :title="t('edit')"
> >
<v-icon large>mdi-pencil</v-icon> <v-icon large>mdi-pencil</v-icon>
</button> </button>
@@ -22,7 +22,7 @@
v-if="isEditMode" v-if="isEditMode"
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('common.save')" :title="t('save')"
> >
<v-icon large>mdi-check</v-icon> <v-icon large>mdi-check</v-icon>
</button> </button>
@@ -32,279 +32,87 @@
v-if="isEditMode" v-if="isEditMode"
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('common.cancel')" :title="t('cancel')"
> >
<v-icon large>mdi-close</v-icon> <v-icon large>mdi-close</v-icon>
</button> </button>
</div> </div>
<!-- MainPage --> <!-- MainPage -->
<div class="flex flex-col mt-4"> <div class="flex flex-col">
<h1 class="flex justify-start text-2xl font-bold text-center">{{ t('creator.sections.about.title') }}</h1> <h1 class="flex justify-start text-2xl font-bold text-center mb-4">
{{ t('creator.sections.about.title') }}
</h1>
<div> <div>
<!-- Main image Bloc D'information--> <!-- Description Section -->
<div class="py-4">
<div v-if="!isEditMode">
<p v-if="mainImageText" class="text-lg text-justify">
{{ mainImageText }}
</p>
</div>
<v-textarea v-if="isEditMode" v-model="editableMainImageText" class="w-full p-2 py-6 " :label="t('creator.sections.about.description')"
variant="outlined"></v-textarea>
<div class="flex flex-row items-center space-x-4">
<!-- image principale-->
<div v-if="!isEditMode" class="flex justify-center items-center w-1/2">
<img
v-if="mainImageUrl"
:src="mainImageUrl"
:alt="t('creator.sections.about.mainImage')"
class="max-w-full h-auto cursor-pointer"/>
</div>
<div v-if="isEditMode" class="relative flex justify-center">
<label>
<input class="hidden" type="file" @change="updateImage('mainImageUrl', $event)"/>
<img :src="mainImageUrl || fallbackImage"
:alt="t('creator.sections.about.mainImage')"
class=" max-w-full h-auto cursor-pointer max-h-96"/>
</label>
<button v-if="isEditMode"
class="absolute top-10 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600"
@click="deleteImage('mainImageUrl')">
{{ t('common.delete') }}
</button>
</div>
<div class="w-1/2 flex flex-col justify-center">
<h2 v-if="videoSubtitleMain" class="text-xl font-semibold text-center">
{{ t('creator.sections.support.title') }}
</h2>
<div v-if="!isEditMode">
<p v-if="mainVideoText" class="text-lg text-justify">
{{ mainVideoText }}
</p>
</div>
<div v-if="isEditMode">
<v-textarea
v-model="editableMainVideoText"
class="p-2 rounded-md mt-4"
:label="t('creator.sections.support.description')"
rows="10"
variant="outlined"
></v-textarea>
</div>
</div>
</div>
<div> <div>
<div v-if="!isEditMode" class="py-5 text-lg font-bold"> <div v-if="!isEditMode">
{{ videoSubtitle }} <p v-if="description" class="text-lg text-justify mb-6">
{{ description }}
</p>
</div> </div>
<div v-if="isEditMode">
<v-text-field
v-model="editableVideoSubtitle"
class="w-full p-2"
:label="t('creator.sections.support.subtitle')"
variant="outlined"
></v-text-field>
</div>
<v-textarea v-if="isEditMode" <v-textarea v-if="isEditMode"
v-model="editableVideoText" v-model="editableDescription"
class="w-full p-2" class="w-full p-2 py-6"
:label="t('creator.sections.support.description')" :label="t('creator.sections.about.description')"
variant="outlined" variant="outlined"></v-textarea>
></v-textarea>
</div>
</div> </div>
<!-- media--> <!-- Video Section -->
<div v-if="!isEditMode"> <div v-if="videoUrl || isEditMode"
<div v-if="videoUrlMain" class="video-container"> :class="['content-section', {
'rounded-t-xl': hasImages || isEditMode,
'rounded-xl': !hasImages && !isEditMode
}]">
<div v-if="!isEditMode && videoUrl" class="video-container">
<iframe <iframe
:src="videoUrlMain" :src="youtubeEmbedUrl"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen allowfullscreen
class="video-frame" class="video-frame"
title="YouTube video player"> title="YouTube video player">
</iframe> </iframe>
</div> </div>
</div>
<div v-if="isEditMode"> <div v-if="isEditMode">
<v-text-field <v-text-field
v-model="editableVideoUrlMain" v-model="editableVideoUrl"
class="w-full p-2 rounded-md" class="w-full p-2"
:label="t('creator.fields.videoUrl')" :label="t('creator.fields.videoUrl')"
type="text" type="text"
variant="outlined" variant="outlined"
/> />
</div> </div>
<!-- Images -->
<div v-if="!isEditMode">
<div v-if="imagesSubtitle || image1Url || image2Url || image3Url || image4Url || imagesText ">
<!-- images-->
<div class="py-2">
<div>
<!-- Affichage des images -->
<div class="flex gap-2">
<!-- Première image -->
<div v-if="image1Url" class="relative w-full sm:flex-1 ">
<img :src="image1Url"
:alt="t('creator.sections.about.image1')"
class="rounded-md max-w-full h-auto cursor-pointer"/>
</div> </div>
<!-- Deuxième image --> <!-- Photos Section using CreatorAlbum component -->
<div v-if="image2Url" class="relative w-full sm:flex-1 "> <CreatorAlbum
<img :src="image2Url" v-if="hasImages || isEditMode"
:alt="t('creator.sections.about.image2')" :is-edit-mode="isEditMode"
class="rounded-md max-w-full h-auto cursor-pointer"/> :images="imageUrls"
</div> @update:images="updateImages"
@update:isEditMode="isEditMode = $event"
<!-- Troisième image --> :class="['content-section', {
<div v-if="image3Url" class="relative w-full sm:flex-1 "> 'rounded-b-xl': videoUrl || isEditMode,
<img :src="image3Url" 'rounded-xl': !videoUrl && !isEditMode
:alt="t('creator.sections.about.image3')" }]"
class="rounded-md max-w-full h-auto cursor-pointer"/> />
</div>
<!-- Quatrième image -->
<div v-if="image4Url" class="relative w-full sm:flex-1 ">
<img :src="image4Url"
:alt="t('creator.sections.about.image4')"
class="rounded-md max-w-full h-auto cursor-pointer"/>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="isEditMode" class="rounded-2xl">
<!--images-->
<div class=" text-2xl pa-2">{{ t('creator.sections.about.images') }}</div>
<div class="pa-2 grid grid-cols-1 gap-4 md:grid-cols-4">
<!-- Première image -->
<div class="relative">
<label>
<input class="hidden" type="file" @change="updateImage('image1Url', $event)"/>
<img :src="image1Url || fallbackImage"
:alt="t('creator.sections.about.image1')"
class="rounded-md max-w-full h-auto cursor-pointer"/>
</label>
<button class="absolute top-2 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600"
@click="deleteImage('image1Url')">
{{ t('common.delete') }}
</button>
</div>
<!-- Deuxième image -->
<div class="relative">
<label>
<input class="hidden" type="file" @change="updateImage('image2Url', $event)"/>
<img :src="image2Url || fallbackImage"
:alt="t('creator.sections.about.image2')"
class="rounded-md max-w-full h-auto cursor-pointer"/>
</label>
<button class="absolute top-2 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600"
@click="deleteImage('image2Url')">
{{ t('common.delete') }}
</button>
</div>
<!-- Troisième image -->
<div class="relative">
<label>
<input class="hidden" type="file" @change="updateImage('image3Url', $event)"/>
<img :src="image3Url || fallbackImage"
:alt="t('creator.sections.about.image3')"
class="rounded-md max-w-full h-auto cursor-pointer"/>
</label>
<button class="absolute top-2 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600"
@click="deleteImage('image3Url')">
{{ t('common.delete') }}
</button>
</div>
<!-- Quatrième image -->
<div class="relative">
<label>
<input class="hidden" type="file" @change="updateImage('image4Url', $event)"/>
<img :src="image4Url || fallbackImage"
:alt="t('creator.sections.about.image4')"
class="rounded-md max-w-full h-auto cursor-pointer"/>
</label>
<button class="absolute top-2 right-2 px-2 py-1 bg-red-500 text-white hover:bg-red-600"
@click="deleteImage('image4Url')">
{{ t('common.delete') }}
</button>
</div>
</div>
<!-- Description-->
<div class="text-2xl pa-2">{{ t('creator.sections.about.description') }}</div>
</div>
<!--Edit-->
<div v-if="isEditMode">
<v-text-field
v-model="editablePhoneNumber"
class="w-full p-2"
:label="t('creator.fields.phoneNumber')"
variant="outlined"
></v-text-field>
<v-text-field
v-model="editableEmail"
class="w-full p-2"
:label="t('creator.fields.email')"
variant="outlined"
></v-text-field>
</div>
<!-- Contact Info-->
<div v-if="!isEditMode && phoneNumber || email">
<div class="my-10 flex flex-row">
<div v-if="phoneNumber" class="flex items-center space-x-2 w-1/2 justify-center">
<i class="mdi mdi-phone-outline text-2xl"></i>
<span>{{ phoneNumber }}</span>
</div>
<!-- Affichage de l'email -->
<div v-if="email" class="flex items-center space-x-2 w-1/2 justify-center">
<i class="mdi mdi-email-outline text-2xl"></i>
<a :href="`mailto:${email}`" class="no-underline text-current">
{{ email }}
</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import {onMounted, ref} from "vue"; import {onMounted, ref, computed} from "vue";
import {useClient} from "@/plugins/api.js"; 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 {useAuthStore} from "@/stores/authStore.js";
import {useI18n} from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import CreatorAlbum from './CreatorAlbum.vue';
const { t } = useI18n(); const {t} = useI18n();
const authStore = useAuthStore();
const creatorProfileStore = useCreatorProfileStore(); const creatorProfileStore = useCreatorProfileStore();
const brandingStore = useBrandingStore(); const brandingStore = useBrandingStore();
const client = useClient(); const client = useClient();
@@ -314,193 +122,169 @@ const isLoggedIn = true;
const isEditMode = ref(false); const isEditMode = ref(false);
const showEditButtons = ref(false); const showEditButtons = ref(false);
const fallbackImage = "/medias/emptyimage.png";
// Variables réactives pour les données // Variables réactives pour les données
const mainTitle = ref(""); const description = ref("");
const mainImageUrl = ref(""); const videoUrl = ref("");
const mainImageText = ref(""); const imageUrls = ref([]);
const mainVideoText = ref(""); const albumId = ref(null);
const imagesSubtitle = ref(""); const originalPhotos = ref([]);
const image1Url = ref("");
const image2Url = ref("");
const image3Url = ref("");
const image4Url = ref("");
const imagesText = ref("");
const videoSubtitle = ref("");
const videoSubtitleMain = ref("");
const videoUrlMain = ref("");
const phoneNumber = ref("");
const email = ref("");
// Editable fields // Editable fields
const editableMainTitle = ref(""); const editableDescription = ref("");
const editableMainImageText = ref(""); const editableVideoUrl = ref("");
const editableMainVideoText = ref("");
const editableImagesText = ref("");
const editableVideoSubtitle = ref("");
const editableVideoUrlMain = ref("");
const editablePhoneNumber = ref("");
const editableEmail = ref("");
const editableImages = ref([null, null, null, null]); // Computed property to check if there are images
const hasImages = computed(() => {
// Only consider it has images if there are actual image URLs (not empty strings)
return imageUrls.value.some(img => img && img.trim() !== "");
});
// Computed property for YouTube embed URL
const youtubeEmbedUrl = computed(() => {
if (!videoUrl.value) return "";
return `https://www.youtube.com/embed/${videoUrl.value}`;
});
// Activer/désactiver le mode édition // Activer/désactiver le mode édition
function toggleEditMode() { function toggleEditMode() {
isEditMode.value = !isEditMode.value; isEditMode.value = !isEditMode.value;
if (isEditMode.value) { if (isEditMode.value) {
// Charger les valeurs pour l'édition // Charger les valeurs pour l'édition
editableMainTitle.value = mainTitle.value; editableDescription.value = description.value;
editableMainImageText.value = mainImageText.value; editableVideoUrl.value = videoUrl.value;
editableMainVideoText.value = mainVideoText.value; }
editableImagesText.value = imagesText.value; }
editableVideoSubtitle.value = videoSubtitle.value;
editableVideoUrlMain.value = videoUrlMain.value; // Fetch album data
editablePhoneNumber.value = phoneNumber.value; async function fetchAlbumData() {
editableEmail.value = email.value; if (!creatorProfileStore.creator?.id) return;
albumId.value = creatorProfileStore.creator.id;
try {
// Try to get the album
const response = await client.get(`/api/albums/${albumId.value}`);
if (response.data && response.data.photos) {
// Store original photos for comparison
originalPhotos.value = response.data.photos;
// Extract photo URLs from the album photos
imageUrls.value = response.data.photos.map(photo => photo.photoUrl);
} else { } else {
// Sauvegarder les modifications // Initialize with empty slots for adding new photos
mainTitle.value = editableMainTitle.value; imageUrls.value = Array(6).fill("");
mainImageText.value = editableMainImageText.value; originalPhotos.value = [];
mainVideoText.value = editableMainVideoText.value;
imagesText.value = editableImagesText.value;
videoSubtitle.value = editableVideoSubtitle.value;
videoUrlMain.value = editableVideoUrlMain.value;
phoneNumber.value = editablePhoneNumber.value;
email.value = editableEmail.value;
// Réinitialisation des images supprimées à des strings vides si nécessaire
if (mainImageUrl.value === null) mainImageUrl.value = "";
if (image1Url.value === null) image1Url.value = "";
if (image2Url.value === null) image2Url.value = "";
if (image3Url.value === null) image3Url.value = "";
if (image4Url.value === null) image4Url.value = "";
}
}
// Supprimer une image
function deleteImage(field) {
switch (field) {
case "mainImageUrl":
mainImageUrl.value = ""; // Remplace par un string vide
break;
case "image1Url":
image1Url.value = ""; // Remplace par un string vide
break;
case "image2Url":
image2Url.value = ""; // Remplace par un string vide
break;
case "image3Url":
image3Url.value = ""; // Remplace par un string vide
break;
case "image4Url":
image4Url.value = ""; // Remplace par un string vide
break;
}
}
// Mettre à jour une image
function updateImage(field, event) {
const file = event.target.files[0];
if (file) {
switch (field) {
case "mainImageUrl":
editableImages.value[0] = file;
mainImageUrl.value = URL.createObjectURL(file);
break;
case "image1Url":
editableImages.value[1] = file;
image1Url.value = URL.createObjectURL(file);
break;
case "image2Url":
editableImages.value[2] = file;
image2Url.value = URL.createObjectURL(file);
break;
case "image3Url":
editableImages.value[3] = file;
image3Url.value = URL.createObjectURL(file);
break;
case "image4Url":
editableImages.value[4] = file;
image4Url.value = URL.createObjectURL(file);
break;
} }
} catch (error) {
// Album might not exist yet, which is fine
console.log("Album might not exist yet:", error);
// Initialize with empty slots for adding new photos
imageUrls.value = Array(6).fill("");
originalPhotos.value = [];
} }
} }
// Charger les données au montage // Charger les données au montage
onMounted(() => { onMounted(async () => {
if (brandingStore.presentationInfos === undefined) return; if (!brandingStore.value?.presentation) return;
mainTitle.value = brandingStore.presentationInfos.title; description.value = brandingStore.value.presentation.description || "";
mainImageUrl.value = brandingStore.presentationInfos.mainImageUrl; videoUrl.value = brandingStore.value.presentation.videoUrl || "";
mainImageText.value = brandingStore.presentationInfos.mainImageText;
mainVideoText.value = brandingStore.presentationInfos.mainVideoText; // Fetch album data
imagesSubtitle.value = brandingStore.presentationInfos.imagesSubtitle; await fetchAlbumData();
image1Url.value = brandingStore.presentationInfos.image1Url;
image2Url.value = brandingStore.presentationInfos.image2Url;
image3Url.value = brandingStore.presentationInfos.image3Url;
image4Url.value = brandingStore.presentationInfos.image4Url;
imagesText.value = brandingStore.presentationInfos.imagesText;
videoSubtitle.value = brandingStore.presentationInfos.videoSubtitle;
videoSubtitleMain.value = brandingStore.presentationInfos.videoSubtitleMain;
videoUrlMain.value = brandingStore.presentationInfos.videoUrlMain;
phoneNumber.value = brandingStore.presentationInfos.phoneNumber;
email.value = brandingStore.presentationInfos.email;
}); });
// Update images from CreatorAlbum component
function updateImages(newImages) {
imageUrls.value = newImages;
}
async function saveChanges() { async function saveChanges() {
if (!creatorProfileStore.creator.id) { if (!creatorProfileStore.creator.id) {
console.error("L'ID du créateur est manquant !"); console.error("L'ID du créateur est manquant !");
return; return;
} }
const formData = new FormData();
// Ajout des champs textuels
formData.append("PhoneNumber", editablePhoneNumber.value || "");
formData.append("Email", editableEmail.value || "");
formData.append("Title", editableMainTitle.value || "");
formData.append("MainImageText", editableMainImageText.value || "");
formData.append("MainVideoText", editableMainVideoText.value || "");
formData.append("ImagesText", editableImagesText.value || "");
formData.append("VideoSubtitle", editableVideoSubtitle.value || "");
formData.append("VideoUrlMain", editableVideoUrlMain.value || "");
// Ajout des URLs d'images supprimées
formData.append("MainImageUrl", mainImageUrl.value || ""); // Peut contenir un string vide
formData.append("Image1Url", image1Url.value || "");
formData.append("Image2Url", image2Url.value || "");
formData.append("Image3Url", image3Url.value || "");
formData.append("Image4Url", image4Url.value || "");
// Ajout des fichiers d'images téléversées
if (editableImages.value[0]) formData.append("MainImage", editableImages.value[0]);
if (editableImages.value[1]) formData.append("Image1", editableImages.value[1]);
if (editableImages.value[2]) formData.append("Image2", editableImages.value[2]);
if (editableImages.value[3]) formData.append("Image3", editableImages.value[3]);
if (editableImages.value[4]) formData.append("Image4", editableImages.value[4]);
try { try {
isLoading.value = true; isLoading.value = true;
const response = await client.post( // Save presentation info
const presentationResponse = await client.post(
`/api/creators/${creatorProfileStore.creator.id}/presentation-infos`, `/api/creators/${creatorProfileStore.creator.id}/presentation-infos`,
formData, {
{headers: {"Content-Type": "multipart/form-data"}} description: editableDescription.value || "",
videoUrl: editableVideoUrl.value || ""
}
); );
// Mettre à jour les valeurs locales pour refléter les changements // Mettre à jour les valeurs locales pour refléter les changements
mainTitle.value = editableMainTitle.value; description.value = editableDescription.value;
mainImageText.value = editableMainImageText.value; videoUrl.value = editableVideoUrl.value;
mainVideoText.value = editableMainVideoText.value;
imagesText.value = editableImagesText.value;
videoSubtitle.value = editableVideoSubtitle.value;
videoUrlMain.value = editableVideoUrlMain.value;
phoneNumber.value = editablePhoneNumber.value;
email.value = editableEmail.value;
console.log("Données sauvegardées :", response.data); // Save album photos if they've changed
if (imageUrls.value.length > 0) {
// Create or update the album
const albumId = creatorProfileStore.creator.id;
try {
// Try to create the album first (it will fail if it already exists)
await client.post('/api/albums', {
albumId: albumId,
title: `${creatorProfileStore.creator.name}'s Album`,
description: "Photo album for the creator"
});
} catch (error) {
// Album might already exist, which is fine
console.log("Album might already exist:", error);
}
// Check for deleted photos
const deletedPhotos = originalPhotos.value.filter(originalPhoto => {
// If the photo URL is not in the current images array, it was deleted
return !imageUrls.value.includes(originalPhoto.photoUrl);
});
// Delete removed photos
for (const photo of deletedPhotos) {
try {
await client.delete(`/api/albums/${albumId}/photos/${photo.id}`);
} catch (error) {
console.error("Error deleting photo:", error);
}
}
// Now add or update photos
for (let i = 0; i < imageUrls.value.length; i++) {
const imageUrl = imageUrls.value[i];
if (imageUrl && imageUrl.startsWith('data:')) {
// This is a new image that needs to be uploaded
const photoId = crypto.randomUUID();
const formData = new FormData();
// Convert data URL to file
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], `photo-${i}.jpg`, { type: 'image/jpeg' });
formData.append('file', file);
await client.post(`/api/albums/${albumId}/photos`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
params: {
photoId: photoId
}
});
}
}
// Refresh album data after changes
await fetchAlbumData();
}
console.log("Données sauvegardées :", presentationResponse.data);
isEditMode.value = false; isEditMode.value = false;
@@ -513,25 +297,25 @@ async function saveChanges() {
function cancelEdit() { function cancelEdit() {
// Restaurer les valeurs d'origine // Restaurer les valeurs d'origine
editableMainTitle.value = mainTitle.value; editableDescription.value = description.value;
editableMainImageText.value = mainImageText.value; editableVideoUrl.value = videoUrl.value;
editableMainVideoText.value = mainVideoText.value;
editableImagesText.value = imagesText.value;
editableVideoSubtitle.value = videoSubtitle.value;
editableVideoUrlMain.value = videoUrlMain.value;
editablePhoneNumber.value = phoneNumber.value;
editableEmail.value = email.value;
// Désactiver le mode édition // Désactiver le mode édition
isEditMode.value = false; isEditMode.value = false;
} }
</script> </script>
<style scoped> <style scoped>
.content-section {
@apply w-full overflow-hidden;
}
.video-container { .video-container {
position: relative; position: relative;
width: 100%; width: 100%;
padding-top: 56.25%; /* Ratio 16:9 (9/16 = 0.5625) */ padding-top: 31.25%; /* Reduced from 56.25% to make it shorter while maintaining aspect ratio */
max-height: 40vh;
} }
.video-frame { .video-frame {
@@ -540,106 +324,84 @@ function cancelEdit() {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 0; border: none;
border-radius: 0.5rem; /* Pour les bords arrondis */ }
/* Add responsive breakpoints */
@media (max-width: 640px) {
.video-container {
padding-top: 35%;
max-height: 35vh;
}
}
@media (min-width: 1024px) {
.video-container {
padding-top: 30%;
max-height: 38vh;
}
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"common": {
"save": "Save",
"edit": "Edit", "edit": "Edit",
"save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"delete": "Delete"
},
"creator": { "creator": {
"sections": { "sections": {
"about": { "about": {
"title": "About", "title": "About",
"description": "Description", "description": "Description"
"mainImage": "Main image",
"image1": "Image 1",
"image2": "Image 2",
"image3": "Image 3",
"image4": "Image 4",
"images": "Images"
}, },
"support": { "photos": {
"title": "Support", "title": "Photos",
"description": "Description", "image": "Image"
"subtitle": "Subtitle"
} }
}, },
"fields": { "fields": {
"videoUrl": "Video URL", "videoUrl": "Video URL"
"phoneNumber": "Phone Number",
"email": "Email"
} }
} }
}, },
"fr": { "fr": {
"common": {
"save": "Enregistrer",
"edit": "Modifier", "edit": "Modifier",
"save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",
"delete": "Supprimer"
},
"creator": { "creator": {
"sections": { "sections": {
"about": { "about": {
"title": "À propos", "title": "À propos",
"description": "Description", "description": "Description"
"mainImage": "Image principale",
"image1": "Image 1",
"image2": "Image 2",
"image3": "Image 3",
"image4": "Image 4",
"images": "Images"
}, },
"support": { "photos": {
"title": "Support", "title": "Photos",
"description": "Description", "image": "Image"
"subtitle": "Sous-titre"
} }
}, },
"fields": { "fields": {
"videoUrl": "URL de la vidéo", "videoUrl": "URL de la vidéo"
"phoneNumber": "Numéro de téléphone",
"email": "Email"
} }
} }
}, },
"es": { "es": {
"common": {
"save": "Guardar",
"edit": "Editar", "edit": "Editar",
"save": "Guardar",
"cancel": "Cancelar", "cancel": "Cancelar",
"delete": "Eliminar"
},
"creator": { "creator": {
"sections": { "sections": {
"about": { "about": {
"title": "Acerca de", "title": "Acerca de",
"description": "Descripción", "description": "Descripción"
"mainImage": "Imagen principal",
"image1": "Imagen 1",
"image2": "Imagen 2",
"image3": "Imagen 3",
"image4": "Imagen 4",
"images": "Imágenes"
}, },
"support": { "photos": {
"title": "Soporte", "title": "Fotos",
"description": "Descripción", "image": "Imagen"
"subtitle": "Subtítulo"
} }
}, },
"fields": { "fields": {
"videoUrl": "URL del video", "videoUrl": "URL del video"
"phoneNumber": "Número de teléfono",
"email": "Correo electrónico"
} }
} }
} }

View File

@@ -9,7 +9,7 @@
> >
<img <img
class="w-full aspect-[4/1] banner object-cover" class="w-full aspect-[4/1] banner object-cover"
:src="brandingStore.value?.images?.banner ?? '/images/placeholders/banner.png'" :src="brandingStore.value?.bannerUrl ?? '/images/placeholders/banner.png'"
:alt="t('alt')" :alt="t('alt')"
> >
<!-- Tint Effect --> <!-- Tint Effect -->

View File

@@ -0,0 +1,262 @@
<template>
<div class="album-editor">
<h2 class="text-xl font-semibold mb-4">{{ t('creator.sections.album.title') }}</h2>
<div class="image-grid">
<!-- Upload button -->
<div class="image-wrapper upload-wrapper" @click="triggerFileInput">
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
accept="image/*"
multiple
class="hidden"
/>
<div class="upload-content">
<v-icon size="large">mdi-plus</v-icon>
<span class="text-sm mt-2">{{ t('upload') }}</span>
</div>
</div>
<!-- Draggable images -->
<draggable
v-model="localImages"
class="image-grid"
item-key="id"
@end="handleReorder"
>
<template #item="{ element, index }">
<div class="image-wrapper">
<img :src="element.url" :alt="'Image ' + (index + 1)" />
<div class="image-actions">
<button @click="deleteImage(index)" class="action-btn delete-btn" :title="t('delete')">
<v-icon>mdi-delete</v-icon>
</button>
<button @click="moveImage(index, 'up')"
class="action-btn move-btn"
:disabled="index === 0"
:title="t('moveUp')">
<v-icon>mdi-arrow-up</v-icon>
</button>
<button @click="moveImage(index, 'down')"
class="action-btn move-btn"
:disabled="index === localImages.length - 1"
:title="t('moveDown')">
<v-icon>mdi-arrow-down</v-icon>
</button>
</div>
</div>
</template>
</draggable>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useI18n } from 'vue-i18n';
import draggable from 'vuedraggable';
const props = defineProps({
images: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:images']);
const { t } = useI18n();
const fileInput = ref(null);
// Local copy of images with IDs for drag and drop
const localImages = ref([]);
onMounted(() => {
// Initialize local images with IDs
localImages.value = props.images.map((url, index) => ({
id: index,
url: url
}));
});
// Trigger file input click
function triggerFileInput() {
fileInput.value.click();
}
// Handle file upload
async function handleFileUpload(event) {
const files = Array.from(event.target.files);
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
// Create a data URL for preview
const reader = new FileReader();
reader.onload = (e) => {
const newImage = {
id: Date.now() + Math.random(), // Unique ID
url: e.target.result
};
localImages.value.push(newImage);
emit('update:images', localImages.value.map(img => img.url));
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error uploading image:', error);
}
}
}
// Reset file input
event.target.value = '';
}
// Delete an image
function deleteImage(index) {
localImages.value.splice(index, 1);
emit('update:images', localImages.value.map(img => img.url));
}
// Move image up or down
function moveImage(index, direction) {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex >= 0 && newIndex < localImages.value.length) {
const temp = localImages.value[index];
localImages.value[index] = localImages.value[newIndex];
localImages.value[newIndex] = temp;
emit('update:images', localImages.value.map(img => img.url));
}
}
// Handle reorder after drag and drop
function handleReorder() {
emit('update:images', localImages.value.map(img => img.url));
}
</script>
<style scoped>
.album-editor {
@apply w-full;
}
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
width: 100%;
}
.image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
}
.upload-wrapper {
border: 2px dashed #ccc;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.upload-wrapper:hover {
border-color: #666;
background-color: #f5f5f5;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
color: #666;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-actions {
position: absolute;
top: 0;
right: 0;
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s ease;
}
.image-wrapper:hover .image-actions {
opacity: 1;
}
.action-btn {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.action-btn:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive adjustments */
@media (min-width: 768px) {
.image-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1024px) {
.image-grid {
grid-template-columns: repeat(5, 1fr);
}
}
@media (max-width: 640px) {
.image-grid {
gap: 0.25rem;
}
}
</style>
<i18n>
{
"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"
}
}
</i18n>

View File

@@ -0,0 +1,144 @@
<template>
<div v-if="hasImages" class="album-view">
<!-- Album Display -->
<div class="image-grid">
<div v-for="(url, index) in displayedImages"
:key="index"
class="image-wrapper">
<img :src="url"
:alt="t('creator.sections.album.image')"
class="image"/>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted } from "vue";
import { useI18n } from 'vue-i18n';
const props = defineProps({
images: {
type: Array,
required: true,
default: () => []
}
});
const { t } = useI18n();
// Add a reactive window width
const windowWidth = ref(window.innerWidth);
// Update window width on resize
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
// Add and remove event listener
onMounted(() => {
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
const hasImages = computed(() => {
return props.images.some(url => url);
});
const nonEmptyImages = computed(() => {
return props.images.filter(url => url);
});
// Show different number of images based on reactive window width
const displayedImages = computed(() => {
const images = nonEmptyImages.value;
if (windowWidth.value >= 1024) {
return images.slice(0, 5); // 5 images on large screens
} else if (windowWidth.value >= 768) {
return images.slice(0, 4); // 4 images on medium screens
}
return images.slice(0, 3); // 3 images on smaller screens
});
</script>
<style scoped>
.album-view {
@apply w-full;
}
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
width: 100%;
}
.image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 1;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Responsive adjustments */
@media (min-width: 768px) {
.image-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1024px) {
.image-grid {
grid-template-columns: repeat(5, 1fr);
}
}
@media (max-width: 640px) {
.image-grid {
gap: 0.25rem;
}
}
</style>
<i18n>
{
"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"
}
}
}
}
}
</i18n>

View File

@@ -91,7 +91,7 @@ const emits = defineEmits(['closeRequested'])
const fileInput = ref(null) const fileInput = ref(null)
const selectedFile = 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 fallbackUrl = '/images/hutopymedia/banners/hutopyul.png'
const errorMessage = ref('') const errorMessage = ref('')
const showCropper = ref(false) 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') emits('closeRequested')
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -189,8 +190,8 @@ const publish = async () => {
const cancel = () => { const cancel = () => {
showCropper.value = false showCropper.value = false
// Reset to original state if we were editing // Reset to original state if we were editing
if (props.creator?.images?.banner) { if (props.creator?.bannerUrl) {
fileUrl.value = props.creator.images.banner fileUrl.value = props.creator.bannerUrl
selectedFile.value = null selectedFile.value = null
} else { } else {
fileUrl.value = fallbackUrl fileUrl.value = fallbackUrl

View File

@@ -0,0 +1,102 @@
<template>
<div v-if="hasImages || isEditMode"
class="creator-album"
@click="handleAlbumClick">
<!-- Use AlbumView for display mode -->
<AlbumView v-if="!isEditMode"
:images="images" />
<!-- Use AlbumEditor for edit mode -->
<AlbumEditor v-if="isEditMode"
:images="images"
@update:images="updateImages" />
</div>
</template>
<script setup>
import { computed } from "vue";
import AlbumView from './AlbumView.vue';
import AlbumEditor from './AlbumEditor.vue';
const props = defineProps({
isEditMode: {
type: Boolean,
required: true
},
images: {
type: Array,
required: true,
default: () => []
}
});
const emit = defineEmits(['update:images']);
// Computed property to check if there are images
const hasImages = computed(() => {
return props.images.some(url => url);
});
// Handle album click to enter edit mode
function handleAlbumClick() {
if (!props.isEditMode) {
emit('update:isEditMode', true);
}
}
// Update images from AlbumEditor component
function updateImages(newImages) {
emit('update:images', newImages);
}
</script>
<style scoped>
.creator-album {
@apply w-full;
cursor: pointer;
}
</style>
<i18n>
{
"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"
}
}
}
}
}
</i18n>

View File

@@ -5,9 +5,8 @@
<div class="content-sections"> <div class="content-sections">
<!-- Donation Section --> <!-- Donation Section -->
<div class="section sm:hidden"> <div v-if="brandingStore.value?.acceptDonation" class="section sm:hidden">
<DonationButton <DonationButton
v-if="brandingStore.value?.acceptDonation"
:creator-id="brandingStore.value?.id" :creator-id="brandingStore.value?.id"
:creator-name="brandingStore.value?.name" :creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/paymentfailed/' + brandingStore.value?.id" :on-cancelled-url="baseURL + '/paymentfailed/' + brandingStore.value?.id"
@@ -17,7 +16,7 @@
<!-- About Creator Section --> <!-- About Creator Section -->
<div class="section"> <div class="section">
<AboutCreator /> <AboutCreator/>
</div> </div>
</div> </div>
@@ -26,7 +25,6 @@
<script setup> <script setup>
import AboutCreator from './AboutCreator.vue'; import AboutCreator from './AboutCreator.vue';
import { ref } from 'vue';
import DonationButton from "@/views/creators/DonationButton.vue"; import DonationButton from "@/views/creators/DonationButton.vue";
import {useBrandingStore} from "@/stores/brandingStore.js"; import {useBrandingStore} from "@/stores/brandingStore.js";
@@ -58,8 +56,7 @@ const baseURL = window.location.origin;
@apply rounded-2xl; @apply rounded-2xl;
@apply p-[1px]; @apply p-[1px];
background: linear-gradient(135deg, rgba(64, 64, 64, 1) 0%, rgba(64, 64, 64, 0) 20%, rgba(64, 64, 64, 0.5) 100%); background: linear-gradient(135deg, rgba(64, 64, 64, 1) 0%, rgba(64, 64, 64, 0) 20%, rgba(64, 64, 64, 0.5) 100%);
mask: mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0); linear-gradient(#fff 0 0);
mask-composite: exclude; mask-composite: exclude;
pointer-events: none; pointer-events: none;
@@ -68,27 +65,4 @@ const baseURL = window.location.origin;
</style> </style>
<i18n> <i18n>
{
"en": {
"creator": {
"home": {
"title": "Creator Home"
}
}
},
"fr": {
"creator": {
"home": {
"title": "Accueil du Créateur"
}
}
},
"es": {
"creator": {
"home": {
"title": "Inicio del Creador"
}
}
}
}
</i18n> </i18n>

View File

@@ -7,7 +7,7 @@
<div class="rounded-full border-4 border-hPrimary w-[110px] h-[110px]"> <div class="rounded-full border-4 border-hPrimary w-[110px] h-[110px]">
<img <img
:src="brandingStore.value.images?.logo ?? '/images/placeholders/profile.png'" :src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'"
:alt="t('logoAlt')" :alt="t('logoAlt')"
width="110px" width="110px"
height="110px" height="110px"

View File

@@ -94,7 +94,7 @@ const emits = defineEmits(['closeRequested'])
const fileInput = ref(null) const fileInput = ref(null)
const selectedFile = ref(null) const selectedFile = ref(null)
const fileUrl = ref(props.creator.images.logo) const fileUrl = ref(props.creator.portraitUrl)
const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png' const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png'
const errorMessage = ref('') const errorMessage = ref('')
const showCropper = ref(false) const showCropper = ref(false)
@@ -180,7 +180,10 @@ const publish = async () => {
} }
) )
props.creator.images.logo = `${response.data.blobUrl}?t=${Date.now()}` props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}`
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl
}
emits('closeRequested') emits('closeRequested')
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -194,8 +197,8 @@ const publish = async () => {
const cancel = () => { const cancel = () => {
showCropper.value = false showCropper.value = false
// Reset to original state if we were editing // Reset to original state if we were editing
if (props.creator.images.logo) { if (props.creator.portraitUrl) {
fileUrl.value = props.creator.images.logo fileUrl.value = props.creator.portraitUrl
selectedFile.value = null selectedFile.value = null
} else { } else {
fileUrl.value = fallbackUrl fileUrl.value = fallbackUrl

View File

@@ -9,7 +9,7 @@ const { t } = useI18n();
<template> <template>
<footer class="flex flex-col gap-10"> <footer class="flex flex-col gap-10 pt-7 pb-10">
<div class="footer-socials"> <div class="footer-socials">
<a href="https://www.facebook.com/profile.php?id=61556819217561" target="_blank"> <a href="https://www.facebook.com/profile.php?id=61556819217561" target="_blank">
@@ -76,17 +76,17 @@ const { t } = useI18n();
.footer-copyright { .footer-copyright {
@apply flex justify-center; @apply flex justify-center;
@apply text-hOnBackground tracking-widest font-sans text-sm uppercase; @apply text-hOnBackground tracking-widest font-sans text-sm;
} }
.social-icon { .social-icon {
@apply fill-current w-8 h-8; @apply fill-current w-6 h-6;
@apply text-hOnBackground; @apply text-hOnBackground;
} }
.link { .link {
@apply text-hOnBackground; @apply text-hOnBackground;
@apply tracking-widest font-sans text-sm uppercase; @apply tracking-widest font-sans text-sm;
@apply hover:text-gray-400; @apply hover:text-gray-400;
} }

View File

@@ -25,32 +25,20 @@ function toggleLanguage() {
<div class="side-logo"> <div class="side-logo">
<router-link to="/@hutopy"> <router-link to="/@hutopy">
<!-- Show full logo on medium and larger screens -->
<img src="/images/hutopy-logo.png" <img src="/images/hutopy-logo.png"
alt="hutopy logo" alt="hutopy logo"
class="hidden sm:block"
height="50"> height="50">
<!-- Show icon version on small screens -->
<img alt="hutopy icon"
class="block sm:hidden"
height="50"
src="/images/hutopy-icon.png"
width="50">
</router-link> </router-link>
</div> </div>
<div class="flex-grow flex items-center lg:items-start lg:justify-center p-4">
</div>
<div class="side-menu"> <div class="side-menu">
<div class="side-menu-portrait"> <div v-if="authStore.isAuthenticated"
class="side-menu-portrait">
<img :src="userProfileStore.portraitUrl" <img :src="userProfileStore.portraitUrl"
alt="Profile Image" alt="Profile Image"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
class="rounded-full" class="rounded-full">
width="32"
height="32">
<span class="profile-label">{{ userProfileStore.alias }}</span> <span class="profile-label">{{ userProfileStore.alias }}</span>
</div> </div>
@@ -92,6 +80,7 @@ function toggleLanguage() {
<i class="mdi mdi-login"></i> <i class="mdi mdi-login"></i>
<span class="label">{{ t('sidebar.signIn') }}</span> <span class="label">{{ t('sidebar.signIn') }}</span>
</button> </button>
</router-link> </router-link>
</template> </template>
<div v-else> <div v-else>
@@ -110,53 +99,63 @@ function toggleLanguage() {
<style scoped> <style scoped>
.side-container { .side-container {
@apply bg-hSurface text-hOnSurface;
@apply lg:fixed lg:max-h-screen;
@apply flex; @apply flex;
@apply lg:flex-col lg:w-64 lg:max-w-64; @apply lg:flex-col lg:w-64 lg:max-w-64;
@apply h-16 lg:h-screen; @apply h-16 lg:h-screen;
@apply lg:border-r-2 lg:border-[#2d282d];
} }
.side-logo { .side-logo {
@apply flex items-center justify-center; @apply flex flex-grow;
@apply mx-6 lg:mx-0 lg:mt-2; @apply items-center justify-start p-4;
@apply lg:items-start lg:justify-center lg:pt-4;
} }
.side-menu { .side-menu {
@apply flex gap-4 p-4; @apply flex gap-4 p-6;
@apply items-center lg:items-stretch; @apply items-center lg:items-stretch;
@apply flex-row-reverse lg:flex-col; @apply flex-row-reverse lg:flex-col;
} }
.side-menu-portrait { .side-menu-portrait {
@apply w-10 h-10;
@apply -ml-1;
@apply flex items-center justify-start; @apply flex items-center justify-start;
} }
.side-menu-items { .side-menu-items {
@apply flex; @apply flex gap-2;
@apply flex-row lg:flex-col; @apply flex-row;
@apply lg:gap-2; @apply lg:w-full lg:flex-col;
@apply lg:w-full;
} }
.profile-label { .profile-label {
@apply mx-4 text-lg font-sans capitalize; @apply label;
@apply ml-5;
@apply text-lg font-sans capitalize;
@apply font-semibold; @apply font-semibold;
@apply hidden lg:inline @apply hidden lg:inline;
} }
.label { .label {
@apply text-nowrap; @apply text-nowrap;
@apply mx-2; @apply ml-4;
@apply hidden lg:inline @apply hidden lg:inline;
} }
.menu-item-action { .menu-item-action {
/* FIXME: The hover value is not semantically correct */ @apply bg-hSurface text-hOnSurface hover:mix-blend-screen;
@apply bg-hBackground hover:bg-hSurface;
@apply capitalize; @apply capitalize;
@apply flex items-center gap-4 py-2 rounded; @apply flex items-center gap-3 p-2 rounded-full md:rounded-full;
@apply mx-0; @apply mx-0;
@apply lg:px-2; @apply lg:pl-2;
@apply w-10 h-10 justify-center lg:w-full lg:h-auto lg:justify-normal; @apply w-10 h-10 justify-center lg:w-full lg:h-auto lg:justify-normal;
i {
@apply text-xl;
}
} }
</style> </style>

View File

@@ -357,7 +357,7 @@ function handleDelete() {
<style scoped> <style scoped>
.card { .card {
@apply rounded-lg p-4 w-full max-w-2xl; @apply rounded-lg p-4 w-full;
} }
.card-title { .card-title {

320
package-lock.json generated Normal file
View File

@@ -0,0 +1,320 @@
{
"name": "hutopy",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"vuedraggable": "^4.1.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.27.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"license": "MIT",
"peer": true
},
"node_modules/@vue/compiler-core": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
"integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.25.3",
"@vue/shared": "3.5.13",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz",
"integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.13",
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz",
"integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.25.3",
"@vue/compiler-core": "3.5.13",
"@vue/compiler-dom": "3.5.13",
"@vue/compiler-ssr": "3.5.13",
"@vue/shared": "3.5.13",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.11",
"postcss": "^8.4.48",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz",
"integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.13",
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz",
"integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz",
"integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.13",
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz",
"integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.13",
"@vue/runtime-core": "3.5.13",
"@vue/shared": "3.5.13",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz",
"integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.13",
"@vue/shared": "3.5.13"
},
"peerDependencies": {
"vue": "3.5.13"
}
},
"node_modules/@vue/shared": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz",
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
"license": "MIT",
"peer": true
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT",
"peer": true
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC",
"peer": true
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vue": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.13",
"@vue/compiler-sfc": "3.5.13",
"@vue/runtime-dom": "3.5.13",
"@vue/server-renderer": "3.5.13",
"@vue/shared": "3.5.13"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"vuedraggable": "^4.1.0"
}
}