feat: add editable channel images
This commit is contained in:
2605
backend/src/Socialize.Api/Migrations/20260509170908_AddChannelImages.Designer.cs
generated
Normal file
2605
backend/src/Socialize.Api/Migrations/20260509170908_AddChannelImages.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
internal partial class AddChannelImages : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "BannerUrl",
|
||||||
|
table: "Channels",
|
||||||
|
type: "character varying(2048)",
|
||||||
|
maxLength: 2048,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PortraitUrl",
|
||||||
|
table: "Channels",
|
||||||
|
type: "character varying(2048)",
|
||||||
|
maxLength: 2048,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BannerUrl",
|
||||||
|
table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PortraitUrl",
|
||||||
|
table: "Channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -817,6 +817,10 @@ namespace Socialize.Api.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("BannerUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
@@ -840,6 +844,10 @@ namespace Socialize.Api.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)");
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("PortraitUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
b.Property<Guid>("WorkspaceId")
|
b.Property<Guid>("WorkspaceId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ internal class Channel
|
|||||||
public required string Network { get; set; }
|
public required string Network { get; set; }
|
||||||
public string? Handle { get; set; }
|
public string? Handle { get; set; }
|
||||||
public string? ExternalUrl { get; set; }
|
public string? ExternalUrl { get; set; }
|
||||||
|
public string? PortraitUrl { get; set; }
|
||||||
|
public string? BannerUrl { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ internal static class ChannelModelConfiguration
|
|||||||
channel.Property(x => x.Network).HasMaxLength(64).IsRequired();
|
channel.Property(x => x.Network).HasMaxLength(64).IsRequired();
|
||||||
channel.Property(x => x.Handle).HasMaxLength(256);
|
channel.Property(x => x.Handle).HasMaxLength(256);
|
||||||
channel.Property(x => x.ExternalUrl).HasMaxLength(2048);
|
channel.Property(x => x.ExternalUrl).HasMaxLength(2048);
|
||||||
|
channel.Property(x => x.PortraitUrl).HasMaxLength(2048);
|
||||||
|
channel.Property(x => x.BannerUrl).HasMaxLength(2048);
|
||||||
channel.Property(x => x.CreatedAt)
|
channel.Property(x => x.CreatedAt)
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ internal record ChannelDto(
|
|||||||
string Network,
|
string Network,
|
||||||
string? Handle,
|
string? Handle,
|
||||||
string? ExternalUrl,
|
string? ExternalUrl,
|
||||||
|
string? PortraitUrl,
|
||||||
|
string? BannerUrl,
|
||||||
DateTimeOffset CreatedAt);
|
DateTimeOffset CreatedAt);
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ internal record CreateChannelRequest(
|
|||||||
string Name,
|
string Name,
|
||||||
string Network,
|
string Network,
|
||||||
string? Handle,
|
string? Handle,
|
||||||
string? ExternalUrl);
|
string? ExternalUrl,
|
||||||
|
string? PortraitUrl,
|
||||||
|
string? BannerUrl);
|
||||||
|
|
||||||
internal class CreateChannelRequestValidator
|
internal class CreateChannelRequestValidator
|
||||||
: Validator<CreateChannelRequest>
|
: Validator<CreateChannelRequest>
|
||||||
@@ -36,6 +38,8 @@ internal class CreateChannelRequestValidator
|
|||||||
.WithMessage("Selected network is invalid.");
|
.WithMessage("Selected network is invalid.");
|
||||||
RuleFor(x => x.Handle).MaximumLength(256);
|
RuleFor(x => x.Handle).MaximumLength(256);
|
||||||
RuleFor(x => x.ExternalUrl).MaximumLength(2048);
|
RuleFor(x => x.ExternalUrl).MaximumLength(2048);
|
||||||
|
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
|
||||||
|
RuleFor(x => x.BannerUrl).MaximumLength(2048);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +76,8 @@ internal class CreateChannelHandler(
|
|||||||
string normalizedNetwork = request.Network.Trim();
|
string normalizedNetwork = request.Network.Trim();
|
||||||
string? normalizedHandle = request.Handle?.Trim();
|
string? normalizedHandle = request.Handle?.Trim();
|
||||||
string? normalizedExternalUrl = request.ExternalUrl?.Trim();
|
string? normalizedExternalUrl = request.ExternalUrl?.Trim();
|
||||||
|
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
|
||||||
|
string? normalizedBannerUrl = request.BannerUrl?.Trim();
|
||||||
|
|
||||||
bool duplicateChannel = await dbContext.Channels
|
bool duplicateChannel = await dbContext.Channels
|
||||||
.AnyAsync(
|
.AnyAsync(
|
||||||
@@ -95,6 +101,8 @@ internal class CreateChannelHandler(
|
|||||||
Network = normalizedNetwork,
|
Network = normalizedNetwork,
|
||||||
Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle,
|
Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle,
|
||||||
ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl,
|
ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl,
|
||||||
|
PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl,
|
||||||
|
BannerUrl = string.IsNullOrWhiteSpace(normalizedBannerUrl) ? null : normalizedBannerUrl,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,6 +116,8 @@ internal class CreateChannelHandler(
|
|||||||
channel.Network,
|
channel.Network,
|
||||||
channel.Handle,
|
channel.Handle,
|
||||||
channel.ExternalUrl,
|
channel.ExternalUrl,
|
||||||
|
channel.PortraitUrl,
|
||||||
|
channel.BannerUrl,
|
||||||
channel.CreatedAt);
|
channel.CreatedAt);
|
||||||
|
|
||||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ internal class GetChannelsHandler(
|
|||||||
channel.Network,
|
channel.Network,
|
||||||
channel.Handle,
|
channel.Handle,
|
||||||
channel.ExternalUrl,
|
channel.ExternalUrl,
|
||||||
|
channel.PortraitUrl,
|
||||||
|
channel.BannerUrl,
|
||||||
channel.CreatedAt))
|
channel.CreatedAt))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Channels.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Channels.Handlers;
|
||||||
|
|
||||||
|
internal record UpdateChannelRequest(
|
||||||
|
Guid Id,
|
||||||
|
Guid WorkspaceId,
|
||||||
|
string Name,
|
||||||
|
string Network,
|
||||||
|
string? Handle,
|
||||||
|
string? ExternalUrl,
|
||||||
|
string? PortraitUrl,
|
||||||
|
string? BannerUrl);
|
||||||
|
|
||||||
|
internal class UpdateChannelRequestValidator
|
||||||
|
: Validator<UpdateChannelRequest>
|
||||||
|
{
|
||||||
|
private static readonly string[] AllowedNetworks =
|
||||||
|
[
|
||||||
|
"Instagram",
|
||||||
|
"TikTok",
|
||||||
|
"Facebook",
|
||||||
|
"LinkedIn",
|
||||||
|
"YouTube",
|
||||||
|
"X",
|
||||||
|
"Reddit",
|
||||||
|
"Website",
|
||||||
|
];
|
||||||
|
|
||||||
|
public UpdateChannelRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty();
|
||||||
|
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
|
RuleFor(x => x.Network).NotEmpty().Must(network => AllowedNetworks.Contains(network))
|
||||||
|
.WithMessage("Selected network is invalid.");
|
||||||
|
RuleFor(x => x.Handle).MaximumLength(256);
|
||||||
|
RuleFor(x => x.ExternalUrl).MaximumLength(2048);
|
||||||
|
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
|
||||||
|
RuleFor(x => x.BannerUrl).MaximumLength(2048);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class UpdateChannelHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
AccessScopeService accessScopeService)
|
||||||
|
: Endpoint<UpdateChannelRequest, ChannelDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Put("/api/channels/{id}");
|
||||||
|
Options(o => o.WithTags("Channels"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(UpdateChannelRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Channel? channel = await dbContext.Channels
|
||||||
|
.SingleOrDefaultAsync(candidate => candidate.Id == request.Id && candidate.WorkspaceId == request.WorkspaceId, ct);
|
||||||
|
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, channel.WorkspaceId, ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string normalizedName = request.Name.Trim();
|
||||||
|
string normalizedNetwork = request.Network.Trim();
|
||||||
|
string? normalizedHandle = request.Handle?.Trim();
|
||||||
|
string? normalizedExternalUrl = request.ExternalUrl?.Trim();
|
||||||
|
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
|
||||||
|
string? normalizedBannerUrl = request.BannerUrl?.Trim();
|
||||||
|
|
||||||
|
bool duplicateChannel = await dbContext.Channels
|
||||||
|
.AnyAsync(
|
||||||
|
candidate => candidate.Id != channel.Id
|
||||||
|
&& candidate.WorkspaceId == channel.WorkspaceId
|
||||||
|
&& candidate.Network == normalizedNetwork
|
||||||
|
&& candidate.Name == normalizedName,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (duplicateChannel)
|
||||||
|
{
|
||||||
|
AddError(request => request.Name, "A channel with this name already exists for the selected network.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.Name = normalizedName;
|
||||||
|
channel.Network = normalizedNetwork;
|
||||||
|
channel.Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle;
|
||||||
|
channel.ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl;
|
||||||
|
channel.PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl;
|
||||||
|
channel.BannerUrl = string.IsNullOrWhiteSpace(normalizedBannerUrl) ? null : normalizedBannerUrl;
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
ChannelDto dto = new(
|
||||||
|
channel.Id,
|
||||||
|
channel.WorkspaceId,
|
||||||
|
channel.Name,
|
||||||
|
channel.Network,
|
||||||
|
channel.Handle,
|
||||||
|
channel.ExternalUrl,
|
||||||
|
channel.PortraitUrl,
|
||||||
|
channel.BannerUrl,
|
||||||
|
channel.CreatedAt);
|
||||||
|
|
||||||
|
await SendOkAsync(dto, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Channels.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Channels.Handlers;
|
||||||
|
|
||||||
|
internal record UploadChannelImageRequest(
|
||||||
|
Guid Id,
|
||||||
|
IFormFile File);
|
||||||
|
|
||||||
|
internal class UploadChannelImageRequestValidator
|
||||||
|
: Validator<UploadChannelImageRequest>
|
||||||
|
{
|
||||||
|
public UploadChannelImageRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty();
|
||||||
|
RuleFor(x => x.File).NotNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class UploadChannelPortraitHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
AccessScopeService accessScopeService,
|
||||||
|
IBlobStorage blobStorage)
|
||||||
|
: Endpoint<UploadChannelImageRequest, ChannelDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/channels/{id}/portrait");
|
||||||
|
Options(o => o.WithTags("Channels"));
|
||||||
|
AllowFileUploads();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(UploadChannelImageRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await UploadImageAsync(request, "portrait", (channel, url) => channel.PortraitUrl = url, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UploadImageAsync(
|
||||||
|
UploadChannelImageRequest request,
|
||||||
|
string imageKind,
|
||||||
|
Action<Channel, string> applyImageUrl,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
Channel? channel = await dbContext.Channels.SingleOrDefaultAsync(candidate => candidate.Id == request.Id, ct);
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, channel.WorkspaceId, ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? blobUrl = await TryUploadImageAsync(request, channel, imageKind, ct);
|
||||||
|
if (blobUrl is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyImageUrl(channel, blobUrl);
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(ChannelImageUploadHelpers.ToDto(channel), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryUploadImageAsync(
|
||||||
|
UploadChannelImageRequest request,
|
||||||
|
Channel channel,
|
||||||
|
string imageKind,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
string normalizedContentType = request.File.ContentType.Trim();
|
||||||
|
if (request.File.Length <= 0)
|
||||||
|
{
|
||||||
|
AddError(request => request.File, "The image must not be empty.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ChannelImageUploadHelpers.IsSupportedImageContentType(normalizedContentType))
|
||||||
|
{
|
||||||
|
AddError(request => request.File, "The image must be a PNG, JPEG, GIF, or WebP file.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string fileName = ChannelImageUploadHelpers.NormalizeFileName(request.File.FileName, normalizedContentType);
|
||||||
|
string blobName = $"{channel.WorkspaceId}/{SubDirectoryNames.Profile}/channels/{channel.Id}/{imageKind}/{fileName}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await blobStorage.UploadFileAsync(
|
||||||
|
ContainerNames.Workspaces,
|
||||||
|
blobName,
|
||||||
|
request.File.OpenReadStream(),
|
||||||
|
normalizedContentType,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
AddError(request => request.File, "The image file is invalid or unsupported.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class UploadChannelBannerHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
AccessScopeService accessScopeService,
|
||||||
|
IBlobStorage blobStorage)
|
||||||
|
: Endpoint<UploadChannelImageRequest, ChannelDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/channels/{id}/banner");
|
||||||
|
Options(o => o.WithTags("Channels"));
|
||||||
|
AllowFileUploads();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(UploadChannelImageRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Channel? channel = await dbContext.Channels.SingleOrDefaultAsync(candidate => candidate.Id == request.Id, ct);
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, channel.WorkspaceId, ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string normalizedContentType = request.File.ContentType.Trim();
|
||||||
|
if (request.File.Length <= 0)
|
||||||
|
{
|
||||||
|
AddError(request => request.File, "The image must not be empty.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ChannelImageUploadHelpers.IsSupportedImageContentType(normalizedContentType))
|
||||||
|
{
|
||||||
|
AddError(request => request.File, "The image must be a PNG, JPEG, GIF, or WebP file.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string fileName = ChannelImageUploadHelpers.NormalizeFileName(request.File.FileName, normalizedContentType);
|
||||||
|
string blobName = $"{channel.WorkspaceId}/{SubDirectoryNames.Profile}/channels/{channel.Id}/banner/{fileName}";
|
||||||
|
string blobUrl;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
blobUrl = await blobStorage.UploadFileAsync(
|
||||||
|
ContainerNames.Workspaces,
|
||||||
|
blobName,
|
||||||
|
request.File.OpenReadStream(),
|
||||||
|
normalizedContentType,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
AddError(request => request.File, "The image file is invalid or unsupported.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.BannerUrl = blobUrl;
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(ChannelImageUploadHelpers.ToDto(channel), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ChannelImageUploadHelpers
|
||||||
|
{
|
||||||
|
public static ChannelDto ToDto(Channel channel)
|
||||||
|
{
|
||||||
|
return new ChannelDto(
|
||||||
|
channel.Id,
|
||||||
|
channel.WorkspaceId,
|
||||||
|
channel.Name,
|
||||||
|
channel.Network,
|
||||||
|
channel.Handle,
|
||||||
|
channel.ExternalUrl,
|
||||||
|
channel.PortraitUrl,
|
||||||
|
channel.BannerUrl,
|
||||||
|
channel.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsSupportedImageContentType(string contentType)
|
||||||
|
{
|
||||||
|
string normalized = contentType.Trim();
|
||||||
|
return normalized.Equals("image/png", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Equals("image/gif", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Equals("image/webp", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeFileName(string? fileName, string contentType)
|
||||||
|
{
|
||||||
|
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
normalized = $"channel-image{DefaultExtension(contentType)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.Length > 256 ? normalized[..256] : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DefaultExtension(string contentType)
|
||||||
|
{
|
||||||
|
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.Equals("image/gif", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ".gif";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ".webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ".jpg";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ A channel has:
|
|||||||
- `network`
|
- `network`
|
||||||
- optional `handle`
|
- optional `handle`
|
||||||
- optional `externalUrl`
|
- optional `externalUrl`
|
||||||
|
- optional `portraitUrl`
|
||||||
|
- optional `bannerUrl`
|
||||||
- `createdAt`
|
- `createdAt`
|
||||||
|
|
||||||
`network` is a controlled string matching the frontend channel network options:
|
`network` is a controlled string matching the frontend channel network options:
|
||||||
@@ -33,6 +35,7 @@ Channel names must be unique inside a workspace for the same network.
|
|||||||
|
|
||||||
- Authenticated users with workspace access can list channels for their active workspace.
|
- Authenticated users with workspace access can list channels for their active workspace.
|
||||||
- Workspace managers can create channels.
|
- Workspace managers can create channels.
|
||||||
|
- Workspace managers can edit channel profile fields and upload portrait/banner images.
|
||||||
- Content planning uses configured channels as selectable destinations.
|
- Content planning uses configured channels as selectable destinations.
|
||||||
- Development seed data should create real workspace channels instead of relying on content target labels as fake channels.
|
- Development seed data should create real workspace channels instead of relying on content target labels as fake channels.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ Make channel setup feel like configuring real social destinations and make conte
|
|||||||
- Show configured channels as compact, network-shaped preview pages.
|
- Show configured channels as compact, network-shaped preview pages.
|
||||||
- Show a banner/profile preview while creating a channel.
|
- Show a banner/profile preview while creating a channel.
|
||||||
- Use existing channel fields: name, network, handle, and external URL.
|
- Use existing channel fields: name, network, handle, and external URL.
|
||||||
|
- Let managers edit existing channel fields.
|
||||||
|
- Let managers upload portrait and banner images for channels.
|
||||||
- In the content creation editor, show a channel setup invitation when the workspace has no configured channels.
|
- In the content creation editor, show a channel setup invitation when the workspace has no configured channels.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
@@ -22,6 +24,7 @@ Make channel setup feel like configuring real social destinations and make conte
|
|||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run build
|
npm run build
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
@@ -30,3 +33,5 @@ npm run build
|
|||||||
- [x] The channels page content area shows preview cards for configured channels.
|
- [x] The channels page content area shows preview cards for configured channels.
|
||||||
- [x] The create-channel form includes a live network-style banner/profile preview.
|
- [x] The create-channel form includes a live network-style banner/profile preview.
|
||||||
- [x] Content creation invites the user to configure channels first when none exist.
|
- [x] Content creation invites the user to configure channels first when none exist.
|
||||||
|
- [x] Existing channels can be edited after creation.
|
||||||
|
- [x] Channel portrait and banner images can be uploaded and shown in previews.
|
||||||
|
|||||||
192
frontend/src/api/schema.d.ts
vendored
192
frontend/src/api/schema.d.ts
vendored
@@ -1156,6 +1156,54 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/channels/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["SocializeApiModulesChannelsHandlersUpdateChannelHandler"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/channels/{id}/portrait": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesChannelsHandlersUploadChannelPortraitHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/channels/{id}/banner": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesChannelsHandlersUploadChannelBannerHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/campaigns": {
|
"/api/campaigns": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2129,6 +2177,8 @@ export interface components {
|
|||||||
network?: string;
|
network?: string;
|
||||||
handle?: string | null;
|
handle?: string | null;
|
||||||
externalUrl?: string | null;
|
externalUrl?: string | null;
|
||||||
|
portraitUrl?: string | null;
|
||||||
|
bannerUrl?: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
@@ -2139,8 +2189,24 @@ export interface components {
|
|||||||
network: string;
|
network: string;
|
||||||
handle?: string | null;
|
handle?: string | null;
|
||||||
externalUrl?: string | null;
|
externalUrl?: string | null;
|
||||||
|
portraitUrl?: string | null;
|
||||||
|
bannerUrl?: string | null;
|
||||||
};
|
};
|
||||||
SocializeApiModulesChannelsHandlersGetChannelsRequest: Record<string, never>;
|
SocializeApiModulesChannelsHandlersGetChannelsRequest: Record<string, never>;
|
||||||
|
SocializeApiModulesChannelsHandlersUpdateChannelRequest: {
|
||||||
|
/** Format: guid */
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
network: string;
|
||||||
|
handle?: string | null;
|
||||||
|
externalUrl?: string | null;
|
||||||
|
portraitUrl?: string | null;
|
||||||
|
bannerUrl?: string | null;
|
||||||
|
};
|
||||||
|
SocializeApiModulesChannelsHandlersUploadChannelImageRequest: {
|
||||||
|
/** Format: binary */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
SocializeApiModulesCampaignsHandlersCampaignDto: {
|
SocializeApiModulesCampaignsHandlersCampaignDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -5269,6 +5335,132 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesChannelsHandlersUpdateChannelHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersUpdateChannelRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesChannelsHandlersUploadChannelPortraitHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["SocializeApiModulesChannelsHandlersUploadChannelImageRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesChannelsHandlersUploadChannelBannerHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["SocializeApiModulesChannelsHandlersUploadChannelImageRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesCampaignsHandlersGetCampaignsHandler: {
|
SocializeApiModulesCampaignsHandlersGetCampaignsHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const useChannelsStore = defineStore('channels', () => {
|
|||||||
const channels = ref([]);
|
const channels = ref([]);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
|
const isUpdating = ref(false);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
const loadedWorkspaceId = ref(null);
|
const loadedWorkspaceId = ref(null);
|
||||||
const allWorkspacesKey = '__all__';
|
const allWorkspacesKey = '__all__';
|
||||||
@@ -104,6 +105,71 @@ export const useChannelsStore = defineStore('channels', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateChannel(channelId, payload) {
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
throw new Error('You must be signed in to update a channel.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUpdating.value) {
|
||||||
|
throw new Error('A channel update request is already in progress.');
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdating.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.put(`/api/channels/${channelId}`, payload);
|
||||||
|
if (response.data) {
|
||||||
|
channels.value = channels.value
|
||||||
|
.map(channel => channel.id === response.data.id ? response.data : channel)
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (updateError) {
|
||||||
|
console.error('Failed to update channel:', updateError);
|
||||||
|
const message = updateError.response?.data?.errors?.[0]?.reason
|
||||||
|
?? updateError.response?.data?.message
|
||||||
|
?? 'Failed to update channel.';
|
||||||
|
error.value = message;
|
||||||
|
throw new Error(message);
|
||||||
|
} finally {
|
||||||
|
isUpdating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadChannelImage(channelId, imageKind, file) {
|
||||||
|
if (!file) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdating.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file, file.name || `${imageKind}.jpg`);
|
||||||
|
|
||||||
|
const response = await client.post(`/api/channels/${channelId}/${imageKind}`, formData);
|
||||||
|
if (response.data) {
|
||||||
|
channels.value = channels.value
|
||||||
|
.map(channel => channel.id === response.data.id ? response.data : channel)
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (uploadError) {
|
||||||
|
console.error('Failed to upload channel image:', uploadError);
|
||||||
|
const message = uploadError.response?.data?.errors?.[0]?.reason
|
||||||
|
?? uploadError.response?.data?.message
|
||||||
|
?? 'Failed to upload channel image.';
|
||||||
|
error.value = message;
|
||||||
|
throw new Error(message);
|
||||||
|
} finally {
|
||||||
|
isUpdating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey],
|
() => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey],
|
||||||
async ([isAuthenticated]) => {
|
async ([isAuthenticated]) => {
|
||||||
@@ -124,8 +190,11 @@ export const useChannelsStore = defineStore('channels', () => {
|
|||||||
channels,
|
channels,
|
||||||
isLoading,
|
isLoading,
|
||||||
isCreating,
|
isCreating,
|
||||||
|
isUpdating,
|
||||||
error,
|
error,
|
||||||
fetchChannels,
|
fetchChannels,
|
||||||
createChannel,
|
createChannel,
|
||||||
|
updateChannel,
|
||||||
|
uploadChannelImage,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,12 +8,15 @@
|
|||||||
import {
|
import {
|
||||||
mdiClose,
|
mdiClose,
|
||||||
mdiFacebook,
|
mdiFacebook,
|
||||||
|
mdiImage,
|
||||||
mdiInstagram,
|
mdiInstagram,
|
||||||
mdiLinkedin,
|
mdiLinkedin,
|
||||||
mdiMusicNote,
|
mdiMusicNote,
|
||||||
mdiOpenInNew,
|
mdiOpenInNew,
|
||||||
|
mdiPencil,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
mdiReddit,
|
mdiReddit,
|
||||||
|
mdiContentSave,
|
||||||
mdiWeb,
|
mdiWeb,
|
||||||
mdiYoutube,
|
mdiYoutube,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
const channelsStore = useChannelsStore();
|
const channelsStore = useChannelsStore();
|
||||||
|
|
||||||
const isCreateFormVisible = ref(false);
|
const isCreateFormVisible = ref(false);
|
||||||
|
const editingChannelId = ref('');
|
||||||
const formError = ref(null);
|
const formError = ref(null);
|
||||||
const activeNetwork = ref('Instagram');
|
const activeNetwork = ref('Instagram');
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -32,6 +36,8 @@
|
|||||||
network: 'Instagram',
|
network: 'Instagram',
|
||||||
handle: '',
|
handle: '',
|
||||||
externalUrl: '',
|
externalUrl: '',
|
||||||
|
portraitUrl: '',
|
||||||
|
bannerUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const networkOptions = [
|
const networkOptions = [
|
||||||
@@ -63,11 +69,16 @@
|
|||||||
const channelsForActiveNetwork = computed(() =>
|
const channelsForActiveNetwork = computed(() =>
|
||||||
configuredChannels.value.filter(channel => channel.network === activeNetwork.value)
|
configuredChannels.value.filter(channel => channel.network === activeNetwork.value)
|
||||||
);
|
);
|
||||||
|
const editingChannel = computed(() =>
|
||||||
|
configuredChannels.value.find(channel => channel.id === editingChannelId.value) ?? null
|
||||||
|
);
|
||||||
const previewChannel = computed(() => ({
|
const previewChannel = computed(() => ({
|
||||||
name: form.name.trim() || `${form.network} channel`,
|
name: form.name.trim() || `${form.network} channel`,
|
||||||
network: form.network,
|
network: form.network,
|
||||||
handle: form.handle.trim(),
|
handle: form.handle.trim(),
|
||||||
externalUrl: form.externalUrl.trim(),
|
externalUrl: form.externalUrl.trim(),
|
||||||
|
portraitUrl: form.portraitUrl.trim(),
|
||||||
|
bannerUrl: form.bannerUrl.trim(),
|
||||||
workspaceName: workspaceStore.activeWorkspace?.name ?? t('nav.noWorkspace'),
|
workspaceName: workspaceStore.activeWorkspace?.name ?? t('nav.noWorkspace'),
|
||||||
scheduled: 0,
|
scheduled: 0,
|
||||||
readyCount: 0,
|
readyCount: 0,
|
||||||
@@ -95,6 +106,8 @@
|
|||||||
form.network = activeNetwork.value;
|
form.network = activeNetwork.value;
|
||||||
form.handle = '';
|
form.handle = '';
|
||||||
form.externalUrl = '';
|
form.externalUrl = '';
|
||||||
|
form.portraitUrl = '';
|
||||||
|
form.bannerUrl = '';
|
||||||
formError.value = null;
|
formError.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +115,20 @@
|
|||||||
activeNetwork.value = network;
|
activeNetwork.value = network;
|
||||||
resetForm();
|
resetForm();
|
||||||
form.network = network;
|
form.network = network;
|
||||||
|
editingChannelId.value = '';
|
||||||
|
isCreateFormVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditForm(channel) {
|
||||||
|
activeNetwork.value = channel.network;
|
||||||
|
editingChannelId.value = channel.id;
|
||||||
|
form.name = channel.name ?? '';
|
||||||
|
form.network = channel.network ?? activeNetwork.value;
|
||||||
|
form.handle = channel.handle ?? '';
|
||||||
|
form.externalUrl = channel.externalUrl ?? '';
|
||||||
|
form.portraitUrl = channel.portraitUrl ?? '';
|
||||||
|
form.bannerUrl = channel.bannerUrl ?? '';
|
||||||
|
formError.value = null;
|
||||||
isCreateFormVisible.value = true;
|
isCreateFormVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,14 +144,28 @@
|
|||||||
formError.value = null;
|
formError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await channelsStore.createChannel({
|
const payload = {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
network: form.network,
|
network: form.network,
|
||||||
handle: form.handle,
|
handle: form.handle,
|
||||||
externalUrl: form.externalUrl,
|
externalUrl: form.externalUrl,
|
||||||
});
|
portraitUrl: form.portraitUrl,
|
||||||
|
bannerUrl: form.bannerUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingChannelId.value) {
|
||||||
|
await channelsStore.updateChannel(editingChannelId.value, {
|
||||||
|
id: editingChannelId.value,
|
||||||
|
workspaceId: editingChannel.value?.workspaceId ?? workspaceStore.activeWorkspaceId,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await channelsStore.createChannel(payload);
|
||||||
|
}
|
||||||
|
|
||||||
activeNetwork.value = form.network;
|
activeNetwork.value = form.network;
|
||||||
isCreateFormVisible.value = false;
|
isCreateFormVisible.value = false;
|
||||||
|
editingChannelId.value = '';
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
formError.value = error.message ?? t('channels.errors.createFailed');
|
formError.value = error.message ?? t('channels.errors.createFailed');
|
||||||
@@ -164,6 +205,27 @@
|
|||||||
return `network-${(network ?? 'other').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
return `network-${(network ?? 'other').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadChannelImage(channel, imageKind, event) {
|
||||||
|
const [file] = Array.from(event.target.files ?? []);
|
||||||
|
event.target.value = '';
|
||||||
|
|
||||||
|
if (!file || !channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await channelsStore.uploadChannelImage(channel.id, imageKind, file);
|
||||||
|
if (editingChannelId.value === channel.id && updated) {
|
||||||
|
form.portraitUrl = updated.portraitUrl ?? '';
|
||||||
|
form.bannerUrl = updated.bannerUrl ?? '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
formError.value = error.message ?? t('channels.errors.updateFailed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.create,
|
() => route.query.create,
|
||||||
createValue => {
|
createValue => {
|
||||||
@@ -221,11 +283,25 @@
|
|||||||
:class="networkClass(form.network)"
|
:class="networkClass(form.network)"
|
||||||
>
|
>
|
||||||
<div class="channel-banner">
|
<div class="channel-banner">
|
||||||
<v-icon :icon="networkIcon(form.network)" />
|
<img
|
||||||
|
v-if="previewChannel.bannerUrl"
|
||||||
|
:src="previewChannel.bannerUrl"
|
||||||
|
:alt="`${previewChannel.name} banner`"
|
||||||
|
/>
|
||||||
|
<v-icon
|
||||||
|
v-else
|
||||||
|
class="banner-network-icon"
|
||||||
|
:icon="networkIcon(form.network)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-profile-row">
|
<div class="channel-profile-row">
|
||||||
<div class="channel-portrait">
|
<div class="channel-portrait">
|
||||||
{{ channelInitials(previewChannel) }}
|
<img
|
||||||
|
v-if="previewChannel.portraitUrl"
|
||||||
|
:src="previewChannel.portraitUrl"
|
||||||
|
:alt="`${previewChannel.name} portrait`"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ channelInitials(previewChannel) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ previewChannel.name }}</strong>
|
<strong>{{ previewChannel.name }}</strong>
|
||||||
@@ -240,7 +316,7 @@
|
|||||||
@submit.prevent="submitForm"
|
@submit.prevent="submitForm"
|
||||||
>
|
>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<strong>{{ t('channels.createTitle') }}</strong>
|
<strong>{{ editingChannelId ? t('channels.editTitle') : t('channels.createTitle') }}</strong>
|
||||||
<span>{{ form.network }}</span>
|
<span>{{ form.network }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -277,6 +353,44 @@
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
hide-details
|
hide-details
|
||||||
/>
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.portraitUrl"
|
||||||
|
:label="t('channels.fields.portraitUrl')"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.bannerUrl"
|
||||||
|
:label="t('channels.fields.bannerUrl')"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="editingChannelId"
|
||||||
|
class="image-upload-row"
|
||||||
|
>
|
||||||
|
<label class="image-upload-button">
|
||||||
|
<v-icon :icon="mdiImage" />
|
||||||
|
<span>{{ t('channels.actions.changePortrait') }}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||||
|
:disabled="channelsStore.isUpdating"
|
||||||
|
@change="uploadChannelImage(editingChannel, 'portrait', $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="image-upload-button">
|
||||||
|
<v-icon :icon="mdiImage" />
|
||||||
|
<span>{{ t('channels.actions.changeBanner') }}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||||
|
:disabled="channelsStore.isUpdating"
|
||||||
|
@change="uploadChannelImage(editingChannel, 'banner', $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-actions">
|
<div class="panel-actions">
|
||||||
@@ -290,9 +404,12 @@
|
|||||||
<v-btn variant="text" :ripple="false"
|
<v-btn variant="text" :ripple="false"
|
||||||
class="primary"
|
class="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="channelsStore.isCreating"
|
:disabled="channelsStore.isCreating || channelsStore.isUpdating"
|
||||||
>
|
>
|
||||||
{{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }}
|
<v-icon :icon="editingChannelId ? mdiContentSave : mdiPlus" />
|
||||||
|
<span>
|
||||||
|
{{ channelsStore.isCreating || channelsStore.isUpdating ? t('common.saving') : (editingChannelId ? t('channels.saveChanges') : t('channels.createTitle')) }}
|
||||||
|
</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -323,7 +440,24 @@
|
|||||||
:class="networkClass(channel.network)"
|
:class="networkClass(channel.network)"
|
||||||
>
|
>
|
||||||
<div class="channel-banner">
|
<div class="channel-banner">
|
||||||
<v-icon :icon="networkIcon(channel.network)" />
|
<img
|
||||||
|
v-if="channel.bannerUrl"
|
||||||
|
:src="channel.bannerUrl"
|
||||||
|
:alt="`${channel.name} banner`"
|
||||||
|
/>
|
||||||
|
<v-icon
|
||||||
|
v-else
|
||||||
|
class="banner-network-icon"
|
||||||
|
:icon="networkIcon(channel.network)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="channel-edit-button"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`${t('channels.editTitle')} ${channel.name}`"
|
||||||
|
@click="openEditForm(channel)"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiPencil" />
|
||||||
|
</button>
|
||||||
<a
|
<a
|
||||||
v-if="channel.externalUrl"
|
v-if="channel.externalUrl"
|
||||||
:href="channel.externalUrl"
|
:href="channel.externalUrl"
|
||||||
@@ -337,7 +471,12 @@
|
|||||||
|
|
||||||
<div class="channel-profile-row">
|
<div class="channel-profile-row">
|
||||||
<div class="channel-portrait">
|
<div class="channel-portrait">
|
||||||
{{ channelInitials(channel) }}
|
<img
|
||||||
|
v-if="channel.portraitUrl"
|
||||||
|
:src="channel.portraitUrl"
|
||||||
|
:alt="`${channel.name} portrait`"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ channelInitials(channel) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ channel.name }}</strong>
|
<strong>{{ channel.name }}</strong>
|
||||||
@@ -451,6 +590,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
|
@apply gap-2;
|
||||||
background: var(--app-color-on-surface);
|
background: var(--app-color-on-surface);
|
||||||
color: var(--app-color-on-primary);
|
color: var(--app-color-on-primary);
|
||||||
}
|
}
|
||||||
@@ -480,6 +620,21 @@
|
|||||||
@apply grid gap-4;
|
@apply grid gap-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-upload-row {
|
||||||
|
@apply flex flex-wrap gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-button {
|
||||||
|
@apply inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-full border px-4 text-sm font-bold;
|
||||||
|
background: var(--app-control-hover);
|
||||||
|
border-color: var(--app-border-subtle);
|
||||||
|
color: var(--app-color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-button input {
|
||||||
|
@apply sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@apply flex flex-col gap-2 text-sm font-semibold;
|
@apply flex flex-col gap-2 text-sm font-semibold;
|
||||||
}
|
}
|
||||||
@@ -507,16 +662,29 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-banner :deep(.v-icon:first-child) {
|
.channel-banner img {
|
||||||
|
@apply h-full w-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-network-icon {
|
||||||
@apply text-5xl opacity-90;
|
@apply text-5xl opacity-90;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-edit-button,
|
||||||
.channel-banner a {
|
.channel-banner a {
|
||||||
@apply absolute right-3 top-3 grid h-8 w-8 place-items-center rounded-full;
|
@apply absolute right-3 top-3 grid h-8 w-8 place-items-center rounded-full;
|
||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.18);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-banner a {
|
||||||
|
@apply top-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-edit-button {
|
||||||
|
@apply border-0;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-profile-row {
|
.channel-profile-row {
|
||||||
@apply -mt-9 flex items-end gap-3 px-5;
|
@apply -mt-9 flex items-end gap-3 px-5;
|
||||||
}
|
}
|
||||||
@@ -530,12 +698,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-portrait {
|
.channel-portrait {
|
||||||
@apply grid h-16 w-16 shrink-0 place-items-center rounded-full border-4 text-lg font-black shadow-sm;
|
@apply grid h-16 w-16 shrink-0 place-items-center overflow-hidden rounded-full border-4 text-lg font-black shadow-sm;
|
||||||
background: var(--app-color-on-primary);
|
background: var(--app-color-on-primary);
|
||||||
border-color: var(--app-color-on-primary);
|
border-color: var(--app-color-on-primary);
|
||||||
color: var(--app-color-on-surface);
|
color: var(--app-color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-portrait img {
|
||||||
|
@apply h-full w-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-preview-card > p {
|
.channel-preview-card > p {
|
||||||
@apply px-5 text-sm font-semibold;
|
@apply px-5 text-sm font-semibold;
|
||||||
color: var(--app-text-muted);
|
color: var(--app-text-muted);
|
||||||
|
|||||||
@@ -981,6 +981,8 @@
|
|||||||
"title": "Channels",
|
"title": "Channels",
|
||||||
"description": "Add channels to the workspace.",
|
"description": "Add channels to the workspace.",
|
||||||
"createTitle": "Create channel",
|
"createTitle": "Create channel",
|
||||||
|
"editTitle": "Edit channel",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
"empty": "No channels are available for the active workspace yet.",
|
"empty": "No channels are available for the active workspace yet.",
|
||||||
"emptyAction": "Add a channel for {network}",
|
"emptyAction": "Add a channel for {network}",
|
||||||
"nextDue": "Next due",
|
"nextDue": "Next due",
|
||||||
@@ -989,7 +991,13 @@
|
|||||||
"name": "Channel name",
|
"name": "Channel name",
|
||||||
"network": "Network",
|
"network": "Network",
|
||||||
"handle": "Handle",
|
"handle": "Handle",
|
||||||
"externalUrl": "External URL"
|
"externalUrl": "External URL",
|
||||||
|
"portraitUrl": "Portrait URL",
|
||||||
|
"bannerUrl": "Banner URL"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"changePortrait": "Change portrait",
|
||||||
|
"changeBanner": "Change banner"
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"scheduled": "Scheduled",
|
"scheduled": "Scheduled",
|
||||||
@@ -997,7 +1005,8 @@
|
|||||||
"blocked": "Blocked"
|
"blocked": "Blocked"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"createFailed": "The channel could not be created."
|
"createFailed": "The channel could not be created.",
|
||||||
|
"updateFailed": "The channel could not be updated."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reviewQueue": {
|
"reviewQueue": {
|
||||||
|
|||||||
@@ -981,6 +981,8 @@
|
|||||||
"title": "Canaux",
|
"title": "Canaux",
|
||||||
"description": "Ajoutez des canaux à l'espace.",
|
"description": "Ajoutez des canaux à l'espace.",
|
||||||
"createTitle": "Créer un canal",
|
"createTitle": "Créer un canal",
|
||||||
|
"editTitle": "Modifier le canal",
|
||||||
|
"saveChanges": "Enregistrer",
|
||||||
"empty": "Aucun canal n'est disponible pour l'espace actif pour le moment.",
|
"empty": "Aucun canal n'est disponible pour l'espace actif pour le moment.",
|
||||||
"emptyAction": "Ajouter un canal pour {network}",
|
"emptyAction": "Ajouter un canal pour {network}",
|
||||||
"nextDue": "Prochaine échéance",
|
"nextDue": "Prochaine échéance",
|
||||||
@@ -989,7 +991,13 @@
|
|||||||
"name": "Nom du canal",
|
"name": "Nom du canal",
|
||||||
"network": "Réseau",
|
"network": "Réseau",
|
||||||
"handle": "Identifiant",
|
"handle": "Identifiant",
|
||||||
"externalUrl": "URL externe"
|
"externalUrl": "URL externe",
|
||||||
|
"portraitUrl": "URL du portrait",
|
||||||
|
"bannerUrl": "URL de la bannière"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"changePortrait": "Changer le portrait",
|
||||||
|
"changeBanner": "Changer la bannière"
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"scheduled": "Planifié",
|
"scheduled": "Planifié",
|
||||||
@@ -997,7 +1005,8 @@
|
|||||||
"blocked": "Bloqué"
|
"blocked": "Bloqué"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"createFailed": "Le canal n'a pas pu être créé."
|
"createFailed": "Le canal n'a pas pu être créé.",
|
||||||
|
"updateFailed": "Le canal n'a pas pu être modifié."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reviewQueue": {
|
"reviewQueue": {
|
||||||
|
|||||||
@@ -3821,6 +3821,195 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/channels/{id}": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"Channels",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesChannelsHandlersUpdateChannelHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"x-name": "UpdateChannelRequest",
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersUpdateChannelRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"x-position": 1
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersChannelDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/channels/{id}/portrait": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Channels",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesChannelsHandlersUploadChannelPortraitHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"x-name": "UploadChannelImageRequest",
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersUploadChannelImageRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"x-position": 1
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersChannelDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/channels/{id}/banner": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Channels",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesChannelsHandlersUploadChannelBannerHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"x-name": "UploadChannelImageRequest",
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersUploadChannelImageRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"x-position": 1
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersChannelDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/campaigns": {
|
"/api/campaigns": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -7075,6 +7264,14 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"portraitUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"bannerUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@@ -7118,6 +7315,18 @@
|
|||||||
"maxLength": 2048,
|
"maxLength": 2048,
|
||||||
"minLength": 0,
|
"minLength": 0,
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"portraitUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"bannerUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7125,6 +7334,72 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"SocializeApiModulesChannelsHandlersUpdateChannelRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"workspaceId",
|
||||||
|
"name",
|
||||||
|
"network"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"workspaceId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid",
|
||||||
|
"minLength": 1,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 256,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
"handle": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 256,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"externalUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"portraitUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"bannerUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SocializeApiModulesChannelsHandlersUploadChannelImageRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"file"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary",
|
||||||
|
"nullable": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SocializeApiModulesCampaignsHandlersCampaignDto": {
|
"SocializeApiModulesCampaignsHandlersCampaignDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user