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 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<Creator> Creators => Set<Creator>();
public DbSet<Slugs> Slugs => Set<Slugs>();
public DbSet<Album> Albums => Set<Album>();
public DbSet<AlbumPhoto> AlbumPhotos => Set<AlbumPhoto>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
@@ -62,16 +64,50 @@ public class ContentDbContext(
modelBuilder
.Entity<Creator>()
.OwnsOne<Images>(x => x.Images)
.ToTable(nameof(Images));
modelBuilder
.Entity<Creator>()
.OwnsOne<PresentationInfos>(x => x.PresentationInfos)
.ToTable(nameof(PresentationInfos));
.OwnsOne<Presentation>(x => x.Presentation)
.ToTable(nameof(Presentation));
modelBuilder
.Entity<Creator>()
.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

@@ -6,7 +6,7 @@ public class Creator
{
public Guid Id { get; set; }
public Guid CreatedBy { get; set; }
public Guid CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public Guid? DeletedBy { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
@@ -16,14 +16,16 @@ public class Creator
/// </summary>
public bool IsDeleted { get; private set; } // private set → EF updates it
public bool AcceptDonation { get; set; }
[MaxLength(2048)] public string? BannerUrl { get; set; }
[MaxLength(2048)] public string? PortraitUrl { get; set; }
public bool Verified { get; set; }
[MaxLength(255)] public string Name { get; set; }
[MaxLength(128)] public string Slug { get; set; }
[MaxLength(255)] public string? Title { get; set; }
public bool AcceptDonation { get; set; }
public Socials Socials { get; set; } = new();
public Images Images { get; set; } = new();
public PresentationInfos PresentationInfos { get; set; } = new();
public Presentation Presentation { get; set; } = new();
}
public class Socials
@@ -38,29 +40,10 @@ public class Socials
[MaxLength(2048)] public string? WebsiteUrl { get; set; }
}
public class Images
public class Presentation
{
[MaxLength(2048)] public string? Banner { get; set; }
[MaxLength(2048)] public string? Logo { get; set; }
}
public class PresentationInfos
{
[MaxLength(255)] public string PhoneNumber { get; set; } = string.Empty;
[MaxLength(255)] public string Email { get; set; } = string.Empty;
[MaxLength(2000)] public string Title { get; set; } = string.Empty;
[MaxLength(2048)] public string MainImageUrl { get; set; } = string.Empty;
[MaxLength(10000)] public string MainImageText { get; set; } = string.Empty;
[MaxLength(10000)] public string MainVideoText { get; set; } = string.Empty;
[MaxLength(2000)] public string ImagesSubtitle { get; set; } = string.Empty;
[MaxLength(2048)] public string Image1Url { get; set; } = string.Empty;
[MaxLength(2048)] public string Image2Url { get; set; } = string.Empty;
[MaxLength(2048)] public string Image3Url { get; set; } = string.Empty;
[MaxLength(2048)] public string Image4Url { get; set; } = string.Empty;
[MaxLength(10000)] public string ImagesText { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoSubtitle { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoSubtitleMain { get; set; } = string.Empty;
[MaxLength(2048)] public string VideoUrlMain { get; set; } = string.Empty;
[MaxLength(2048)] public string VideoUrl { get; set; } = string.Empty;
[MaxLength(10000)] public string VideoText { get; set; } = string.Empty;
[MaxLength(2000)] public string Description { get; set; } = null!;
[MaxLength(2048)] public string? VideoUrl { get; set; }
[MaxLength(255)] public string? PhoneNumber { get; set; }
[MaxLength(255)] public string? Email { get; set; }
}

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);
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")
@@ -83,6 +165,10 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
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");
@@ -105,6 +191,10 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
@@ -160,6 +250,17 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b.ToTable("Slugs", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.AlbumPhoto", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Album", "Album")
.WithMany("Photos")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
@@ -203,120 +304,31 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Presentation", "Presentation", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
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>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(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")
.IsRequired()
.HasMaxLength(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")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "Content");
b1.ToTable("Presentation", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
@@ -367,15 +379,17 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.HasForeignKey("CreatorId");
});
b.Navigation("Images")
.IsRequired();
b.Navigation("PresentationInfos")
b.Navigation("Presentation")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Album", b =>
{
b.Navigation("Photos");
});
#pragma warning restore 612, 618
}
}

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

View File

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

View File

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

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,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedByPortraitUrl = c.Creator.PortraitUrl,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,

