diff --git a/docs/TASKS/content/008-multi-channel-preview-editor.md b/docs/TASKS/content/008-multi-channel-preview-editor.md index f479c218..55dfcae8 100644 --- a/docs/TASKS/content/008-multi-channel-preview-editor.md +++ b/docs/TASKS/content/008-multi-channel-preview-editor.md @@ -22,6 +22,10 @@ The editor should use one shared content body for every selected target channel, - Make target preview tabs compact. - Move the create content entry point into the top app menu bar. - Remove the asset-management tab from the content detail production panel. +- Move content editing into the channel preview cards instead of using a separate post text field. +- 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. ## Validation @@ -44,3 +48,7 @@ npm run build - [x] Target channel tabs use compact icon-only buttons with channel names as accessible labels and tooltips. - [x] Create content is available from the top app menu bar. - [x] Assets are no longer shown in the content detail production panel. +- [x] Authors edit the active channel preview directly. +- [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. diff --git a/frontend/src/features/content/views/ContentItemDetailView.vue b/frontend/src/features/content/views/ContentItemDetailView.vue index 780f946a..40b13424 100644 --- a/frontend/src/features/content/views/ContentItemDetailView.vue +++ b/frontend/src/features/content/views/ContentItemDetailView.vue @@ -96,8 +96,27 @@ const selectedPlacements = computed(() => form.placements.filter(placement => placement.channelId || placement.channelName || placement.network) ); + const primaryPublicationMessage = computed(() => { + const syncedMessage = selectedPlacements.value + .find(placement => isPlacementSynced(placement) && placement.message?.trim()) + ?.message; + const firstPlacementMessage = selectedPlacements.value + .find(placement => placement.message?.trim()) + ?.message; + + return form.body.trim() || syncedMessage?.trim() || firstPlacementMessage?.trim() || ''; + }); const derivedTitle = computed(() => { - const firstLine = form.body + const explicitTitle = form.title.trim() || selectedPlacements.value + .find(placement => placement.title?.trim()) + ?.title + ?.trim(); + + if (explicitTitle) { + return explicitTitle.length > 80 ? `${explicitTitle.slice(0, 77)}...` : explicitTitle; + } + + const firstLine = primaryPublicationMessage.value .split('\n') .map(line => line.trim()) .find(Boolean); @@ -116,7 +135,6 @@ return selectedPlacements.value.find(placement => placement.id === activePlacementId.value) ?? selectedPlacements.value[0]; }); - const sharedPreviewText = computed(() => form.body.trim() || 'Draft the shared caption once. Every selected channel will preview this same content.'); const sharedPreviewTags = computed(() => parseHashtags(form.hashtags)); const workspaceHashtagFeed = computed(() => { const selected = new Set(sharedPreviewTags.value.map(tag => normalizeHashtag(tag).toLowerCase())); @@ -218,6 +236,9 @@ }; }); }); + const activePlacementUsesTitle = computed(() => + activePlacement.value ? placementUsesTitle(activePlacement.value) : false + ); function blankPlacement(channel = null) { return { @@ -226,28 +247,15 @@ channelId: channel?.id ?? '', channelName: channel?.name ?? '', variantLabel: '', + title: form.title, message: form.body, + isSynced: true, hashtags: form.hashtags, + mediaKind: defaultMediaKind(channel?.network ?? ''), mediaItems: [], }; } - function blankMedia() { - return { - id: crypto.randomUUID(), - mediaType: 'Image', - label: '', - url: '', - }; - } - - function syncPlacementChannel(placement, value) { - const channel = availableChannels.value.find(candidate => candidate.id === value); - placement.channelId = value; - placement.channelName = channel?.name ?? ''; - placement.network = channel?.network ?? placement.network; - } - function addPlacement(channel = null) { if (channel) { const existing = form.placements.find(placement => placement.channelId === channel.id); @@ -288,12 +296,88 @@ return form.placements.some(placement => placement.channelId === channelId); } - function addMedia(placement) { - placement.mediaItems.push(blankMedia()); + function isPlacementSynced(placement) { + return placement?.isSynced !== false; } - function removeMedia(placement, mediaId) { - placement.mediaItems = placement.mediaItems.filter(media => media.id !== mediaId); + function updatePlacementSync(placement, value) { + placement.isSynced = value; + + if (value) { + placement.title = form.title; + placement.message = form.body; + placement.hashtags = form.hashtags; + } + } + + function placementMessage(placement) { + if (!placement) { + return ''; + } + + return isPlacementSynced(placement) ? form.body : placement.message; + } + + function updatePlacementMessage(placement, value) { + if (isPlacementSynced(placement)) { + form.body = value; + syncSharedPlacements(); + return; + } + + placement.message = value; + } + + function placementTitle(placement) { + if (!placement) { + return ''; + } + + return isPlacementSynced(placement) ? form.title : placement.title; + } + + function updatePlacementTitle(placement, value) { + if (isPlacementSynced(placement)) { + form.title = value; + syncSharedPlacements(); + return; + } + + placement.title = value; + } + + function syncSharedPlacements() { + for (const placement of form.placements) { + if (!isPlacementSynced(placement)) { + continue; + } + + placement.title = form.title; + placement.message = form.body; + placement.hashtags = form.hashtags; + } + } + + function placementUsesTitle(placement) { + const normalized = (placement?.network ?? '').toLowerCase(); + return normalized.includes('youtube') || + normalized.includes('reddit') || + normalized.includes('website') || + normalized.includes('newsletter'); + } + + function defaultMediaKind(network) { + const normalized = (network ?? '').toLowerCase(); + + if (normalized.includes('youtube') || normalized.includes('tiktok')) { + return 'Video'; + } + + if (normalized.includes('instagram')) { + return 'Image'; + } + + return 'None'; } function removeHashtag(tagToRemove) { @@ -432,8 +516,11 @@ channelId, channelName, variantLabel: placement.variantLabel ?? '', - message: form.body, - hashtags: form.hashtags, + title: placement.title ?? form.title, + message: placement.message ?? form.body, + isSynced: placement.isSynced !== false, + hashtags: placement.hashtags ?? form.hashtags, + mediaKind: placement.mediaKind ?? defaultMediaKind(network), mediaItems: (placement.mediaItems ?? []).map(media => ({ id: media.id ?? crypto.randomUUID(), mediaType: media.mediaType ?? 'Image', @@ -454,11 +541,7 @@ body: form.body, hashtags: form.hashtags, changeSummary: form.changeSummary, - placements: form.placements.map(placement => ({ - ...placement, - message: form.body, - hashtags: form.hashtags, - })), + placements: form.placements, })); } @@ -484,8 +567,11 @@ channelId: channel?.id ?? '', channelName: channel?.name ?? target, variantLabel: '', + title: item.value?.title ?? '', message: item.value?.publicationMessage ?? '', + isSynced: true, hashtags: item.value?.hashtags ?? '', + mediaKind: defaultMediaKind(channel?.network ?? ''), mediaItems: [], }; }); @@ -563,7 +649,9 @@ async function saveContent() { saveError.message = ''; - if (!form.body.trim() || !form.campaignId || !form.placements.length) { + syncSharedPlacements(); + + if (!primaryPublicationMessage.value || !form.campaignId || !form.placements.length) { saveError.message = 'Post text, campaign, and at least one channel are required.'; return; } @@ -576,7 +664,7 @@ const payload = { title: derivedTitle.value, campaignId: form.campaignId, - publicationMessage: form.body.trim(), + publicationMessage: primaryPublicationMessage.value, publicationTargets: placementSummary.value, hashtags: form.hashtags.trim(), dueDate: null, @@ -934,16 +1022,6 @@ {{ placementSummary || 'No channels selected yet' }} -
{{ sharedPreviewText }}
+