This commit is contained in:
2025-02-07 15:44:59 -05:00
parent 2b30479263
commit 009368ca8f
38 changed files with 1815 additions and 945 deletions

View File

@@ -8,6 +8,7 @@ public class ContentDbContext(
public DbSet<Content> Contents => Set<Content>(); public DbSet<Content> Contents => Set<Content>();
public DbSet<Creator> Creators => Set<Creator>(); public DbSet<Creator> Creators => Set<Creator>();
public DbSet<Slugs> Slugs => Set<Slugs>();
protected override void OnModelCreating( protected override void OnModelCreating(
ModelBuilder modelBuilder) ModelBuilder modelBuilder)
@@ -36,12 +37,12 @@ public class ContentDbContext(
.Property(c => c.ThumbnailUrl); .Property(c => c.ThumbnailUrl);
modelBuilder modelBuilder
.Entity<Creator>() .Entity<Slugs>()
.Property(x => x.NormalizedName) .Property(x => x.NormalizedName)
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", stored: true); .HasComputedColumnSql("LOWER( \"Content\".\"Slugs\".\"Name\")", stored: true);
modelBuilder modelBuilder
.Entity<Creator>() .Entity<Slugs>()
.HasIndex(x => x.NormalizedName) .HasIndex(x => x.NormalizedName)
.IsUnique(); .IsUnique();

View File

@@ -9,8 +9,7 @@ public class Creator
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
public bool AcceptDonation { get; set; } public bool AcceptDonation { get; set; }
public bool Verified { get; set; } public bool Verified { get; set; }
[MaxLength(255)] public string Name { get; set; } = null!; public Slugs Slugs { get; set; } = null!;
[MaxLength(255)] public string NormalizedName { get; set; } = null!;
[MaxLength(255)] public string? Title { get; set; } [MaxLength(255)] public string? Title { get; set; }
public Socials Socials { get; set; } = new(); public Socials Socials { get; set; } = new();
public Colors Colors { get; set; } = new(); public Colors Colors { get; set; } = new();

View File

@@ -0,0 +1,442 @@
// <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("20250131210849_AddSlug")]
partial class AddSlug
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.10")
.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<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(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
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<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid>("SlugsId")
.HasColumnType("uuid");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("Verified")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("SlugsId");
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Slugs", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
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.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("CreatedBy")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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.HasOne("Hutopy.Web.Features.Contents.Data.Slugs", "Slugs")
.WithMany()
.HasForeignKey("SlugsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Surface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.HasKey("CreatorId");
b1.ToTable("Colors", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Logo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Images", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
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(2000)
.HasColumnType("character varying(2000)");
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(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "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(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("PresentationInfos")
.IsRequired();
b.Navigation("Slugs");
b.Navigation("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddSlug : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Creators_NormalizedName",
schema: "Content",
table: "Creators");
migrationBuilder.DropColumn(
name: "NormalizedName",
schema: "Content",
table: "Creators");
// Add SlugsId column to Creators (temporary nullable to avoid issues while updating)
migrationBuilder.AddColumn<Guid>(
name: "SlugsId",
schema: "Content",
table: "Creators",
type: "uuid",
nullable: true);
// Create the Slugs table
migrationBuilder.CreateTable(
name: "Slugs",
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),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, computedColumnSql: "LOWER( \"Content\".\"Slugs\".\"Name\")", stored: true),
ReservedUntil = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Active = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Slugs", x => x.Id);
});
// Create Slugs for existing Creators
migrationBuilder.Sql(@"
INSERT INTO ""Content"".""Slugs"" (""Id"", ""Name"", ""CreatedBy"", ""CreatedAt"", ""ReservedUntil"", ""Active"")
SELECT ""Id"", ""Name"", ""CreatedBy"", ""CreatedAt"", ""CreatedAt"", TRUE
FROM ""Content"".""Creators""
WHERE ""Name"" IS NOT NULL
");
// Update Creators to reference Slugs
migrationBuilder.Sql(@"
UPDATE ""Content"".""Creators""
SET ""SlugsId"" = (SELECT ""Id"" FROM ""Content"".""Slugs"" WHERE ""Content"".""Slugs"".""Name"" = ""Content"".""Creators"".""Name"")
WHERE ""Name"" IS NOT NULL
");
// Make SlugsId non-nullable
migrationBuilder.AlterColumn<Guid>(
name: "SlugsId",
schema: "Content",
table: "Creators",
type: "uuid",
nullable: false,
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
// Create index for SlugsId
migrationBuilder.CreateIndex(
name: "IX_Creators_SlugsId",
schema: "Content",
table: "Creators",
column: "SlugsId");
// Create index for NormalizedName in Slugs
migrationBuilder.CreateIndex(
name: "IX_Slugs_NormalizedName",
schema: "Content",
table: "Slugs",
column: "NormalizedName",
unique: true);
// Add foreign key constraint
migrationBuilder.AddForeignKey(
name: "FK_Creators_Slugs_SlugsId",
schema: "Content",
table: "Creators",
column: "SlugsId",
principalSchema: "Content",
principalTable: "Slugs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
// Drop the Name column
migrationBuilder.DropColumn(
name: "Name",
schema: "Content",
table: "Creators");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Creators_Slugs_SlugsId",
schema: "Content",
table: "Creators");
migrationBuilder.DropTable(
name: "Slugs",
schema: "Content");
migrationBuilder.DropIndex(
name: "IX_Creators_SlugsId",
schema: "Content",
table: "Creators");
migrationBuilder.DropColumn(
name: "SlugsId",
schema: "Content",
table: "Creators");
migrationBuilder.AddColumn<string>(
name: "Name",
schema: "Content",
table: "Creators",
type: "character varying(255)",
maxLength: 255,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "NormalizedName",
schema: "Content",
table: "Creators",
type: "character varying(255)",
maxLength: 255,
nullable: false,
computedColumnSql: "LOWER( \"Content\".\"Creators\".\"Name\")",
stored: true);
migrationBuilder.CreateIndex(
name: "IX_Creators_NormalizedName",
schema: "Content",
table: "Creators",
column: "NormalizedName",
unique: true);
}
}
}

View File

@@ -86,17 +86,8 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b.Property<Guid>("CreatedBy") b.Property<Guid>("CreatedBy")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Name") b.Property<Guid>("SlugsId")
.IsRequired() .HasColumnType("uuid");
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true);
b.Property<string>("Title") b.Property<string>("Title")
.HasMaxLength(255) .HasMaxLength(255)
@@ -107,10 +98,47 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("SlugsId");
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Slugs", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
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.HasKey("Id");
b.HasIndex("NormalizedName") b.HasIndex("NormalizedName")
.IsUnique(); .IsUnique();
b.ToTable("Creators", "Content"); b.ToTable("Slugs", "Content");
}); });
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b => modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
@@ -158,6 +186,12 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{ {
b.HasOne("Hutopy.Web.Features.Contents.Data.Slugs", "Slugs")
.WithMany()
.HasForeignKey("SlugsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 => b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{ {
b1.Property<Guid>("CreatorId") b1.Property<Guid>("CreatorId")
@@ -394,6 +428,8 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b.Navigation("PresentationInfos") b.Navigation("PresentationInfos")
.IsRequired(); .IsRequired();
b.Navigation("Slugs");
b.Navigation("Socials") b.Navigation("Socials")
.IsRequired(); .IsRequired();
}); });

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Contents.Data;
public class Slugs
{
public Guid Id { get; set; }
public Guid CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; init; }
[MaxLength(128)] public string Name { get; set; } = null!;
[MaxLength(128)] public string NormalizedName { get; set; } = null!;
public DateTimeOffset ReservedUntil { get; set; }
public bool Active { get; set; }
}

