From 5a798d6650e052b544e56d55b4a829a7aed587c1 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Sat, 9 May 2026 12:42:20 -0400 Subject: [PATCH] feat: add content media dam uploads --- .../BlobStorage/Contracts/ContentTypes.cs | 44 ++- .../Modules/Assets/Handlers/GetAssets.cs | 52 +++- .../Modules/Assets/Handlers/UploadAsset.cs | 266 ++++++++++++++++++ .../008-multi-channel-preview-editor.md | 7 +- frontend/src/api/schema.d.ts | 71 ++++- .../content/stores/contentItemDetailStore.js | 40 +++ .../content/views/ContentItemDetailView.vue | 244 +++++++++++++++- shared/openapi/openapi.json | 108 ++++++- 8 files changed, 801 insertions(+), 31 deletions(-) create mode 100644 backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs diff --git a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContentTypes.cs b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContentTypes.cs index c4b9853a..7cb588f6 100644 --- a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContentTypes.cs +++ b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContentTypes.cs @@ -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 AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg, TextHtml]; + private static readonly HashSet 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 "" or "" tags string content = Encoding.UTF8.GetString(buffer); return content.Contains("", StringComparison.OrdinalIgnoreCase); diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs index 27e748b8..a2e0d850 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs @@ -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 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 assets = await dbContext.Assets - .Where(asset => asset.ContentItemId == request.ContentItemId) - .OrderBy(asset => asset.DisplayName) + List assets = await query + .OrderByDescending(asset => asset.CreatedAt) + .ThenBy(asset => asset.DisplayName) .Select(asset => new AssetDto( asset.Id, asset.WorkspaceId, diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs new file mode 100644 index 00000000..62c0c3c4 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/UploadAsset.cs @@ -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 +{ + 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 +{ + 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"; + } +} diff --git a/docs/TASKS/content/008-multi-channel-preview-editor.md b/docs/TASKS/content/008-multi-channel-preview-editor.md index 55dfcae8..a734393f 100644 --- a/docs/TASKS/content/008-multi-channel-preview-editor.md +++ b/docs/TASKS/content/008-multi-channel-preview-editor.md @@ -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. diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index ce09c2c4..d063723b 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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; + 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: { diff --git a/frontend/src/features/content/stores/contentItemDetailStore.js b/frontend/src/features/content/stores/contentItemDetailStore.js index a4c05248..d1ab2005 100644 --- a/frontend/src/features/content/stores/contentItemDetailStore.js +++ b/frontend/src/features/content/stores/contentItemDetailStore.js @@ -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, }; }); diff --git a/frontend/src/features/content/views/ContentItemDetailView.vue b/frontend/src/features/content/views/ContentItemDetailView.vue index 40b13424..9ed37b5e 100644 --- a/frontend/src/features/content/views/ContentItemDetailView.vue +++ b/frontend/src/features/content/views/ContentItemDetailView.vue @@ -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 @@ + + + + - - {{ activePlacement.mediaKind }} +