feat: add content media dam uploads
This commit is contained in:
@@ -7,9 +7,25 @@ internal static class ContentTypes
|
|||||||
private const string ImagePng = "image/png";
|
private const string ImagePng = "image/png";
|
||||||
private const string ImageJpeg = "image/jpeg";
|
private const string ImageJpeg = "image/jpeg";
|
||||||
private const string ImageJpg = "image/jpg";
|
private const string ImageJpg = "image/jpg";
|
||||||
|
private const string ImageGif = "image/gif";
|
||||||
|
private const string ImageWebp = "image/webp";
|
||||||
|
private const string VideoMp4 = "video/mp4";
|
||||||
|
private const string VideoWebm = "video/webm";
|
||||||
|
private const string VideoQuickTime = "video/quicktime";
|
||||||
private const string TextHtml = "text/html";
|
private const string TextHtml = "text/html";
|
||||||
|
|
||||||
private static readonly HashSet<string> AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg, TextHtml];
|
private static readonly HashSet<string> AllowedContentTypes =
|
||||||
|
[
|
||||||
|
ImagePng,
|
||||||
|
ImageJpeg,
|
||||||
|
ImageJpg,
|
||||||
|
ImageGif,
|
||||||
|
ImageWebp,
|
||||||
|
VideoMp4,
|
||||||
|
VideoWebm,
|
||||||
|
VideoQuickTime,
|
||||||
|
TextHtml,
|
||||||
|
];
|
||||||
|
|
||||||
public static bool IsAllowed(
|
public static bool IsAllowed(
|
||||||
string contentType,
|
string contentType,
|
||||||
@@ -37,6 +53,32 @@ internal static class ContentTypes
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GIF file signatures: GIF87a and GIF89a
|
||||||
|
if (buffer[0] == 0x47 && buffer[1] == 0x49 && buffer[2] == 0x46 &&
|
||||||
|
buffer[3] == 0x38 && (buffer[4] == 0x37 || buffer[4] == 0x39) && buffer[5] == 0x61)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP files are RIFF containers with a WEBP marker.
|
||||||
|
if (buffer[0] == 0x52 && buffer[1] == 0x49 && buffer[2] == 0x46 && buffer[3] == 0x46 &&
|
||||||
|
buffer[8] == 0x57 && buffer[9] == 0x45 && buffer[10] == 0x42 && buffer[11] == 0x50)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MP4/MOV containers expose an ftyp box near the beginning.
|
||||||
|
if (buffer[4] == 0x66 && buffer[5] == 0x74 && buffer[6] == 0x79 && buffer[7] == 0x70)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebM files use the EBML header.
|
||||||
|
if (buffer[0] == 0x1A && buffer[1] == 0x45 && buffer[2] == 0xDF && buffer[3] == 0xA3)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
|
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
|
||||||
string content = Encoding.UTF8.GetString(buffer);
|
string content = Encoding.UTF8.GetString(buffer);
|
||||||
return content.Contains("<!DOCTYPE html>", StringComparison.OrdinalIgnoreCase);
|
return content.Contains("<!DOCTYPE html>", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ using FastEndpoints;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Assets.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.Assets.Handlers;
|
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||||
|
|
||||||
internal record GetAssetsRequest(Guid ContentItemId);
|
internal record GetAssetsRequest(Guid? ContentItemId, Guid? WorkspaceId);
|
||||||
|
|
||||||
internal record AssetRevisionDto(
|
internal record AssetRevisionDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -44,23 +45,46 @@ internal class GetAssetsHandler(
|
|||||||
|
|
||||||
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
|
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var item = await dbContext.ContentItems
|
IQueryable<Asset> query = dbContext.Assets;
|
||||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
|
||||||
if (item is null)
|
if (request.ContentItemId.HasValue)
|
||||||
{
|
{
|
||||||
await SendNotFoundAsync(ct);
|
var item = await dbContext.ContentItems
|
||||||
|
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId.Value, ct);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.Where(asset => asset.ContentItemId == request.ContentItemId.Value);
|
||||||
|
}
|
||||||
|
else if (request.WorkspaceId.HasValue)
|
||||||
|
{
|
||||||
|
if (!await accessScopeService.CanAccessWorkspaceAsync(User, request.WorkspaceId.Value, ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.Where(asset => asset.WorkspaceId == request.WorkspaceId.Value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AddError(request => request.WorkspaceId, "A workspace or content item is required.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
|
List<AssetDto> assets = await query
|
||||||
{
|
.OrderByDescending(asset => asset.CreatedAt)
|
||||||
await SendForbiddenAsync(ct);
|
.ThenBy(asset => asset.DisplayName)
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<AssetDto> assets = await dbContext.Assets
|
|
||||||
.Where(asset => asset.ContentItemId == request.ContentItemId)
|
|
||||||
.OrderBy(asset => asset.DisplayName)
|
|
||||||
.Select(asset => new AssetDto(
|
.Select(asset => new AssetDto(
|
||||||
asset.Id,
|
asset.Id,
|
||||||
asset.WorkspaceId,
|
asset.WorkspaceId,
|
||||||
|
|||||||
266
backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs
Normal file
266
backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Assets.Data;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
|
using Socialize.Api.Modules.Notifications.Contracts;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||||
|
|
||||||
|
internal record UploadAssetRequest(
|
||||||
|
Guid WorkspaceId,
|
||||||
|
Guid ContentItemId,
|
||||||
|
string AssetType,
|
||||||
|
string? DisplayName,
|
||||||
|
IFormFile File);
|
||||||
|
|
||||||
|
internal class UploadAssetRequestValidator
|
||||||
|
: Validator<UploadAssetRequest>
|
||||||
|
{
|
||||||
|
public UploadAssetRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||||
|
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||||
|
RuleFor(x => x.AssetType).NotEmpty().MaximumLength(64);
|
||||||
|
RuleFor(x => x.DisplayName).MaximumLength(256);
|
||||||
|
RuleFor(x => x.File).NotNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class UploadAssetHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
AccessScopeService accessScopeService,
|
||||||
|
IBlobStorage blobStorage,
|
||||||
|
IContentItemActivityWriter activityWriter,
|
||||||
|
INotificationEventWriter notificationEventWriter)
|
||||||
|
: Endpoint<UploadAssetRequest, AssetDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/assets/upload");
|
||||||
|
Options(o => o.WithTags("Assets"));
|
||||||
|
AllowFileUploads();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(UploadAssetRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ContentItem? contentItem = await dbContext.ContentItems
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (contentItem is null)
|
||||||
|
{
|
||||||
|
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.File.Length <= 0)
|
||||||
|
{
|
||||||
|
AddError(request => request.File, "The media file must not be empty.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string normalizedContentType = request.File.ContentType.Trim();
|
||||||
|
if (!IsSupportedMediaContentType(normalizedContentType))
|
||||||
|
{
|
||||||
|
AddError(request => request.File, "The media file must be an image or video.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid assetId = Guid.NewGuid();
|
||||||
|
string fileName = NormalizeFileName(request.File.FileName, normalizedContentType);
|
||||||
|
string displayName = string.IsNullOrWhiteSpace(request.DisplayName)
|
||||||
|
? Path.GetFileNameWithoutExtension(fileName)
|
||||||
|
: request.DisplayName.Trim();
|
||||||
|
string blobName =
|
||||||
|
$"{contentItem.WorkspaceId}/{SubDirectoryNames.Contents}/{contentItem.Id}/assets/{assetId}/{fileName}";
|
||||||
|
string blobUrl;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
blobUrl = await blobStorage.UploadFileAsync(
|
||||||
|
ContainerNames.Workspaces,
|
||||||
|
blobName,
|
||||||
|
request.File.OpenReadStream(),
|
||||||
|
normalizedContentType,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
AddError(request => request.File, "The media file is invalid or unsupported.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Asset asset = new()
|
||||||
|
{
|
||||||
|
Id = assetId,
|
||||||
|
WorkspaceId = request.WorkspaceId,
|
||||||
|
ContentItemId = request.ContentItemId,
|
||||||
|
AssetType = NormalizeAssetType(request.AssetType, normalizedContentType),
|
||||||
|
SourceType = "Uploaded",
|
||||||
|
DisplayName = displayName,
|
||||||
|
GoogleDriveFileId = null,
|
||||||
|
GoogleDriveLink = null,
|
||||||
|
PreviewUrl = blobUrl,
|
||||||
|
CurrentRevisionNumber = 1,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
AssetRevision revision = new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AssetId = asset.Id,
|
||||||
|
RevisionNumber = 1,
|
||||||
|
SourceReference = blobName,
|
||||||
|
PreviewUrl = blobUrl,
|
||||||
|
CreatedByUserId = User.GetUserId(),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.Assets.Add(asset);
|
||||||
|
dbContext.AssetRevisions.Add(revision);
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await activityWriter.WriteAsync(
|
||||||
|
new ContentItemActivityWriteModel(
|
||||||
|
asset.WorkspaceId,
|
||||||
|
asset.ContentItemId,
|
||||||
|
"asset.uploaded",
|
||||||
|
"Asset",
|
||||||
|
asset.Id,
|
||||||
|
$"Asset {asset.DisplayName} was uploaded to {contentItem.Title}.",
|
||||||
|
User.GetUserId(),
|
||||||
|
User.GetEmail(),
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
assetType = asset.AssetType,
|
||||||
|
sourceType = asset.SourceType,
|
||||||
|
currentRevisionNumber = asset.CurrentRevisionNumber,
|
||||||
|
})),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
await notificationEventWriter.WriteAsync(
|
||||||
|
new NotificationEventWriteModel(
|
||||||
|
asset.WorkspaceId,
|
||||||
|
asset.ContentItemId,
|
||||||
|
"asset.uploaded",
|
||||||
|
"Asset",
|
||||||
|
asset.Id,
|
||||||
|
$"Asset {asset.DisplayName} was uploaded to {contentItem.Title}.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$$"""{"assetId":"{{asset.Id}}"}"""),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
AssetDto dto = new(
|
||||||
|
asset.Id,
|
||||||
|
asset.WorkspaceId,
|
||||||
|
asset.ContentItemId,
|
||||||
|
asset.AssetType,
|
||||||
|
asset.SourceType,
|
||||||
|
asset.DisplayName,
|
||||||
|
asset.GoogleDriveFileId,
|
||||||
|
asset.GoogleDriveLink,
|
||||||
|
asset.PreviewUrl,
|
||||||
|
asset.CurrentRevisionNumber,
|
||||||
|
asset.CreatedAt,
|
||||||
|
[
|
||||||
|
new AssetRevisionDto(
|
||||||
|
revision.Id,
|
||||||
|
revision.AssetId,
|
||||||
|
revision.RevisionNumber,
|
||||||
|
revision.SourceReference,
|
||||||
|
revision.PreviewUrl,
|
||||||
|
revision.Notes,
|
||||||
|
revision.CreatedByUserId,
|
||||||
|
revision.CreatedAt)
|
||||||
|
]);
|
||||||
|
|
||||||
|
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSupportedMediaContentType(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) ||
|
||||||
|
normalized.Equals("video/mp4", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Equals("video/webm", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeAssetType(string assetType, string contentType)
|
||||||
|
{
|
||||||
|
string normalized = assetType.Trim();
|
||||||
|
if (!normalized.Equals("None", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return normalized.Length > 64 ? normalized[..64] : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ? "Video" : "Image";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeFileName(string? fileName, string contentType)
|
||||||
|
{
|
||||||
|
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
normalized = $"asset{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";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.Equals("video/webm", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ".webm";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ".mov";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ".mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ".jpg";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,16 @@ The editor should use one shared content body for every selected target channel,
|
|||||||
- Keep target copy/title synchronized by default, with per-target opt-out.
|
- Keep target copy/title synchronized by default, with per-target opt-out.
|
||||||
- Show title editing only for networks that normally need titles, such as YouTube, Reddit, Website, and newsletters.
|
- Show title editing only for networks that normally need titles, such as YouTube, Reddit, Website, and newsletters.
|
||||||
- Let each target choose its own media requirement.
|
- Let each target choose its own media requirement.
|
||||||
- Preserve existing save payloads and backend contracts.
|
- Let authors upload media from the editor; uploaded media is stored as a DAM asset.
|
||||||
|
- Let authors attach an existing workspace DAM asset to the active target preview.
|
||||||
|
- Preserve existing content item save payloads.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run build
|
npm run build
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
@@ -52,3 +55,5 @@ npm run build
|
|||||||
- [x] Channel copy/title is synchronized by default and can be unsynchronized per target.
|
- [x] Channel copy/title is synchronized by default and can be unsynchronized per target.
|
||||||
- [x] Title input only appears for title-oriented targets.
|
- [x] Title input only appears for title-oriented targets.
|
||||||
- [x] Each target can choose no media, image, video, clip, or carousel independently.
|
- [x] Each target can choose no media, image, video, clip, or carousel independently.
|
||||||
|
- [x] Authors can upload media from a target preview and create a DAM asset.
|
||||||
|
- [x] Authors can choose an existing workspace DAM asset for a target preview.
|
||||||
|
|||||||
71
frontend/src/api/schema.d.ts
vendored
71
frontend/src/api/schema.d.ts
vendored
@@ -1364,6 +1364,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/assets/upload": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesAssetsHandlersUploadAssetHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/approvals": {
|
"/api/approvals": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2307,6 +2323,16 @@ export interface components {
|
|||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
};
|
};
|
||||||
SocializeApiModulesAssetsHandlersGetAssetsRequest: Record<string, never>;
|
SocializeApiModulesAssetsHandlersGetAssetsRequest: Record<string, never>;
|
||||||
|
SocializeApiModulesAssetsHandlersUploadAssetRequest: {
|
||||||
|
/** Format: guid */
|
||||||
|
workspaceId: string;
|
||||||
|
/** Format: guid */
|
||||||
|
contentItemId: string;
|
||||||
|
assetType: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
/** Format: binary */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
SocializeApiModulesApprovalsHandlersApprovalRequestDto: {
|
SocializeApiModulesApprovalsHandlersApprovalRequestDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -5758,8 +5784,9 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
SocializeApiModulesAssetsHandlersGetAssetsHandler: {
|
SocializeApiModulesAssetsHandlersGetAssetsHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query?: {
|
||||||
contentItemId: string;
|
contentItemId?: string | null;
|
||||||
|
workspaceId?: string | null;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -5785,6 +5812,46 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesAssetsHandlersUploadAssetHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["SocializeApiModulesAssetsHandlersUploadAssetRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesAssetsHandlersAssetDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesApprovalsHandlersGetApprovalsHandler: {
|
SocializeApiModulesApprovalsHandlersGetApprovalsHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
|||||||
const revisions = ref([]);
|
const revisions = ref([]);
|
||||||
const comments = ref([]);
|
const comments = ref([]);
|
||||||
const approvals = ref([]);
|
const approvals = ref([]);
|
||||||
|
const assets = ref([]);
|
||||||
const notifications = ref([]);
|
const notifications = ref([]);
|
||||||
const activity = ref([]);
|
const activity = ref([]);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
@@ -20,6 +21,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
|||||||
comment: false,
|
comment: false,
|
||||||
decision: false,
|
decision: false,
|
||||||
status: false,
|
status: false,
|
||||||
|
asset: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
function currentItemWorkspaceId() {
|
function currentItemWorkspaceId() {
|
||||||
@@ -31,6 +33,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
|||||||
revisions.value = [];
|
revisions.value = [];
|
||||||
comments.value = [];
|
comments.value = [];
|
||||||
approvals.value = [];
|
approvals.value = [];
|
||||||
|
assets.value = [];
|
||||||
notifications.value = [];
|
notifications.value = [];
|
||||||
activity.value = [];
|
activity.value = [];
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@@ -60,6 +63,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
|||||||
comments.value = commentsResponse.data ?? [];
|
comments.value = commentsResponse.data ?? [];
|
||||||
approvals.value = approvalsResponse.data ?? [];
|
approvals.value = approvalsResponse.data ?? [];
|
||||||
activity.value = activityResponse.data ?? [];
|
activity.value = activityResponse.data ?? [];
|
||||||
|
await fetchAssets({ workspaceId: item.value?.workspaceId });
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
console.error('Failed to load content item detail:', fetchError);
|
console.error('Failed to load content item detail:', fetchError);
|
||||||
reset();
|
reset();
|
||||||
@@ -168,11 +172,45 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
|||||||
return activity.value;
|
return activity.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAssets({ contentItemId = null, workspaceId = null } = {}) {
|
||||||
|
const response = await client.get('/api/assets', {
|
||||||
|
params: {
|
||||||
|
contentItemId: contentItemId ?? undefined,
|
||||||
|
workspaceId: contentItemId ? undefined : (workspaceId ?? currentItemWorkspaceId() ?? undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assets.value = response.data ?? [];
|
||||||
|
return assets.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAsset(contentItemId, payload) {
|
||||||
|
actions.asset = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('workspaceId', payload.workspaceId ?? currentItemWorkspaceId());
|
||||||
|
formData.append('contentItemId', contentItemId);
|
||||||
|
formData.append('assetType', payload.assetType ?? 'Image');
|
||||||
|
formData.append('displayName', payload.displayName ?? payload.file?.name ?? 'Uploaded media');
|
||||||
|
formData.append('file', payload.file, payload.file?.name || 'uploaded-media');
|
||||||
|
|
||||||
|
const response = await client.post('/api/assets/upload', formData);
|
||||||
|
if (response.data) {
|
||||||
|
assets.value = [response.data, ...assets.value.filter(asset => asset.id !== response.data.id)];
|
||||||
|
await fetchActivity(contentItemId);
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
actions.asset = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
revisions,
|
revisions,
|
||||||
comments,
|
comments,
|
||||||
approvals,
|
approvals,
|
||||||
|
assets,
|
||||||
notifications,
|
notifications,
|
||||||
activity,
|
activity,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -185,5 +223,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
|||||||
submitDecision,
|
submitDecision,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
fetchActivity,
|
fetchActivity,
|
||||||
|
fetchAssets,
|
||||||
|
uploadAsset,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
mdiClose,
|
mdiClose,
|
||||||
mdiFacebook,
|
mdiFacebook,
|
||||||
mdiInstagram,
|
mdiInstagram,
|
||||||
|
mdiImageMultiple,
|
||||||
mdiLinkedin,
|
mdiLinkedin,
|
||||||
mdiMusicNote,
|
mdiMusicNote,
|
||||||
mdiReddit,
|
mdiReddit,
|
||||||
mdiTwitter,
|
mdiTwitter,
|
||||||
|
mdiUpload,
|
||||||
mdiWeb,
|
mdiWeb,
|
||||||
mdiYoutube,
|
mdiYoutube,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
@@ -135,6 +137,9 @@
|
|||||||
return selectedPlacements.value.find(placement => placement.id === activePlacementId.value)
|
return selectedPlacements.value.find(placement => placement.id === activePlacementId.value)
|
||||||
?? selectedPlacements.value[0];
|
?? selectedPlacements.value[0];
|
||||||
});
|
});
|
||||||
|
const damAssets = computed(() =>
|
||||||
|
detailStore.assets.filter(asset => !contentWorkspaceId.value || asset.workspaceId === contentWorkspaceId.value)
|
||||||
|
);
|
||||||
const sharedPreviewTags = computed(() => parseHashtags(form.hashtags));
|
const sharedPreviewTags = computed(() => parseHashtags(form.hashtags));
|
||||||
const workspaceHashtagFeed = computed(() => {
|
const workspaceHashtagFeed = computed(() => {
|
||||||
const selected = new Set(sharedPreviewTags.value.map(tag => normalizeHashtag(tag).toLowerCase()));
|
const selected = new Set(sharedPreviewTags.value.map(tag => normalizeHashtag(tag).toLowerCase()));
|
||||||
@@ -253,6 +258,10 @@
|
|||||||
hashtags: form.hashtags,
|
hashtags: form.hashtags,
|
||||||
mediaKind: defaultMediaKind(channel?.network ?? ''),
|
mediaKind: defaultMediaKind(channel?.network ?? ''),
|
||||||
mediaItems: [],
|
mediaItems: [],
|
||||||
|
assetId: '',
|
||||||
|
assetName: '',
|
||||||
|
assetPreviewUrl: '',
|
||||||
|
assetSourceType: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +389,103 @@
|
|||||||
return 'None';
|
return 'None';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mediaKindFromAsset(asset) {
|
||||||
|
const type = (asset?.assetType ?? '').toLowerCase();
|
||||||
|
const previewUrl = (asset?.previewUrl ?? '').toLowerCase();
|
||||||
|
|
||||||
|
if (type.includes('clip')) {
|
||||||
|
return 'Clip';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.includes('video') || /\.(mp4|webm|mov)(\?|$)/.test(previewUrl)) {
|
||||||
|
return 'Video';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.includes('carousel')) {
|
||||||
|
return 'Carousel';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Image';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedPlacementAsset(placement) {
|
||||||
|
if (!placement?.assetId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return damAssets.value.find(asset => asset.id === placement.assetId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placementAssetPreviewUrl(placement) {
|
||||||
|
return placement?.assetPreviewUrl || selectedPlacementAsset(placement)?.previewUrl || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlacementVideo(placement) {
|
||||||
|
return ['Video', 'Clip'].includes(mediaKindFromAsset({
|
||||||
|
assetType: placement?.mediaKind,
|
||||||
|
previewUrl: placementAssetPreviewUrl(placement),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAssetToPlacement(placement, asset) {
|
||||||
|
if (!placement || !asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
placement.assetId = asset.id;
|
||||||
|
placement.assetName = asset.displayName;
|
||||||
|
placement.assetPreviewUrl = asset.previewUrl ?? '';
|
||||||
|
placement.assetSourceType = asset.sourceType ?? '';
|
||||||
|
placement.mediaKind = mediaKindFromAsset(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDamAsset(placement, assetId) {
|
||||||
|
const asset = damAssets.value.find(candidate => candidate.id === assetId);
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
placement.assetId = '';
|
||||||
|
placement.assetName = '';
|
||||||
|
placement.assetPreviewUrl = '';
|
||||||
|
placement.assetSourceType = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAssetToPlacement(placement, asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPlacementMedia(placement, event) {
|
||||||
|
const [file] = Array.from(event.target.files ?? []);
|
||||||
|
event.target.value = '';
|
||||||
|
|
||||||
|
if (!file || !placement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetContentItemId = contentItemId.value;
|
||||||
|
if (!targetContentItemId) {
|
||||||
|
targetContentItemId = await saveContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetContentItemId || !contentWorkspaceId.value) {
|
||||||
|
saveError.message = 'The content needs a workspace and campaign before media can be uploaded.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const asset = await detailStore.uploadAsset(targetContentItemId, {
|
||||||
|
workspaceId: contentWorkspaceId.value,
|
||||||
|
assetType: placement.mediaKind === 'None' ? (file.type.startsWith('video/') ? 'Video' : 'Image') : placement.mediaKind,
|
||||||
|
displayName: file.name,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
const currentPlacement = form.placements.find(candidate => candidate.id === placement.id) ?? placement;
|
||||||
|
applyAssetToPlacement(currentPlacement, asset);
|
||||||
|
persistDraft();
|
||||||
|
} catch (error) {
|
||||||
|
saveError.message = 'The media file could not be uploaded to the DAM.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function removeHashtag(tagToRemove) {
|
function removeHashtag(tagToRemove) {
|
||||||
form.hashtags = parseHashtags(form.hashtags)
|
form.hashtags = parseHashtags(form.hashtags)
|
||||||
.filter(tag => normalizeHashtag(tag).toLowerCase() !== normalizeHashtag(tagToRemove).toLowerCase())
|
.filter(tag => normalizeHashtag(tag).toLowerCase() !== normalizeHashtag(tagToRemove).toLowerCase())
|
||||||
@@ -527,6 +633,10 @@
|
|||||||
label: media.label ?? '',
|
label: media.label ?? '',
|
||||||
url: media.url ?? '',
|
url: media.url ?? '',
|
||||||
})),
|
})),
|
||||||
|
assetId: placement.assetId ?? '',
|
||||||
|
assetName: placement.assetName ?? '',
|
||||||
|
assetPreviewUrl: placement.assetPreviewUrl ?? '',
|
||||||
|
assetSourceType: placement.assetSourceType ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +683,10 @@
|
|||||||
hashtags: item.value?.hashtags ?? '',
|
hashtags: item.value?.hashtags ?? '',
|
||||||
mediaKind: defaultMediaKind(channel?.network ?? ''),
|
mediaKind: defaultMediaKind(channel?.network ?? ''),
|
||||||
mediaItems: [],
|
mediaItems: [],
|
||||||
|
assetId: '',
|
||||||
|
assetName: '',
|
||||||
|
assetPreviewUrl: '',
|
||||||
|
assetSourceType: '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -653,12 +767,12 @@
|
|||||||
|
|
||||||
if (!primaryPublicationMessage.value || !form.campaignId || !form.placements.length) {
|
if (!primaryPublicationMessage.value || !form.campaignId || !form.placements.length) {
|
||||||
saveError.message = 'Post text, campaign, and at least one channel are required.';
|
saveError.message = 'Post text, campaign, and at least one channel are required.';
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCreateMode.value && !operationalClient.value?.id) {
|
if (isCreateMode.value && !operationalClient.value?.id) {
|
||||||
saveError.message = 'This workspace needs an operational account before content can be created.';
|
saveError.message = 'This workspace needs an operational account before content can be created.';
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -683,10 +797,11 @@
|
|||||||
};
|
};
|
||||||
clearDraft();
|
clearDraft();
|
||||||
await router.replace({ name: 'content-item-detail', params: { id: created.id } });
|
await router.replace({ name: 'content-item-detail', params: { id: created.id } });
|
||||||
|
return created.id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
saveError.message = 'The content item could not be created.';
|
saveError.message = 'The content item could not be created.';
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -697,8 +812,10 @@
|
|||||||
|
|
||||||
persistDraft();
|
persistDraft();
|
||||||
form.changeSummary = '';
|
form.changeSummary = '';
|
||||||
|
return contentItemId.value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
saveError.message = 'The content revision could not be saved.';
|
saveError.message = 'The content revision could not be saved.';
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,6 +1049,7 @@
|
|||||||
calendarStore.fetchSources(workspaceId),
|
calendarStore.fetchSources(workspaceId),
|
||||||
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
|
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
|
||||||
workspaceStore.fetchMembers(workspaceId),
|
workspaceStore.fetchMembers(workspaceId),
|
||||||
|
detailStore.fetchAssets({ workspaceId }),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -1169,6 +1287,33 @@
|
|||||||
<option value="Carousel">Carousel</option>
|
<option value="Carousel">Carousel</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
class="dam-select"
|
||||||
|
:value="activePlacement.assetId"
|
||||||
|
aria-label="Use media from DAM"
|
||||||
|
@change="selectDamAsset(activePlacement, $event.target.value)"
|
||||||
|
>
|
||||||
|
<option value="">DAM media</option>
|
||||||
|
<option
|
||||||
|
v-for="asset in damAssets"
|
||||||
|
:key="asset.id"
|
||||||
|
:value="asset.id"
|
||||||
|
>
|
||||||
|
{{ asset.displayName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="media-upload-button">
|
||||||
|
<v-icon :icon="mdiUpload" />
|
||||||
|
<span>{{ detailStore.actions.asset ? 'Uploading' : 'Upload' }}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp,video/mp4,video/webm,video/quicktime"
|
||||||
|
:disabled="detailStore.actions.asset"
|
||||||
|
@change="uploadPlacementMedia(activePlacement, $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<v-btn variant="text" :ripple="false"
|
<v-btn variant="text" :ripple="false"
|
||||||
class="preview-remove"
|
class="preview-remove"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1188,8 +1333,20 @@
|
|||||||
v-if="activePlacement.mediaKind !== 'None'"
|
v-if="activePlacement.mediaKind !== 'None'"
|
||||||
class="youtube-player"
|
class="youtube-player"
|
||||||
>
|
>
|
||||||
<v-icon :icon="mdiYoutube" />
|
<video
|
||||||
<span>{{ activePlacement.mediaKind }}</span>
|
v-if="placementAssetPreviewUrl(activePlacement) && isPlacementVideo(activePlacement)"
|
||||||
|
:src="placementAssetPreviewUrl(activePlacement)"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else-if="placementAssetPreviewUrl(activePlacement)"
|
||||||
|
:src="placementAssetPreviewUrl(activePlacement)"
|
||||||
|
:alt="activePlacement.assetName || 'Selected media'"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<v-icon :icon="mdiYoutube" />
|
||||||
|
<span>{{ activePlacement.mediaKind }}</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="youtube-meta">
|
<div class="youtube-meta">
|
||||||
<input
|
<input
|
||||||
@@ -1224,6 +1381,25 @@
|
|||||||
data-placeholder="Write the post..."
|
data-placeholder="Write the post..."
|
||||||
@input="updatePlacementMessage(activePlacement, $event.currentTarget.innerText)"
|
@input="updatePlacementMessage(activePlacement, $event.currentTarget.innerText)"
|
||||||
>{{ placementMessage(activePlacement) }}</div>
|
>{{ placementMessage(activePlacement) }}</div>
|
||||||
|
<div
|
||||||
|
v-if="activePlacement.mediaKind !== 'None'"
|
||||||
|
class="feed-media is-x-media"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
v-if="placementAssetPreviewUrl(activePlacement) && isPlacementVideo(activePlacement)"
|
||||||
|
:src="placementAssetPreviewUrl(activePlacement)"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else-if="placementAssetPreviewUrl(activePlacement)"
|
||||||
|
:src="placementAssetPreviewUrl(activePlacement)"
|
||||||
|
:alt="activePlacement.assetName || 'Selected media'"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<v-icon :icon="mdiImageMultiple" />
|
||||||
|
<span>{{ activePlacement.mediaKind }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="sharedPreviewTags.length"
|
v-if="sharedPreviewTags.length"
|
||||||
class="preview-tags"
|
class="preview-tags"
|
||||||
@@ -1251,8 +1427,20 @@
|
|||||||
v-if="activePlacement.mediaKind !== 'None'"
|
v-if="activePlacement.mediaKind !== 'None'"
|
||||||
class="feed-media"
|
class="feed-media"
|
||||||
>
|
>
|
||||||
<v-icon :icon="channelIcon(activePlacement.network)" />
|
<video
|
||||||
<span>{{ activePlacement.mediaKind }}</span>
|
v-if="placementAssetPreviewUrl(activePlacement) && isPlacementVideo(activePlacement)"
|
||||||
|
:src="placementAssetPreviewUrl(activePlacement)"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else-if="placementAssetPreviewUrl(activePlacement)"
|
||||||
|
:src="placementAssetPreviewUrl(activePlacement)"
|
||||||
|
:alt="activePlacement.assetName || 'Selected media'"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<v-icon :icon="channelIcon(activePlacement.network)" />
|
||||||
|
<span>{{ activePlacement.mediaKind }}</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-if="activePlacementUsesTitle"
|
v-if="activePlacementUsesTitle"
|
||||||
@@ -1812,7 +2000,8 @@
|
|||||||
@apply h-4 w-4 accent-teal-700;
|
@apply h-4 w-4 accent-teal-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-kind-select {
|
.media-kind-select,
|
||||||
|
.dam-select {
|
||||||
@apply h-8 rounded-full border px-3 text-xs font-bold;
|
@apply h-8 rounded-full border px-3 text-xs font-bold;
|
||||||
background: var(--app-color-on-primary);
|
background: var(--app-color-on-primary);
|
||||||
border-color: var(--app-border-subtle);
|
border-color: var(--app-border-subtle);
|
||||||
@@ -1820,6 +2009,25 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dam-select {
|
||||||
|
@apply max-w-40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-upload-button {
|
||||||
|
@apply inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-bold;
|
||||||
|
background: var(--app-color-on-surface);
|
||||||
|
border-color: var(--app-color-on-surface);
|
||||||
|
color: var(--app-color-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-upload-button input {
|
||||||
|
@apply sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-upload-button:has(input:disabled) {
|
||||||
|
@apply cursor-wait opacity-70;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-profile div:last-child {
|
.preview-profile div:last-child {
|
||||||
@apply flex min-w-0 flex-col;
|
@apply flex min-w-0 flex-col;
|
||||||
}
|
}
|
||||||
@@ -1881,11 +2089,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.youtube-player {
|
.youtube-player {
|
||||||
@apply grid aspect-video w-full place-items-center rounded-[1rem];
|
@apply grid aspect-video w-full overflow-hidden rounded-[1rem];
|
||||||
background: #111827;
|
background: #111827;
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.youtube-player > :not(img):not(video),
|
||||||
|
.feed-media > :not(img):not(video) {
|
||||||
|
@apply self-center justify-self-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-player img,
|
||||||
|
.youtube-player video,
|
||||||
|
.feed-media img,
|
||||||
|
.feed-media video {
|
||||||
|
@apply h-full w-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
.youtube-player span,
|
.youtube-player span,
|
||||||
.feed-media span {
|
.feed-media span {
|
||||||
@apply rounded-full px-3 py-1 text-xs font-black uppercase tracking-[0.14em];
|
@apply rounded-full px-3 py-1 text-xs font-black uppercase tracking-[0.14em];
|
||||||
@@ -1917,11 +2137,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feed-media {
|
.feed-media {
|
||||||
@apply grid aspect-[4/5] w-full place-items-center gap-3 rounded-[1rem];
|
@apply grid aspect-[4/5] w-full overflow-hidden rounded-[1rem];
|
||||||
background: linear-gradient(135deg, #0f766e, #f97316);
|
background: linear-gradient(135deg, #0f766e, #f97316);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-media.is-x-media {
|
||||||
|
@apply aspect-[16/9];
|
||||||
|
}
|
||||||
|
|
||||||
.feed-media :deep(.v-icon) {
|
.feed-media :deep(.v-icon) {
|
||||||
@apply text-5xl;
|
@apply text-5xl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4564,10 +4564,19 @@
|
|||||||
{
|
{
|
||||||
"name": "contentItemId",
|
"name": "contentItemId",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": true,
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "guid"
|
"format": "guid",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "workspaceId",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid",
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4596,6 +4605,58 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/assets/upload": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Assets",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesAssetsHandlersUploadAssetHandler",
|
||||||
|
"requestBody": {
|
||||||
|
"x-name": "UploadAssetRequest",
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesAssetsHandlersUploadAssetRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"x-position": 1
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesAssetsHandlersAssetDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/approvals": {
|
"/api/approvals": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -7651,6 +7712,47 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"SocializeApiModulesAssetsHandlersUploadAssetRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"workspaceId",
|
||||||
|
"contentItemId",
|
||||||
|
"assetType",
|
||||||
|
"file"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"workspaceId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid",
|
||||||
|
"minLength": 1,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
"contentItemId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid",
|
||||||
|
"minLength": 1,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
"assetType": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
"displayName": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 256,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary",
|
||||||
|
"nullable": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SocializeApiModulesApprovalsHandlersApprovalRequestDto": {
|
"SocializeApiModulesApprovalsHandlersApprovalRequestDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -7813,4 +7915,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user