View File

@@ -1,4 +1,5 @@
using Hutopy.Web.Features.Contents.Data; using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Web.Features.Contents; namespace Hutopy.Web.Features.Contents;
@@ -10,6 +11,7 @@ public static class DependencyInjection
{ {
builder.Services.AddDbContext<ContentDbContext>(configureAction); builder.Services.AddDbContext<ContentDbContext>(configureAction);
builder.Services.AddScoped<ContentDbContextInitializer>(); builder.Services.AddScoped<ContentDbContextInitializer>();
builder.Services.Configure<ContentOptions>(builder.Configuration.GetSection(ContentOptions.ConfigurationSection));
return builder; return builder;
} }

View File

@@ -0,0 +1,8 @@
namespace Hutopy.Web.Features.Contents.Handlers;
public class ContentOptions
{
public const string ConfigurationSection = "Contents";
public TimeSpan SlugReservationDuration { get; set; }
}

View File

@@ -67,7 +67,7 @@ public sealed class PostContentHtml(
{ {
Id = c.Id, Id = c.Id,
CreatedBy = c.CreatedBy, CreatedBy = c.CreatedBy,
CreatedByName = c.Creator!.Name, CreatedByName = c.Creator!.Slugs.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo, CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedAt = c.CreatedAt, CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy, DeletedBy = c.DeletedBy,

View File

@@ -1,28 +1,27 @@
using System.Net; using Hutopy.Web.Common.Security;
using FluentValidation.Results;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data; using Hutopy.Web.Features.Contents.Data;
using Npgsql;
namespace Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI] [PublicAPI]
public record CreateCreatorRequest( public record CreateCreatorRequest(
Guid CreatorId, Guid SlugReservationId,
string Name); Guid CreatorId);
[UsedImplicitly] [UsedImplicitly]
public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorRequest> public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorRequest>
{ {
public CreateCreatorRequestValidator() public CreateCreatorRequestValidator()
{ {
RuleFor(r => r.CreatorId) RuleFor(r => r.SlugReservationId)
.NotNull().WithMessage("You should specify the CreatorId") .NotNull()
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId"); .NotEmpty()
.WithMessage("You should specify a valid Name");
RuleFor(r => r.Name) RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the Name") .NotNull()
.NotEmpty().WithMessage("You should specify a valid/not empty Name"); .NotEmpty()
.WithMessage("You should specify a valid CreatorId");
} }
} }
@@ -41,14 +40,30 @@ public sealed class CreateCreatorHandler(
CreateCreatorRequest req, CreateCreatorRequest req,
CancellationToken ct) CancellationToken ct)
{ {
await using var transaction = await context.Database.BeginTransactionAsync(ct);
try try
{ {
var slug = await context
.Slugs
.SingleAsync(s => s.Id == req.SlugReservationId, ct);
if (slug.Active == false
&& slug.ReservedUntil >= DateTime.Now
&& slug.CreatedBy == User.GetUserId())
{
await SendErrorsAsync(500, ct);
return;
}
slug.Active = true;
await context.Creators.AddAsync( await context.Creators.AddAsync(
new Creator new Creator
{ {
Id = req.CreatorId, Id = req.CreatorId,
CreatedBy = User.GetUserId(), CreatedBy = User.GetUserId(),
Name = req.Name, Slugs = slug,
Colors = Colors =
{ {
Primary = "#A30E79", Primary = "#A30E79",
@@ -67,25 +82,13 @@ public sealed class CreateCreatorHandler(
await context.SaveChangesAsync(ct); await context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
await SendOkAsync(ct); await SendOkAsync(ct);
} }
catch (Exception e) catch (Exception e)
{ {
if (e.InnerException is PostgresException innerException) await transaction.RollbackAsync(ct);
{
if (innerException.ConstraintName == "IX_Creators_NormalizedName")
{
await SendResultAsync(new ProblemDetails(
[new ValidationFailure(nameof(Creator.Name), "The name is already taken.")],
(int)HttpStatusCode.Conflict));
}
}
else
{
await SendResultAsync(new ProblemDetails(
[new ValidationFailure(nameof(Creator.Name), e.Message)],
(int)HttpStatusCode.Conflict));
}
} }
} }
} }

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
using Hutopy.Web.Features.Contents.Data; using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Web.Features.Contents.Handlers;
@@ -10,18 +9,31 @@ public sealed class GetCreatorByAliasRequest
} }
[PublicAPI] [PublicAPI]
public record struct GetCreatorByAliasResponse( public class GetCreatorByAliasResponse(
Guid Id, Guid id,
Guid CreatedBy, Guid createdBy,
DateTimeOffset CreatedAt, DateTimeOffset createdAt,
bool Verified, bool verified,
bool AcceptDonation, bool acceptDonation,
string Name, string name,
string? Title, string? title,
Socials Socials, Socials socials,
Colors Colors, Colors colors,
PresentationInfos PresentationInfos, PresentationInfos presentationInfos,
Images Images); Images images)
{
public Guid Id { get; } = id;
public Guid CreatedBy { get; } = createdBy;
public DateTimeOffset CreatedAt { get; } = createdAt;
public bool Verified { get; } = verified;
public bool AcceptDonation { get; } = acceptDonation;
public string Name { get; } = name;
public string? Title { get; } = title;
public Socials Socials { get; } = socials;
public Colors Colors { get; } = colors;
public PresentationInfos PresentationInfos { get; } = presentationInfos;
public Images Images { get; } = images;
}
[UsedImplicitly] [UsedImplicitly]
public sealed class GetCreatorByAliasRequestValidator public sealed class GetCreatorByAliasRequestValidator
@@ -55,8 +67,22 @@ public class GetCreatorByAliasHandler(
var creator = await context var creator = await context
.Creators .Creators
.Where(c => EF.Functions.ILike(c.Name, creatorName)) .Where(c => EF.Functions.ILike(c.Slugs.Name, creatorName))
.FirstOrDefaultAsync(ct); .AsNoTracking()
.Select(c => new GetCreatorByAliasResponse
(
c.Id,
c.CreatedBy,
c.CreatedAt,
c.Verified,
c.AcceptDonation,
c.Slugs.NormalizedName,
c.Title,
c.Socials,
c.Colors,
c.PresentationInfos,
c.Images))
.SingleOrDefaultAsync(ct);
if (creator is null) if (creator is null)
{ {
@@ -64,20 +90,7 @@ public class GetCreatorByAliasHandler(
} }
else else
{ {
var model = new GetCreatorByAliasResponse( await SendAsync(creator, cancellation: ct);
creator.Id,
creator.CreatedBy,
creator.CreatedAt,
creator.Verified,
creator.AcceptDonation,
creator.Name,
creator.Title,
creator.Socials,
creator.Colors,
creator.PresentationInfos,
creator.Images);
await SendAsync(model, cancellation: ct);
} }
} }
} }

View File

@@ -4,10 +4,26 @@ using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetCreatorProfileResponse
{
public Guid Id { get; set; }
public Guid CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Title { get; set; }
public string Name { get; set; }
public bool Verified { get; set; }
public bool AcceptDonation { get; set; }
public Colors Colors { get; set; }
public Images Images { get; set; }
public PresentationInfos PresentationInfos { get; set; }
public Socials Socials { get; set; }
}
[PublicAPI] [PublicAPI]
public class GetCreatorProfileHandler( public class GetCreatorProfileHandler(
ContentDbContext context) ContentDbContext context)
: EndpointWithoutRequest<Creator> : EndpointWithoutRequest<GetCreatorProfileResponse>
{ {
public override void Configure() public override void Configure()
{ {
@@ -21,9 +37,23 @@ public class GetCreatorProfileHandler(
{ {
var creator = await context var creator = await context
.Creators .Creators
.FindAsync( .Where(c => c.Id == HttpContext.User.GetUserId())
[HttpContext.User.GetUserId()], .AsNoTracking()
cancellationToken: ct); .Select(c => new GetCreatorProfileResponse
{
Id = c.Id,
CreatedBy = c.CreatedBy,
CreatedAt = c.CreatedAt,
Title = c.Title,
Name = c.Slugs.NormalizedName,
Verified = c.Verified,
AcceptDonation = c.AcceptDonation,
Colors = c.Colors,
Images = c.Images,
PresentationInfos = c.PresentationInfos,
Socials = c.Socials,
})
.SingleOrDefaultAsync(ct);
if (creator is null) await SendNotFoundAsync(ct); if (creator is null) await SendNotFoundAsync(ct);
else await SendAsync(creator, cancellation: ct); else await SendAsync(creator, cancellation: ct);

View File

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

View File

@@ -0,0 +1,95 @@
using System.Net;
using FluentValidation.Results;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.Extensions.Options;
using Npgsql;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ReserveSlugRequest
{
public string Slug { get; set; } = null!;
public required Guid ReservationId { get; set; }
}
[PublicAPI]
public sealed class ReserveSlugRequestValidator : Validator<ReserveSlugRequest>
{
public ReserveSlugRequestValidator()
{
RuleFor(r => r.Slug)
.NotEmpty()
.NotNull()
.WithMessage("You should specify a valid Slug");
}
}
[PublicAPI]
public sealed class ReserveSlug(
ContentDbContext context,
IOptions<ContentOptions> opts)
: Endpoint<ReserveSlugRequest>
{
public override void Configure()
{
Post("/api/creators/@{Slug}/reserve");
Options(o => o.WithTags("Contents"));
}
public override async Task HandleAsync(
ReserveSlugRequest req,
CancellationToken ct)
{
await using var transaction = await context.Database.BeginTransactionAsync(ct);
try
{
await context.Slugs.AddAsync(
new Slugs
{
Id = req.ReservationId,
Active = false,
Name = req.Slug,
ReservedUntil = DateTimeOffset.UtcNow + opts.Value.SlugReservationDuration,
CreatedBy = User.GetUserId(),
},
cancellationToken: ct);
await context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
await SendOkAsync(new { Message = "Slug reserved." }, ct);
}
catch (Exception e)
{
await transaction.RollbackAsync(ct);
Logger.LogError("Transaction failed: {Message}", e.Message);
if (e.InnerException is PostgresException innerException)
{
if (innerException.ConstraintName == "IX_Slugs_NormalizedName")
{
await SendResultAsync(new ProblemDetails(
[
new ValidationFailure(nameof(Slugs.Name),
"The name is already taken.")
],
(int)HttpStatusCode.Conflict));
}
}
else
{
await SendResultAsync(new ProblemDetails(
[
new ValidationFailure(nameof(Slugs.Name),
e.Message)
],
(int)HttpStatusCode.Conflict));
}
}
}
}

View File

@@ -12,5 +12,8 @@
"Jwt": { "Jwt": {
"Lifetime": "00:30:00" "Lifetime": "00:30:00"
} }
},
"Contents": {
"SlugReservationDuration": "00:05:00"
} }
} }

View File

@@ -25,7 +25,7 @@ import LoginView from '../views/LoginView.vue';
import PaymentCompleted from '../views/PaymentCompleted.vue'; import PaymentCompleted from '../views/PaymentCompleted.vue';
import Home from '../views/main/Home.vue'; import Home from '../views/main/Home.vue';
import Wallet from '../views/main/Wallet.vue'; import Wallet from '../views/main/Wallet.vue';
import CreateCreator from "@/views/profile/creators/CreateCreator.vue"; import CreateCreator from "@/views/creators/CreateCreator.vue";
const routes = [ const routes = [
{ {

View File

@@ -24,6 +24,7 @@ export const useAuthStore = defineStore(
const refreshToken = useSessionStorage('auth-refreshToken', undefined) const refreshToken = useSessionStorage('auth-refreshToken', undefined)
const isAuthenticated = computed(() => !!accessToken.value) const isAuthenticated = computed(() => !!accessToken.value)
const userId = computed(() => { const userId = computed(() => {
const claims = getClaimsFromToken(accessToken.value) const claims = getClaimsFromToken(accessToken.value)
return claims.sub; return claims.sub;
@@ -99,4 +100,3 @@ export const useAuthStore = defineStore(
return {accessToken, refreshToken, isAuthenticated, userId, login, loginWithGoogle, logout} return {accessToken, refreshToken, isAuthenticated, userId, login, loginWithGoogle, logout}
}) })

View File

@@ -41,8 +41,8 @@ export const useBrandingStore = defineStore(
if (newCreator !== undefined) { if (newCreator !== undefined) {
value.value = await fetchCreatorData(newCreator) value.value = await fetchCreatorData(newCreator)
currentBrand.value = newCreator currentBrand.value = newCreator
colors.value = value.value.colors colors.value = value.value?.colors
presentationInfos.value = value.value.presentationInfos presentationInfos.value = value.value?.presentationInfos
} else { } else {
value.value = {} value.value = {}
currentBrand.value = undefined currentBrand.value = undefined

View File

@@ -5,17 +5,16 @@ import { defineStore } from 'pinia';
import {computed, watch} from 'vue'; import {computed, watch} from 'vue';
import {useRouter} from 'vue-router'; import {useRouter} from 'vue-router';
export const useCreatorProfileStore = defineStore('creator-profile', () => { export const useCreatorProfileStore = defineStore(
'creator-profile',
() => {
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
watch( watch(
() => authStore.isAuthenticated, () => authStore.isAuthenticated,
async (newValue) => { async (newValue) => {
if (newValue) { if (newValue) {
await fetchCurrentCreatorProfile(); await fetchCurrentCreatorProfile();
if (value.value === undefined) { if (value.value === undefined) {
await router.push('/'); await router.push('/');
} else { } else {
@@ -29,7 +28,7 @@ export const useCreatorProfileStore = defineStore('creator-profile', () => {
const value = useSessionStorage( const value = useSessionStorage(
'creator-profile', 'creator-profile',
undefined, {},
{writeDefaults: false} {writeDefaults: false}
); );
@@ -42,9 +41,13 @@ export const useCreatorProfileStore = defineStore('creator-profile', () => {
async function fetchCurrentCreatorProfile() { async function fetchCurrentCreatorProfile() {
try { try {
const creatorResponse = await client.get(`/api/creators/profile`); const creatorResponse = await client.get(`/api/creators/profile`);
console.log('creatorProfile');
console.dir(creatorResponse.data)
value.value = creatorResponse.data; value.value = creatorResponse.data;
console.dir(value.value);
// TODO: no cache-busting ??? // TODO: no cache-busting ???
} catch (error) { } catch (error) {
console.log(`!!!`)
value.value = undefined; value.value = undefined;
} }
} }

View File

@@ -0,0 +1,65 @@
<template>
<div class="relative">
<!-- Banner Container with mouse events -->
<div
class="relative"
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
@click="isCurrentCreator && openBannerEditor()"
>
<img
class="w-full drop-shadow-[0_10px_6px_rgba(0,0,0,0.25)] h-60"
:src="brandingStore.value.images.banner ? brandingStore.value.images.banner : '/images/placeholders/banner.png'"
alt="Profile Banner"
>
<!-- Tint Effect -->
<div
v-if="showTint"
class="absolute inset-0 bg-black/25 cursor-pointer"
>
<!-- Top-right Icon -->
<div
class="absolute top-4 right-4 w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-lg"
>
<v-icon large>mdi-pencil</v-icon>
</div>
</div>
</div>
</div>
<v-dialog v-model="isDialogOpen" max-width="800px">
<template #default="{ close }">
<div class="bg-white rounded-2xl p-4">
<banner-editor :creator="brandingStore.value"
@closeRequested="() => isDialogOpen = false"
></banner-editor>
</div>
</template>
</v-dialog>
</template>
<script setup>
import BannerEditor from "@/views/creators/BannerEditor.vue";
import {computed, ref} from "vue";
import {useBrandingStore} from "@/stores/brandingStore.js";
import {useAuthStore} from "@/stores/authStore.js";
const authStore = useAuthStore();
const brandingStore = useBrandingStore();
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script>

View File

@@ -0,0 +1,25 @@
<template>
<!-- PC -->
<div class="shadow-lg rounded-2xl mt-2">
<div class="relative z-20">
<div class="min-h-8 rounded-t-2xl shadow-lg"
:style="{ backgroundColor: branding.colors.primary }"
></div>
<actual-banner></actual-banner>
<banner-actions></banner-actions>
</div>
</div>
</template>
<script setup>
import {useBrandingStore} from "@/stores/brandingStore.js";
import ActualBanner from "@/views/creators/ActualBanner.vue";
import BannerActions from "@/views/creators/BannerActions.vue";
const branding = useBrandingStore();
</script>

View File

@@ -3,7 +3,8 @@ import { useClient } from '@/plugins/api.js';
import {useBrandingStore} from '@/stores/brandingStore.js'; import {useBrandingStore} from '@/stores/brandingStore.js';
import DonationButtonBanner from '@/views/creators/DonationButtonBanner.vue'; import DonationButtonBanner from '@/views/creators/DonationButtonBanner.vue';
import {onBeforeUnmount, onMounted, ref} from 'vue'; import {onBeforeUnmount, onMounted, ref} from 'vue';
import IconAccountVerified from "@/components/icons/IconAccountVerified.vue"; import CreatorLogo from "@/views/creators/CreatorLogo.vue";
import NameTitle from "@/views/creators/NameTitle.vue";
const brandingStore = useBrandingStore(); const brandingStore = useBrandingStore();
const isMobile = ref(false); const isMobile = ref(false);
@@ -109,92 +110,23 @@ onBeforeUnmount(() => {
<div class="flex flex-column w-full"> <div class="flex flex-column w-full">
<!-- Container principal avec le profil --> <!-- Container principal avec le profil -->
<div class="relative w-full shadow-xl rounded-2xl"> <div class="relative w-full shadow-xl rounded-2xl">
<div <div class="rounded-b-2xl shadow-2xl"
ref="mainContainer"
class="rounded-b-2xl shadow-2xl"
:style="{ :style="{
backgroundColor: brandingStore.colors.primary, backgroundColor: brandingStore.colors.primary,
boxShadow: '0 5px 10px rgba(0, 0, 0, 0.3)', boxShadow: '0 5px 10px rgba(0, 0, 0, 0.3)',
}" }">
>
<div> <div class="flex flex-row p-2">
<!-- Profile et Info --> <!-- Profile et Info -->
<div> <div>
<!-- Version PC --> <creator-logo/>
<div v-show="!isMobile" class="items-start">
<div>
<img
class="shadow-2xl rounded-full border-solid border-102 absolute z-20 max-w-[190px] ml-10 -mt-5"
:src="
brandingStore.value.images.logo
? brandingStore.value.images.logo
: '/images/placeholders/logo.png'
"
alt="Profile Picture"
:style="{
borderColor: brandingStore.colors.secondary,
height: '190px',
}"
/>
</div>
<div
class="ml-64 w-25 min-w-60 flex flex-row"
:style="{ color: brandingStore.colors.onPrimary }"
>
<div v-show="brandingStore.value.verified" class="text-blue m-4 align-content-center verifiedhook">
<icon-account-verified></icon-account-verified>
</div> </div>
<div class="flex flex-col"> <div class="flex items-center">
<span class="capitalize text-3xl titlepos"> <name-title></name-title>
{{ brandingStore.value.name }}
</span>
<span class="capitalize text-lg titlepos">
{{ brandingStore.value.title }}
</span>
</div>
</div>
</div> </div>
<!-- Version Mobile -->
<div class="relative">
<div
:style="{
borderColor: brandingStore.colors.secondary,
height: '80px',
}"
>
<div
v-show="isMobile"
class="absolute -top-7 left-0 px-3 flex flex-row items-center z-30"
>
<div>
<img
class="shadow-2xl rounded-full border-solid z-20 max-w-[150px]"
:src="
brandingStore.value.images.logo
? brandingStore.value.images.logo
: '/images/placeholders/logo.png'
"
alt="Profile Picture"
:style="{ height: '135px' }"
/>
</div>
<div v-show="brandingStore.value.verified" class="text-blue m-4 align-content-center">
<icon-account-verified></icon-account-verified>
</div>
<div class="ml-3 text-white w-full flex flex-col items-start">
<p class="capitalize text-2xl">
{{ brandingStore.value.name }}
</p>
<p class="capitalize text-md">
{{ brandingStore.value.title }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Actions - Follow et Register --> <!-- Actions - Follow et Register -->
<!-- <div class="flex flex-col items-center justify-center w-full">--> <!-- <div class="flex flex-col items-center justify-center w-full">-->
@@ -258,28 +190,3 @@ onBeforeUnmount(() => {
</div> </div>
</template> </template>
<style scoped>
.nav-button {
@apply rounded flex justify-center font-sans py-1 text-white tracking-widest p-4;
}
.nav-button:hover {
@apply bg-purple-800;
}
/* Transition CSS */
.transition-all {
transition: all 0.3s ease-in-out;
}
.titlepos {
position: relative;
top: 30px;
}
.verifiedhook{
position: relative;
top: 16px;
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<h2 class="text-2xl font-semibold mb-4 flex justify-center"> <h2 class="text-2xl font-semibold mb-4">
Bannière Bannière
</h2> </h2>
@@ -36,7 +36,7 @@ const props = defineProps({
const emits = defineEmits(['closeRequested']) const emits = defineEmits(['closeRequested'])
const selectedFile = ref({}) const selectedFile = ref({})
const fileUrl = ref(props.creator.images.banner) const fileUrl = ref(props.creator?.images?.banner)
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png' const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png'
const onFileSelected = () => { const onFileSelected = () => {

View File

@@ -1,27 +1,37 @@
<script setup> <script setup>
import {ref} from 'vue' import {computed, ref, watch} from 'vue'
import {useUserProfileStore} from "@/stores/userProfileStore.js"; import {useUserProfileStore} from "@/stores/userProfileStore.js";
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js"; import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
import {useClient} from "@/plugins/api.js"; import {useClient} from "@/plugins/api.js";
import {useRouter} from "vue-router"; import {useRouter} from "vue-router";
import NameEditor from "@/views/creators/NameEditor.vue";
const creatorName = ref(''); const creatorName = ref('');
const creatorNameReservationId = ref(undefined);
const canSave = computed(() => creatorNameReservationId.value !== undefined)
const isOperationPending = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const isLoading = ref(false);
const router = useRouter(); const router = useRouter();
const creatorProfileStore = useCreatorProfileStore(); const creatorProfileStore = useCreatorProfileStore();
const userProfileStore = useUserProfileStore(); const userProfileStore = useUserProfileStore();
function handleCreatorNameReservationIdChanged($event) {
console.log(`in handleCreatorNameReservationIdChanged: ${$event.value}`);
creatorNameReservationId.value = $event.value
}
async function createAccount() { async function createAccount() {
const client = useClient();
try { try {
isOperationPending.value = true;
const client = useClient();
errorMessage.value = ''; errorMessage.value = '';
isLoading.value = true;
const normalizedCreatorName = creatorName.value.toLowerCase(); const normalizedCreatorName = creatorName.value.toLowerCase();
await client.post('/api/creators', { await client.post('/api/creators', {
creatorId: userProfileStore.user.id, creatorId: userProfileStore.user.id,
name: normalizedCreatorName, name: normalizedCreatorName,
slugReservationId: creatorNameReservationId.value,
}); });
await creatorProfileStore.fetchCurrentCreatorProfile(); await creatorProfileStore.fetchCurrentCreatorProfile();
await router.push(`/@${normalizedCreatorName}`); await router.push(`/@${normalizedCreatorName}`);
@@ -32,38 +42,37 @@ async function createAccount() {
errorMessage.value = error?.response?.data?.message || error.message || 'An unexpected error occurred.'; errorMessage.value = error?.response?.data?.message || error.message || 'An unexpected error occurred.';
} }
} finally { } finally {
isLoading.value = false; isOperationPending.value = false;
} }
} }
</script> </script>
<template> <template>
<div>
<div class="create-creator-card"> <div class="create-creator-card">
<div class="py-2 text-3xl font-bold">
<div class="text-center mb-10">Créez votre Hutopy.</div> <div class="py-2 text-3xl font-bold text-center mb-10">
Créez votre Hutopy.
</div> </div>
<div class="flex flex-column justify-end gap-2"> <div class="flex flex-column justify-end gap-2">
<v-alert <v-alert
v-if="!!errorMessage" v-if="!!errorMessage"
dense
outlined outlined
text
type="error" type="error"
> >
{{ errorMessage }} {{ errorMessage }}
</v-alert> </v-alert>
<v-text-field <name-editor
variant="outlined" v-model:name="creatorName"
v-model="creatorName" creator-name-reservation-id="creatorNameDirty"
label="Nom de la page" @update:creator-name-reservation-id="handleCreatorNameReservationIdChanged($event)"
outlined ></name-editor>
></v-text-field>
<div class="flex flex-row justify-end gap-2">
<div class="flex flex-row justify-end gap-2">
<v-btn <v-btn
:disabled="isLoading" :disabled="!canSave"
variant="outlined" variant="outlined"
@click="createAccount" @click="createAccount"
:style="{ borderColor: 'rgb(159, 76, 173)', color: 'rgb(159, 76, 173)' }" :style="{ borderColor: 'rgb(159, 76, 173)', color: 'rgb(159, 76, 173)' }"
@@ -71,7 +80,7 @@ async function createAccount() {
Créer Créer
</v-btn> </v-btn>
</div> </div>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,94 +0,0 @@
<template>
<!-- PC -->
<div v-if="!isMobile">
<div class="shadow-lg rounded-2xl mt-2">
<div class="relative z-20">
<div class="min-h-8 rounded-t-2xl shadow-lg" :style="{ backgroundColor: branding.colors.primary }"></div>
<!-- Banner -->
<div class="relative">
<div>
<img
class="w-full drop-shadow-[0_10px_6px_rgba(0,0,0,0.25)]"
:src="branding.value.images.banner ? branding.value.images.banner : '/images/placeholders/banner.png'"
alt="Profile Banner"
style="max-height: 425px"
>
</div>
</div>
</div>
<banner-actions></banner-actions>
</div>
</div>
<!-- Mobile -->
<div v-if="isMobile">
<div class="shadow-lg rounded-2xl ">
<div class="relative z-20">
<div class="shadow-2xl flex items-center px-2 py-2"
:style="{ backgroundColor: branding.colors.primary, color: branding.colors.onPrimary }">
<router-link to="/@Hutopy">
<div class="flex items-center">
<HutopySvg></HutopySvg>
<div class="text-xl font-bold -ml-2 ">Hutopy</div>
</div>
</router-link>
<div class="flex-1"></div>
<router-link to="/login">
<button class="lg:hidden flex items-center justify-center mr-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
:stroke="branding.colors.onPrimary" class="w-8 h-8">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</router-link>
</div>
<!-- Banner -->
<div class="relative">
<div>
<img
class="w-full drop-shadow-[0_10px_6px_rgba(0,0,0,0.25)]"
:src="branding.value.images.banner ? branding.value.images.banner : '/images/placeholders/banner.png'"
alt="Profile Banner"
style="max-height: 425px"
>
</div>
</div>
</div>
<banner-actions></banner-actions>
</div>
</div>
</template>
<script setup>
import {ref, onMounted, onBeforeUnmount} from "vue";
import BannerActions from "@/views/creators/BannerActions.vue";
import {useBrandingStore} from "@/stores/brandingStore.js";
import HutopySvg from "@/views/svg/HutopySvg.vue";
const branding = useBrandingStore();
const isMobile = ref(false);
function updateIsMobile() {
isMobile.value = window.innerWidth <= 640;
}
onMounted(() => {
updateIsMobile();
window.addEventListener("resize", updateIsMobile);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateIsMobile);
});
</script>
<style>
</style>

View File

@@ -5,7 +5,7 @@
<v-progress-linear indeterminate></v-progress-linear> <v-progress-linear indeterminate></v-progress-linear>
</div> </div>
<div v-else> <div v-else>
<creator-banner></creator-banner> <banner></banner>
</div> </div>
<div class="py-8 flex-grow"> <div class="py-8 flex-grow">
<router-view></router-view> <router-view></router-view>
@@ -20,7 +20,7 @@
</template> </template>
<script async setup> <script async setup>
import CreatorBanner from "@/views/creators/CreatorBanner.vue"; import Banner from "@/views/creators/Banner.vue";
import Footer from "@/views/main/Footer.vue"; import Footer from "@/views/main/Footer.vue";
import {useBrandingStore} from "@/stores/brandingStore.js"; import {useBrandingStore} from "@/stores/brandingStore.js";
const brandingStore = useBrandingStore() const brandingStore = useBrandingStore()

View File

@@ -0,0 +1,70 @@
<template>
<div class="rounded-full relative bg-red"
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
@click="isCurrentCreator && openBannerEditor()"
>
<img
class="shadow-2xl rounded-full border-solid border-102 max-w-[190px]"
:src="brandingStore.value.images.logo
? brandingStore.value.images.logo
: '/images/placeholders/logo.png'"
alt="Profile Picture"
:style="{
borderColor: brandingStore.colors.secondary,
height: '190px',
}"
/>
<!-- Tint Effect -->
<div
v-if="showTint"
class="absolute rounded-full inset-0 bg-black/25 cursor-pointer"
>
<!-- Top-right Icon -->
<div
class="absolute top-4 right-4 w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-lg"
>
<v-icon large>mdi-pencil</v-icon>
</div>
</div>
</div>
<v-dialog v-model="isDialogOpen" max-width="800px">
<template #default="{ close }">
<div class="bg-white rounded-2xl p-4">
<creator-logo-editor
:creator="brandingStore?.value"
@closeRequested="() => isDialogOpen = false"
></creator-logo-editor>
</div>
</template>
</v-dialog>
</template>
<script setup>
import {useAuthStore} from "@/stores/authStore.js";
import {useBrandingStore} from "@/stores/brandingStore.js";
import CreatorLogoEditor from "@/views/creators/CreatorLogoEditor.vue";
import {computed, ref} from "vue";
const authStore = useAuthStore();
const brandingStore = useBrandingStore();
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script>

View File

@@ -23,6 +23,7 @@
<v-btn color="black" variant="text" @click="cancel">Annuler</v-btn> <v-btn color="black" variant="text" @click="cancel">Annuler</v-btn>
<v-btn color="#A6147D" @click="publish">Enregistrer</v-btn> <v-btn color="#A6147D" @click="publish">Enregistrer</v-btn>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,89 @@
<script setup>
import {computed, ref} from "vue";
import {v7} from "uuid";
import {useClient} from "@/plugins/api.js";
const props = defineProps({
name: {
required: true
},
creatorNameReservationId: {
required: true
}
});
const emits = defineEmits([
'update:name',
'update:creatorNameReservationId'
]);
const name = ref(props.name);
const isReserved = computed(() => reservationState.value === 'reserved');
const isOperationPending = ref(false);
const reservationState = ref(null);
const reservationId = ref(null);
let timeout = null;
const handleInput = () => {
clearTimeout(timeout);
timeout = setTimeout(() =>
checkNameAvailability(),
200);
};
const client = useClient()
const checkNameAvailability = async () => {
if (!name.value || name.value.trim() === "") {
reservationState.value = null;
return;
}
try {
const id = v7();
isOperationPending.value = true;
reservationState.value = "loading";
await client.post(
`/api/creators/@${encodeURIComponent(name.value)}/reserve`,
{reservationId: id}
);
reservationState.value = "reserved";
reservationId.value = id;
} catch (error) {
reservationState.value = "unavailable"; // Handle API failure case
reservationId.value = undefined;
} finally {
emits('update:name', name);
emits('update:creatorNameReservationId', reservationId);
isOperationPending.value = false;
}
};
</script>
<template>
<v-text-field
variant="outlined"
label="Nom de la page"
v-model="name"
outlined
@input="handleInput"
>
<template #append-inner>
<v-progress-circular
v-if="reservationState === 'loading'"
indeterminate
size="24"
width="3"
color="grey"
></v-progress-circular>
<v-icon v-else-if="reservationState === 'reserved'" color="green">mdi-check-circle</v-icon>
<v-icon v-else-if="reservationState === 'unavailable'" color="red">mdi-close-circle</v-icon>
</template>
</v-text-field>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="relative">
<div class="relative flex flex-row"
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
@click="isCurrentCreator && openBannerEditor()"
>
<div v-show="brandingStore.value.verified"
class="text-blue m-4">
<icon-account-verified></icon-account-verified>
</div>
<div class="flex flex-col"
:style="{ color: brandingStore.colors.onPrimary }">
<span class="capitalize text-3xl">
{{ brandingStore.value.name }}
</span>
<span class="capitalize text-lg">
{{ brandingStore.value.title }}
</span>
</div>
<!-- Tint Effect -->
<div
v-if="showTint"
class="absolute inset-0 bg-black/25 cursor-pointer"
>
<!-- Top-right Icon -->
<div
class="absolute top-1 right-1 w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-lg"
>
<v-icon large>mdi-pencil</v-icon>
</div>
</div>
</div>
</div>
<v-dialog v-model="isDialogOpen" max-width="800px">
<template #default="{ close }">
<div class="bg-white rounded-2xl p-4">
<name-title-editor
:creator="brandingStore?.value"
@closeRequested="() => isDialogOpen = false"
></name-title-editor>
</div>
</template>
</v-dialog>
</template>
<script setup>
import IconAccountVerified from "@/components/icons/IconAccountVerified.vue";
import {useBrandingStore} from "@/stores/brandingStore.js";
import {useAuthStore} from "@/stores/authStore.js";
import {computed, ref} from "vue";
import NameTitleEditor from "@/views/creators/NameTitleEditor.vue";
const authStore = useAuthStore();
const brandingStore = useBrandingStore();
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script>

View File

@@ -0,0 +1,76 @@
<script setup>
import {computed, ref} from "vue";
import {useClient} from "@/plugins/api.js";
import NameTitle from "@/views/creators/NameTitle.vue";
import NameEditor from "@/views/creators/NameEditor.vue";
const props = defineProps({
creator: {
required: true
}
});
const emits = defineEmits(['closeRequested'])
const name = ref(props.creator.name);
const title = ref(props.creator.title);
const canSave = computed(() => name != props.creator.name);
const client = useClient()
const save = async () => {
try {
await client.post(`/api/creators/${props.creator.id}/name`);
await client.post(`/api/creators/${props.creator.id}/title`);
props.creator.creator.name = name;
props.creator.title.name = title;
emits('closeRequested')
} catch (error) {
console.error(error)
}
}
function cancel() {
emits('closeRequested');
}
</script>
<template>
<div class="pb-5 text-2xl">
Modifier le Titre
</div>
<div class="flex flex-col space-y-4">
<name-editor
:name="name"
></name-editor>
<v-text-field
variant="outlined"
v-model="title"
label="Titre"
outlined
></v-text-field>
<div class="flex justify-end space-x-4">
<v-btn color="black"
variant="text"
@click="cancel">
Annuler
</v-btn>
<v-btn color="#A6147D"
:disabled="!canSave"
@click="save">
Enregistrer
</v-btn>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,12 +1,7 @@
<template> <template>
<div class="flex flex-col items-center w-[800px] gap-4">
<h1 class="uppercase pb-5 text-2xl"> <v-card rounded="xl" class="w-full">
<v-icon class="mr-2">mdi-information</v-icon>
{{ $t('personnalinformation.title') }}
</h1>
<v-card class="w-full">
<v-card-title> <v-card-title>
{{ $t('personnalinformation.informations') }} {{ $t('personnalinformation.informations') }}
</v-card-title> </v-card-title>
@@ -53,7 +48,7 @@
</v-card> </v-card>
<!-- Phone & email --> <!-- Phone & email -->
<v-card class="w-full"> <v-card rounded="xl" class="w-full">
<v-card-title> <v-card-title>
{{ $t('personnalinformation.contactdetails') }} {{ $t('personnalinformation.contactdetails') }}
</v-card-title> </v-card-title>
@@ -89,7 +84,6 @@
<!-- <span><v-icon>mdi-chevron-right</v-icon></span>--> <!-- <span><v-icon>mdi-chevron-right</v-icon></span>-->
<!-- </button>--> <!-- </button>-->
<!-- </v-card>--> <!-- </v-card>-->
</div>
<!-- Modal --> <!-- Modal -->
<v-dialog v-model="dialogEditPortraitShown" max-width="600px"> <v-dialog v-model="dialogEditPortraitShown" max-width="600px">
@@ -152,8 +146,8 @@
<script setup> <script setup>
import {ref} from 'vue'; import {ref} from 'vue';
import AddressDialog from './AddressDialog.vue'; import AddressDialog from './account/AddressDialog.vue';
import EmailDialog from "./EmailDialog.vue"; import EmailDialog from "./account/EmailDialog.vue";
import PhoneDialog from "@/views/profile/account/PhoneDialog.vue"; import PhoneDialog from "@/views/profile/account/PhoneDialog.vue";
import BirthdayDialog from "@/views/profile/account/BirthdayDialog.vue"; import BirthdayDialog from "@/views/profile/account/BirthdayDialog.vue";
import AliasDialog from "@/views/profile/account/AliasDialog.vue"; import AliasDialog from "@/views/profile/account/AliasDialog.vue";

View File

@@ -0,0 +1,283 @@
<script setup>
import XIcon from '@/assets/icons/x.svg';
import {useCreatorProfileStore} from '@/stores/creatorProfileStore.js';
import ChangeStripeID from '@/views/profile/creators/ChangeStripeID.vue';
import ChangeTitle from '@/views/profile/creators/ChangeTitle.vue';
import {computed, ref} from 'vue';
import ColorsPicker from './creators/ColorsPicker.vue';
import LogoPicker from '../creators/CreatorLogoEditor.vue';
import Socials from './creators/Socials.vue';
const creatorProfileStore = useCreatorProfileStore();
const dialog = ref(false);
const currentComponent = ref('');
const componentsMap = {
LogoPicker,
Socials,
ColorsPicker,
ChangeTitle,
ChangeStripeID,
};
function requestCancel() {
currentComponent.value = null;
dialog.value = false;
}
const openDialog = (component) => {
currentComponent.value = componentsMap[component];
dialog.value = true;
};
const closeDialog = () => {
currentComponent.value = null;
dialog.value = false;
};
</script>
<template>
<v-dialog v-model="dialog" max-width="800px">
<v-card
:style="{ borderRadius: '25px', border: '3px solid rgb(159, 76, 173)' }"
>
<v-card-text>
<component
:is="currentComponent"
:creator="creatorProfileStore.creator"
@closeRequested="closeDialog"
@requestAccept="requestAccept"
@requestCancel="requestCancel"
></component>
</v-card-text>
</v-card>
</v-dialog>
<!-- Lorsque l'utilisateur n'a pas de creator name-->
<v-card rounded="xl" class="w-full">>
<h1 class="uppercase">
{{ $t('creatorinfopage.informations') }}
</h1>
<!-- INFOS -->
<div class="flex flex-col w-full">
<button
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 min-w-32 text-left">
{{ $t('creatorinfopage.name') }}
</span>
<span class="flex-auto text-left pr-6 capitalize">
{{ creatorProfileStore.creator.name }}
</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
</div>
<!-- TITLE -->
<div class="flex flex-col w-full">
<button
@click="openDialog('ChangeTitle')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 min-w-32 text-left">{{
$t('creatorinfopage.title')
}}</span>
<span class="flex-auto text-left pr-6 capitalize">{{
creatorProfileStore.creator.title
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
</div>
<!-- STRIPE -->
<div class="flex flex-col w-full">
<button
@click="openDialog('ChangeStripeID')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full rounded-b"
>
<span class="flex-none pa-2 min-w-32 text-left"
>Stripe Account ID</span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.stripeId
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
</div>
</v-card>
<v-card rounded="xl" class="w-full">
<div class="py-5 uppercase ml-4">
{{ $t('creatorinfopage.banner&profile') }}
</div>
<div class="flex flex-col w-full">
<button
@click="openDialog('ColorsPicker')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-auto text-left pr-6 capitalize">
Choisissez votre palette de couleurs.
</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
</div>
</v-card>
<v-card rounded="xl" class="w-full">
<div class="uppercase">
{{ $t('creatorinfopage.socialnetwork') }}
</div>
<div class="flex flex-col w-full">
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="pa-2 min-w-32 text-left">
<v-icon>mdi-facebook</v-icon>
</span>
<span class="flex-auto text-left pr-6">
{{ creatorProfileStore.creator.socials?.facebookUrl }}
</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 min-w-32 text-left">
<v-icon>mdi-instagram</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials?.instagramUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 w-9 h-9 text-left ml-0.5">
<XIcon></XIcon>
</span>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials?.xUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="pa-2 min-w-32 text-left"
><v-icon>mdi-linkedin</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials?.linkedInUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 min-w-32 text-left">
<XIcon class="w-5 h-5"></XIcon>
</span>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials?.tikTokUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="pa-2 min-w-32 text-left"
><v-icon>mdi-youtube</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials?.youtubeUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="pa-2 min-w-32 text-left"
><v-icon>mdi-reddit</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials?.redditUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full rounded-b"
>
<span class="pa-2 min-w-32 text-left"
><v-icon>mdi-web</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials?.websiteUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
</div>
</v-card>
</template>
<style>
.HoverBtn:hover {
@apply bg-[#A6147D] text-white;
@apply hover:opacity-90;
}
.custom-border {
border: 3px solid;
}
</style>

View File

@@ -1,147 +1,16 @@
<template> <template>
<!-- Mobile -->
<div v-if="isMobileView" class="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-6 text-center">
<!-- Image -->
<img src="/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png" alt="Image" class="w-64 h-64 rounded-full mb-4 border" />
<!-- Message --> <div class="bg-red flex flex-col gap-8 p-8">
<div class="text-lg text-gray-700 mt-8">
<p class="font-semibold mb-2">Pour vous connecter et modifier votre page, veuillez utiliser un appareil avec un écran plus large, comme un ordinateur.</p>
<p>Pour le moment, l'expérience sur téléphone n'est pas encore complétée.</p>
<p class="mt-4 font-bold">Désolé de l'inconvénient.</p>
</div>
</div>
<!-- PC -->
<div v-else>
<div class="flex flex-col md:flex-row bg-[#f4f4f4] h-full">
<!-- Left Menu -->
<div class=" z-20 w-full md:max-w-xs fixed md:sticky md:top-0 md:flex md:flex-col top-0">
<div class="sticky top-20 z-30">
<div class="flex flex-col items-center md:items-start md:pl-4 mt-16">
<h1 class="text-2xl py-4 font-bold text-center md:text-left">{{$t('profilemenu.manageyouraccount')}}</h1>
<div class="relative flex items-center md:mt-0 w-full">
<!-- Navigation buttons for small screens -->
<button @click="scrollLeftFunc"
class="rounded p-1 absolute left-2 z-10 md:hidden text-fuchsia-800 text-2xl ">
<v-icon>mdi-chevron-left</v-icon>
</button>
<div
ref="scrollContainer"
class="flex md:flex-col space-x-2 space-y-0 md:space-x-0 md:space-y-2 p-4 items-center md:items-start overflow-x-scroll md:overflow-x-visible mx-2 md:mx-0 custom-scroll min-w-[400px] px-1"
@mousedown="mouseDown"
@mouseleave="mouseLeave"
@mouseup="mouseUp"
@mousemove="mouseMove">
<v-btn variant="text" @click="currentComponent = 'CreatorPage'">
<v-icon class="mr-2">mdi-file-edit-outline</v-icon>
{{ $t('profilemenu.creator') }}
</v-btn>
<v-btn variant="text" @click="currentComponent = 'AccountPage'">
<v-icon class="mr-2">mdi-information</v-icon>
{{ $t('profilemenu.user') }}
</v-btn>
</div>
<button @click="scrollRightFunc"
class="rounded p-1 absolute right-2 z-10 md:hidden text-fuchsia-800 bg-[#f4f4f4] text-2xl">
<v-icon>mdi-chevron-right</v-icon>
</button>
</div>
</div>
</div>
</div>
<!-- Mid Content -->
<div class="flex flex-col flex-1 align-center py-12 p-3 mt-28 md:mt-0">
<template v-if="currentComponent === 'CreatorPage'">
<creator-page></creator-page>
</template>
<template v-else-if="currentComponent === 'AccountPage'">
<account-page></account-page> <account-page></account-page>
</template> <creator-page></creator-page>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch } from "vue"; import CreatorPage from "@/views/profile/CreatorPage.vue";
import CreatorPage from "@/views/profile/creators/CreatorPage.vue"; import AccountPage from "@/views/profile/AccountPage.vue";
import AccountPage from "@/views/profile/account/AccountPage.vue";
import { useRoute } from "vue-router";
import { useDisplay } from "vuetify";
const { smAndDown } = useDisplay();
const route = useRoute();
const startingComponent = route.query.target || 'CreatorPage';
const currentComponent = ref(startingComponent);
const isMobileView = ref(smAndDown.value);
watch(smAndDown, (newVal) => {
isMobileView.value = newVal;
});
// Gestion du slider (scroll sur petit écran)
const isDown = ref(false);
const startX = ref(0);
const scrollLeft = ref(0);
const mouseDown = (e) => {
const slider = document.querySelector('.custom-scroll');
isDown.value = true;
slider.classList.add('active');
startX.value = e.pageX - slider.offsetLeft;
scrollLeft.value = slider.scrollLeft;
};
const mouseLeave = () => {
isDown.value = false;
const slider = document.querySelector('.custom-scroll');
slider.classList.remove('active');
};
const mouseUp = () => {
isDown.value = false;
const slider = document.querySelector('.custom-scroll');
slider.classList.remove('active');
};
const mouseMove = (e) => {
if (!isDown.value) return;
e.preventDefault();
const slider = document.querySelector('.custom-scroll');
const x = e.pageX - slider.offsetLeft;
const walk = (x - startX.value) * 3; // scroll-fast
slider.scrollLeft = scrollLeft.value - walk;
};
const scrollLeftFunc = () => {
const container = document.querySelector('.custom-scroll');
container.scrollBy({ left: -100, behavior: 'smooth' });
};
const scrollRightFunc = () => {
const container = document.querySelector('.custom-scroll');
container.scrollBy({ left: 100, behavior: 'smooth' });
};
</script> </script>
<style scoped> <style scoped>
.custom-scroll {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
.custom-scroll::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
</style> </style>

View File

@@ -14,7 +14,7 @@ const title = ref(props.creator.title);
const client = useClient(); const client = useClient();
const save = async () => { async function save() {
try { try {
await client.post( await client.post(
`/api/creators/${props.creator.id}/title`, `/api/creators/${props.creator.id}/title`,
@@ -28,7 +28,7 @@ const save = async () => {
} catch (error) { } catch (error) {
console.error('Error saving title:', error); console.error('Error saving title:', error);
} }
}; }
const cancel = () => { const cancel = () => {
emits('closeRequested'); emits('closeRequested');
@@ -56,6 +56,7 @@ const cancel = () => {
.flex { .flex {
display: flex; display: flex;
} }
.space-y-4 > * + * { .space-y-4 > * + * {
margin-top: 1rem; margin-top: 1rem;
} }

View File

@@ -1,312 +0,0 @@
<script setup>
import XIcon from '@/assets/icons/x.svg';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import ChangeStripeID from '@/views/profile/creators/ChangeStripeID.vue';
import ChangeTitle from '@/views/profile/creators/ChangeTitle.vue';
import { computed, ref } from 'vue';
import BannerPicker from './BannerPicker.vue';
import ColorsPicker from './ColorsPicker.vue';
import LogoPicker from './LogoPicker.vue';
import Socials from './Socials.vue';
const creatorProfileStore = useCreatorProfileStore();
console.log(creatorProfileStore.creator);
const imageBanner = computed(
() =>
creatorProfileStore.creator.images.banner ||
'/images/placeholders/banner.png'
);
const imageLogo = computed(
() =>
creatorProfileStore.creator.images.logo || '/images/placeholders/logo.png'
);
const dialog = ref(false);
const currentComponent = ref('');
const componentsMap = {
BannerPicker,
LogoPicker,
Socials,
ColorsPicker,
ChangeTitle,
ChangeStripeID,
};
function requestCancel() {
currentComponent.value = null;
dialog.value = false;
}
const openDialog = (component) => {
currentComponent.value = componentsMap[component];
dialog.value = true;
};
const closeDialog = () => {
currentComponent.value = null;
dialog.value = false;
};
</script>
<template>
<v-dialog v-model="dialog" max-width="800px">
<v-card
:style="{ borderRadius: '25px', border: '3px solid rgb(159, 76, 173)' }"
>
<v-card-text>
<component
:is="currentComponent"
:creator="creatorProfileStore.creator"
@closeRequested="closeDialog"
@requestAccept="requestAccept"
@requestCancel="requestCancel"
></component>
</v-card-text>
</v-card>
</v-dialog>
<!-- Lorsque l'utilisateur n'a pas de creator name-->
<div class="flex flex-col items-center w-full">
<h1 class="uppercase pb-5 text-2xl">
<v-icon class="mr-2">mdi-file-edit-outline</v-icon>
{{ $t('creatorinfopage.pageinformation') }}
</h1>
<div v-if="creatorProfileStore.hasCreator" class="w-full max-w-[800px]">
<div class="my-10 border rounded bg-white">
<div class="py-5 uppercase ml-4">
{{ $t('creatorinfopage.informations') }}
</div>
<div class="flex flex-col w-full">
<button
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 min-w-32 text-left">{{
$t('creatorinfopage.name')
}}</span>
<span class="flex-auto text-left pr-6 capitalize">{{
creatorProfileStore.creator.name
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
</div>
<div class="flex flex-col w-full">
<button
@click="openDialog('ChangeTitle')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 min-w-32 text-left">{{
$t('creatorinfopage.title')
}}</span>
<span class="flex-auto text-left pr-6 capitalize">{{
creatorProfileStore.creator.title
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
</div>
<div class="flex flex-col w-full">
<button
@click="openDialog('ChangeStripeID')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full rounded-b"
>
<span class="flex-none pa-2 min-w-32 text-left"
>Stripe Account ID</span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.stripeId
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
</div>
</div>
<div class="border rounded bg-white">
<div class="py-5 uppercase ml-4">
{{ $t('creatorinfopage.banner&profile') }}
</div>
<div class="flex flex-col w-full gap-4">
<button
@click="openDialog('ColorsPicker')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-auto text-left pr-6 capitalize">
Choisissez votre palette de couleurs.
</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button>
<img
@click="openDialog('BannerPicker')"
:src="imageBanner"
class="w-full transition duration-200 ease-in-out transform hover:brightness-125"
alt="Tutorial Banner"
/>
</button>
<button class="flex justify-center my-5">
<img
@click="openDialog('LogoPicker')"
class="custom-border hover:brightness-125 active:bg-gray-600 shadow flex items-center transition duration-200 ease-in-out w-48 h-48 rounded-full"
:src="imageLogo"
alt="Profile Image"
/>
</button>
</div>
</div>
<div class="mt-10 border rounded bg-white">
<div class="py-5 uppercase ml-4">
{{ $t('creatorinfopage.socialnetwork') }}
</div>
<div class="flex flex-col w-full ">
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="pa-2 min-w-32 text-left"
><v-icon>mdi-facebook</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials.facebookUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 min-w-32 text-left">
<v-icon>mdi-instagram</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials.instagramUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 w-9 h-9 text-left ml-0.5">
<XIcon></XIcon>
</span>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials.xUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="pa-2 min-w-32 text-left"
><v-icon>mdi-linkedin</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials.linkedInUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="flex-none pa-2 min-w-32 text-left">
<XIcon class="w-5 h-5"></XIcon>
</span>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials.tikTokUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="pa-2 min-w-32 text-left"
><v-icon>mdi-youtube</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials.youtubeUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full"
>
<span class="pa-2 min-w-32 text-left"
><v-icon>mdi-reddit</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials.redditUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
<button
@click="openDialog('Socials')"
class="HoverBtn active:bg-gray-300 py-2 px-4 border-gray-400 shadow flex items-center transition duration-200 ease-in-out w-full rounded-b"
>
<span class="pa-2 min-w-32 text-left"
><v-icon>mdi-web</v-icon></span
>
<span class="flex-auto text-left pr-6">{{
creatorProfileStore.creator.socials.websiteUrl
}}</span>
<span class="flex-none">
<v-icon>mdi-chevron-right</v-icon>
</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style>
.HoverBtn:hover {
@apply bg-[#A6147D] text-white;
@apply hover:opacity-90;
}
.custom-border {
border: 3px solid;
}
</style>