View File

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

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,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedByPortraitUrl = c.Creator.PortraitUrl,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,

View File

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

View File

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

View File

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

View File

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

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 {
@apply border border-[#3d3d3d];
@apply lg:max-h-screen;
@apply lg:fixed;
@apply border-b lg:border-b-0 lg:border-r;
@apply lg:fixed lg:max-h-screen;
@apply flex-shrink-0;
}

View File

@@ -57,15 +57,23 @@
@apply bg-hSurface text-hOnSurface;
}
/* Specific styling for dialog cards */
div.card.dialog {
@apply bg-hSurface text-hOnSurface;
@apply rounded-xl;
@apply shadow-lg;
}
div.card-title {
@apply font-sans font-bold text-2xl;
@apply p-2;
@apply text-hOnSurface;
}
div.card-content {
@apply flex flex-col gap-4;
@apply p-2;
@apply overflow-y-auto;
@apply text-hOnSurface;
}
div.card-actions {

View File

@@ -1,8 +1,8 @@
<template>
<div class="p-4 relative"
<div class="p-4 relative"
@mouseenter="showEditButtons = isLoggedIn && creatorProfileStore.creator?.id === brandingStore.value.id"
@mouseleave="showEditButtons = false">
<!-- Edit buttons with absolute positioning -->
<div v-if="showEditButtons || isEditMode"
class="absolute top-4 right-4 flex gap-2">
@@ -12,7 +12,7 @@
v-if="!isEditMode"
class="w-12 h-12 bg-hutopyPrimary rounded-full flex items-center justify-center shadow-lg"
@click="toggleEditMode()"
:title="t('common.edit')"
:title="t('edit')"
>
<v-icon large>mdi-pencil</v-icon>
</button>
@@ -22,7 +22,7 @@
v-if="isEditMode"
class="w-12 h-12 bg-hutopyPrimary rounded-full flex items-center justify-center shadow-lg"
@click="saveChanges()"
:title="t('common.save')"
:title="t('save')"
>
<v-icon large>mdi-check</v-icon>
</button>
@@ -32,279 +32,87 @@
v-if="isEditMode"
class="w-12 h-12 bg-red-500 rounded-full flex items-center justify-center shadow-lg"
@click="cancelEdit"
:title="t('common.cancel')"
:title="t('cancel')"
>
<v-icon large>mdi-close</v-icon>
</button>
</div>
<!-- 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>
<div>
<!-- Main image Bloc D'information-->
<div class="py-4">
<h1 class="flex justify-start text-2xl font-bold text-center mb-4">
{{ t('creator.sections.about.title') }}
</h1>
<div>
<!-- Description Section -->
<div>
<div v-if="!isEditMode">
<p v-if="mainImageText" class="text-lg text-justify">
{{ mainImageText }}
<p v-if="description" class="text-lg text-justify mb-6">
{{ description }}
</p>
</div>
<v-textarea v-if="isEditMode" v-model="editableMainImageText" class="w-full p-2 py-6 " :label="t('creator.sections.about.description')"
<v-textarea v-if="isEditMode"
v-model="editableDescription"
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 v-if="!isEditMode" class="py-5 text-lg font-bold">
{{ videoSubtitle }}
</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-model="editableVideoText"
class="w-full p-2"
:label="t('creator.sections.support.description')"
variant="outlined"
></v-textarea>
</div>
</div>
<!-- media-->
<div v-if="!isEditMode">
<div v-if="videoUrlMain" class="video-container">
<!-- Video Section -->
<div v-if="videoUrl || isEditMode"
:class="['content-section', {
'rounded-t-xl': hasImages || isEditMode,
'rounded-xl': !hasImages && !isEditMode
}]">
<div v-if="!isEditMode && videoUrl" class="video-container">
<iframe
:src="videoUrlMain"
:src="youtubeEmbedUrl"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="video-frame"
title="YouTube video player">
</iframe>
</div>
</div>
<div v-if="isEditMode">
<v-text-field
v-model="editableVideoUrlMain"
class="w-full p-2 rounded-md"
:label="t('creator.fields.videoUrl')"
type="text"
variant="outlined"
/>
</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>
<!-- Deuxième image -->
<div v-if="image2Url" class="relative w-full sm:flex-1 ">
<img :src="image2Url"
:alt="t('creator.sections.about.image2')"
class="rounded-md max-w-full h-auto cursor-pointer"/>
</div>
<!-- Troisième image -->
<div v-if="image3Url" class="relative w-full sm:flex-1 ">
<img :src="image3Url"
: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 v-if="isEditMode">
<v-text-field
v-model="editableVideoUrl"
class="w-full p-2"
:label="t('creator.fields.videoUrl')"
type="text"
variant="outlined"
/>
</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>
<!-- Photos Section using CreatorAlbum component -->
<CreatorAlbum
v-if="hasImages || isEditMode"
:is-edit-mode="isEditMode"
:images="imageUrls"
@update:images="updateImages"
@update:isEditMode="isEditMode = $event"
:class="['content-section', {
'rounded-b-xl': videoUrl || isEditMode,
'rounded-xl': !videoUrl && !isEditMode
}]"
/>
</div>
</div>
</div>
</template>
<script setup>
import {onMounted, ref} from "vue";
import {onMounted, ref, computed} from "vue";
import {useClient} from "@/plugins/api.js";
import {useBrandingStore} from "@/stores/brandingStore.js";
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
import {useAuthStore} from "@/stores/authStore.js";
import {useI18n} from 'vue-i18n';
import CreatorAlbum from './CreatorAlbum.vue';
const { t } = useI18n();
const authStore = useAuthStore();
const {t} = useI18n();
const creatorProfileStore = useCreatorProfileStore();
const brandingStore = useBrandingStore();
const client = useClient();
@@ -314,193 +122,169 @@ const isLoggedIn = true;
const isEditMode = ref(false);
const showEditButtons = ref(false);
const fallbackImage = "/medias/emptyimage.png";
// Variables réactives pour les données
const mainTitle = ref("");
const mainImageUrl = ref("");
const mainImageText = ref("");
const mainVideoText = ref("");
const imagesSubtitle = 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("");
const description = ref("");
const videoUrl = ref("");
const imageUrls = ref([]);
const albumId = ref(null);
const originalPhotos = ref([]);
// Editable fields
const editableMainTitle = ref("");
const editableMainImageText = ref("");
const editableMainVideoText = ref("");
const editableImagesText = ref("");
const editableVideoSubtitle = ref("");
const editableVideoUrlMain = ref("");
const editablePhoneNumber = ref("");
const editableEmail = ref("");
const editableDescription = ref("");
const editableVideoUrl = 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
function toggleEditMode() {
isEditMode.value = !isEditMode.value;
if (isEditMode.value) {
// Charger les valeurs pour l'édition
editableMainTitle.value = mainTitle.value;
editableMainImageText.value = mainImageText.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;
} else {
// Sauvegarder les modifications
mainTitle.value = editableMainTitle.value;
mainImageText.value = editableMainImageText.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 = "";
editableDescription.value = description.value;
editableVideoUrl.value = videoUrl.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;
// Fetch album data
async function fetchAlbumData() {
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 {
// Initialize with empty slots for adding new photos
imageUrls.value = Array(6).fill("");
originalPhotos.value = [];
}
} 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
onMounted(() => {
if (brandingStore.presentationInfos === undefined) return;
onMounted(async () => {
if (!brandingStore.value?.presentation) return;
mainTitle.value = brandingStore.presentationInfos.title;
mainImageUrl.value = brandingStore.presentationInfos.mainImageUrl;
mainImageText.value = brandingStore.presentationInfos.mainImageText;
mainVideoText.value = brandingStore.presentationInfos.mainVideoText;
imagesSubtitle.value = brandingStore.presentationInfos.imagesSubtitle;
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;
description.value = brandingStore.value.presentation.description || "";
videoUrl.value = brandingStore.value.presentation.videoUrl || "";
// Fetch album data
await fetchAlbumData();
});
// Update images from CreatorAlbum component
function updateImages(newImages) {
imageUrls.value = newImages;
}
async function saveChanges() {
if (!creatorProfileStore.creator.id) {
console.error("L'ID du créateur est manquant !");
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 {
isLoading.value = true;
const response = await client.post(
// Save presentation info
const presentationResponse = await client.post(
`/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
mainTitle.value = editableMainTitle.value;
mainImageText.value = editableMainImageText.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;
description.value = editableDescription.value;
videoUrl.value = editableVideoUrl.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;
@@ -513,25 +297,25 @@ async function saveChanges() {
function cancelEdit() {
// Restaurer les valeurs d'origine
editableMainTitle.value = mainTitle.value;
editableMainImageText.value = mainImageText.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;
editableDescription.value = description.value;
editableVideoUrl.value = videoUrl.value;
// Désactiver le mode édition
isEditMode.value = false;
}
</script>
<style scoped>
.content-section {
@apply w-full overflow-hidden;
}
.video-container {
position: relative;
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 {
@@ -540,106 +324,84 @@ function cancelEdit() {
left: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 0.5rem; /* Pour les bords arrondis */
border: none;
}
/* 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>
<i18n>
{
"en": {
"common": {
"save": "Save",
"edit": "Edit",
"cancel": "Cancel",
"delete": "Delete"
},
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"creator": {
"sections": {
"about": {
"title": "About",
"description": "Description",
"mainImage": "Main image",
"image1": "Image 1",
"image2": "Image 2",
"image3": "Image 3",
"image4": "Image 4",
"images": "Images"
"description": "Description"
},
"support": {
"title": "Support",
"description": "Description",
"subtitle": "Subtitle"
"photos": {
"title": "Photos",
"image": "Image"
}
},
"fields": {
"videoUrl": "Video URL",
"phoneNumber": "Phone Number",
"email": "Email"
"videoUrl": "Video URL"
}
}
},
"fr": {
"common": {
"save": "Enregistrer",
"edit": "Modifier",
"cancel": "Annuler",
"delete": "Supprimer"
},
"edit": "Modifier",
"save": "Enregistrer",
"cancel": "Annuler",
"creator": {
"sections": {
"about": {
"title": "À propos",
"description": "Description",
"mainImage": "Image principale",
"image1": "Image 1",
"image2": "Image 2",
"image3": "Image 3",
"image4": "Image 4",
"images": "Images"
"description": "Description"
},
"support": {
"title": "Support",
"description": "Description",
"subtitle": "Sous-titre"
"photos": {
"title": "Photos",
"image": "Image"
}
},
"fields": {
"videoUrl": "URL de la vidéo",
"phoneNumber": "Numéro de téléphone",
"email": "Email"
"videoUrl": "URL de la vidéo"
}
}
},
"es": {
"common": {
"save": "Guardar",
"edit": "Editar",
"cancel": "Cancelar",
"delete": "Eliminar"
},
"edit": "Editar",
"save": "Guardar",
"cancel": "Cancelar",
"creator": {
"sections": {
"about": {
"title": "Acerca de",
"description": "Descripción",
"mainImage": "Imagen principal",
"image1": "Imagen 1",
"image2": "Imagen 2",
"image3": "Imagen 3",
"image4": "Imagen 4",
"images": "Imágenes"
"description": "Descripción"
},
"support": {
"title": "Soporte",
"description": "Descripción",
"subtitle": "Subtítulo"
"photos": {
"title": "Fotos",
"image": "Imagen"
}
},
"fields": {
"videoUrl": "URL del video",
"phoneNumber": "Número de teléfono",
"email": "Correo electrónico"
"videoUrl": "URL del video"
}
}
}

View File

@@ -9,7 +9,7 @@
>
<img
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')"
>
<!-- 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 selectedFile = ref(null)
const fileUrl = ref(props.creator?.images?.banner)
const fileUrl = ref(props.creator?.bannerUrl)
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png'
const errorMessage = ref('')
const showCropper = ref(false)
@@ -175,7 +175,8 @@ const publish = async () => {
}
)
props.creator.images.banner = `${response.data.blobUrl}?t=${Date.now()}`
props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}`
fileUrl.value = props.creator.bannerUrl
emits('closeRequested')
} catch (error) {
console.error(error)
@@ -189,8 +190,8 @@ const publish = async () => {
const cancel = () => {
showCropper.value = false
// Reset to original state if we were editing
if (props.creator?.images?.banner) {
fileUrl.value = props.creator.images.banner
if (props.creator?.bannerUrl) {
fileUrl.value = props.creator.bannerUrl
selectedFile.value = null
} else {
fileUrl.value = fallbackUrl

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

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

View File

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

View File

@@ -94,7 +94,7 @@ const emits = defineEmits(['closeRequested'])
const fileInput = 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 errorMessage = ref('')
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')
} catch (error) {
console.error(error)
@@ -194,8 +197,8 @@ const publish = async () => {
const cancel = () => {
showCropper.value = false
// Reset to original state if we were editing
if (props.creator.images.logo) {
fileUrl.value = props.creator.images.logo
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl
selectedFile.value = null
} else {
fileUrl.value = fallbackUrl

View File

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

View File

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

View File

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