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 ImageJpeg = "image/jpeg";
|
||||
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 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(
|
||||
string contentType,
|
||||
@@ -37,6 +53,32 @@ internal static class ContentTypes
|
||||
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
|
||||
string content = Encoding.UTF8.GetString(buffer);
|
||||
return content.Contains("<!DOCTYPE html>", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -2,10 +2,11 @@ using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Assets.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||
|
||||
internal record GetAssetsRequest(Guid ContentItemId);
|
||||
internal record GetAssetsRequest(Guid? ContentItemId, Guid? WorkspaceId);
|
||||
|
||||
internal record AssetRevisionDto(
|
||||
Guid Id,
|
||||
@@ -44,23 +45,46 @@ internal class GetAssetsHandler(
|
||||
|
||||
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
|
||||
{
|
||||
var item = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||
if (item is null)
|
||||
IQueryable<Asset> query = dbContext.Assets;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<AssetDto> assets = await dbContext.Assets
|
||||
.Where(asset => asset.ContentItemId == request.ContentItemId)
|
||||
.OrderBy(asset => asset.DisplayName)
|
||||
List<AssetDto> assets = await query
|
||||
.OrderByDescending(asset => asset.CreatedAt)
|
||||
.ThenBy(asset => asset.DisplayName)
|
||||
.Select(asset => new AssetDto(
|
||||
asset.Id,
|
||||
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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
dotnet build backend/Socialize.slnx
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
@@ -52,3 +55,5 @@ npm run build
|
||||
- [x] Channel copy/title is synchronized by default and can be unsynchronized per target.
|
||||
- [x] Title input only appears for title-oriented targets.
|
||||
- [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;
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2307,6 +2323,16 @@ export interface components {
|
||||
previewUrl?: string | null;
|
||||
};
|
||||
SocializeApiModulesAssetsHandlersGetAssetsRequest: Record<string, never>;
|
||||
SocializeApiModulesAssetsHandlersUploadAssetRequest: {
|
||||
/** Format: guid */
|
||||
workspaceId: string;
|
||||
/** Format: guid */
|
||||
contentItemId: string;
|
||||
assetType: string;
|
||||
displayName?: string | null;
|
||||
/** Format: binary */
|
||||
file: string;
|
||||
};
|
||||
SocializeApiModulesApprovalsHandlersApprovalRequestDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
@@ -5758,8 +5784,9 @@ export interface operations {
|
||||
};
|
||||
SocializeApiModulesAssetsHandlersGetAssetsHandler: {
|
||||
parameters: {
|
||||
query: {
|
||||
contentItemId: string;
|
||||
query?: {
|
||||
contentItemId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
header?: 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: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
const revisions = ref([]);
|
||||
const comments = ref([]);
|
||||
const approvals = ref([]);
|
||||
const assets = ref([]);
|
||||
const notifications = ref([]);
|
||||
const activity = ref([]);
|
||||
const isLoading = ref(false);
|
||||
@@ -20,6 +21,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
comment: false,
|
||||
decision: false,
|
||||
status: false,
|
||||
asset: false,
|
||||
});
|
||||
|
||||
function currentItemWorkspaceId() {
|
||||
@@ -31,6 +33,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
revisions.value = [];
|
||||
comments.value = [];
|
||||
approvals.value = [];
|
||||
assets.value = [];
|
||||
notifications.value = [];
|
||||
activity.value = [];
|
||||
error.value = null;
|
||||
@@ -60,6 +63,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
comments.value = commentsResponse.data ?? [];
|
||||
approvals.value = approvalsResponse.data ?? [];
|
||||
activity.value = activityResponse.data ?? [];
|
||||
await fetchAssets({ workspaceId: item.value?.workspaceId });
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to load content item detail:', fetchError);
|
||||
reset();
|
||||
@@ -168,11 +172,45 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
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 {
|
||||
item,
|
||||
revisions,
|
||||
comments,
|
||||
approvals,
|
||||
assets,
|
||||
notifications,
|
||||
activity,
|
||||
isLoading,
|
||||
@@ -185,5 +223,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
submitDecision,
|
||||
updateStatus,
|
||||
fetchActivity,
|
||||
fetchAssets,
|
||||
uploadAsset,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
mdiClose,
|
||||
mdiFacebook,
|
||||
mdiInstagram,
|
||||
mdiImageMultiple,
|
||||
mdiLinkedin,
|
||||
mdiMusicNote,
|
||||
mdiReddit,
|
||||
mdiTwitter,
|
||||
mdiUpload,
|
||||
mdiWeb,
|
||||
mdiYoutube,
|
||||
} from '@mdi/js';
|
||||
@@ -135,6 +137,9 @@
|
||||
return selectedPlacements.value.find(placement => placement.id === activePlacementId.value)
|
||||
?? selectedPlacements.value[0];
|
||||
});
|
||||
const damAssets = computed(() =>
|
||||
detailStore.assets.filter(asset => !contentWorkspaceId.value || asset.workspaceId === contentWorkspaceId.value)
|
||||
);
|
||||
const sharedPreviewTags = computed(() => parseHashtags(form.hashtags));
|
||||
const workspaceHashtagFeed = computed(() => {
|
||||
const selected = new Set(sharedPreviewTags.value.map(tag => normalizeHashtag(tag).toLowerCase()));
|
||||
@@ -253,6 +258,10 @@
|
||||
hashtags: form.hashtags,
|
||||
mediaKind: defaultMediaKind(channel?.network ?? ''),
|
||||
mediaItems: [],
|
||||
assetId: '',
|
||||
assetName: '',
|
||||
assetPreviewUrl: '',
|
||||
assetSourceType: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -380,6 +389,103 @@
|
||||
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) {
|
||||
form.hashtags = parseHashtags(form.hashtags)
|
||||
.filter(tag => normalizeHashtag(tag).toLowerCase() !== normalizeHashtag(tagToRemove).toLowerCase())
|
||||
@@ -527,6 +633,10 @@
|
||||
label: media.label ?? '',
|
||||
url: media.url ?? '',
|
||||
})),
|
||||
assetId: placement.assetId ?? '',
|
||||
assetName: placement.assetName ?? '',
|
||||
assetPreviewUrl: placement.assetPreviewUrl ?? '',
|
||||
assetSourceType: placement.assetSourceType ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -573,6 +683,10 @@
|
||||
hashtags: item.value?.hashtags ?? '',
|
||||
mediaKind: defaultMediaKind(channel?.network ?? ''),
|
||||
mediaItems: [],
|
||||
assetId: '',
|
||||
assetName: '',
|
||||
assetPreviewUrl: '',
|
||||
assetSourceType: '',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -653,12 +767,12 @@
|
||||
|
||||
if (!primaryPublicationMessage.value || !form.campaignId || !form.placements.length) {
|
||||
saveError.message = 'Post text, campaign, and at least one channel are required.';
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCreateMode.value && !operationalClient.value?.id) {
|
||||
saveError.message = 'This workspace needs an operational account before content can be created.';
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -683,10 +797,11 @@
|
||||
};
|
||||
clearDraft();
|
||||
await router.replace({ name: 'content-item-detail', params: { id: created.id } });
|
||||
return created.id;
|
||||
} catch (error) {
|
||||
saveError.message = 'The content item could not be created.';
|
||||
return null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -697,8 +812,10 @@
|
||||
|
||||
persistDraft();
|
||||
form.changeSummary = '';
|
||||
return contentItemId.value;
|
||||
} catch (error) {
|
||||
saveError.message = 'The content revision could not be saved.';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,6 +1049,7 @@
|
||||
calendarStore.fetchSources(workspaceId),
|
||||
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
|
||||
workspaceStore.fetchMembers(workspaceId),
|
||||
detailStore.fetchAssets({ workspaceId }),
|
||||
]);
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -1169,6 +1287,33 @@
|
||||
<option value="Carousel">Carousel</option>
|
||||
</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"
|
||||
class="preview-remove"
|
||||
type="button"
|
||||
@@ -1188,8 +1333,20 @@
|
||||
v-if="activePlacement.mediaKind !== 'None'"
|
||||
class="youtube-player"
|
||||
>
|
||||
<v-icon :icon="mdiYoutube" />
|
||||
<span>{{ activePlacement.mediaKind }}</span>
|
||||
<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="mdiYoutube" />
|
||||
<span>{{ activePlacement.mediaKind }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="youtube-meta">
|
||||
<input
|
||||
@@ -1224,6 +1381,25 @@
|
||||
data-placeholder="Write the post..."
|
||||
@input="updatePlacementMessage(activePlacement, $event.currentTarget.innerText)"
|
||||
>{{ 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
|
||||
v-if="sharedPreviewTags.length"
|
||||
class="preview-tags"
|
||||
@@ -1251,8 +1427,20 @@
|
||||
v-if="activePlacement.mediaKind !== 'None'"
|
||||
class="feed-media"
|
||||
>
|
||||
<v-icon :icon="channelIcon(activePlacement.network)" />
|
||||
<span>{{ activePlacement.mediaKind }}</span>
|
||||
<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="channelIcon(activePlacement.network)" />
|
||||
<span>{{ activePlacement.mediaKind }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<input
|
||||
v-if="activePlacementUsesTitle"
|
||||
@@ -1812,7 +2000,8 @@
|
||||
@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;
|
||||
background: var(--app-color-on-primary);
|
||||
border-color: var(--app-border-subtle);
|
||||
@@ -1820,6 +2009,25 @@
|
||||
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 {
|
||||
@apply flex min-w-0 flex-col;
|
||||
}
|
||||
@@ -1881,11 +2089,23 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
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,
|
||||
.feed-media span {
|
||||
@apply rounded-full px-3 py-1 text-xs font-black uppercase tracking-[0.14em];
|
||||
@@ -1917,11 +2137,15 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feed-media.is-x-media {
|
||||
@apply aspect-[16/9];
|
||||
}
|
||||
|
||||
.feed-media :deep(.v-icon) {
|
||||
@apply text-5xl;
|
||||
}
|
||||
|
||||
@@ -4564,10 +4564,19 @@
|
||||
{
|
||||
"name": "contentItemId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7651,6 +7712,47 @@
|
||||
"type": "object",
|
||||
"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": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
Reference in New Issue
Block a user