{{ sharedPreviewText }}
{{ sharedPreviewText }}
+diff --git a/docs/TASKS/content/008-multi-channel-preview-editor.md b/docs/TASKS/content/008-multi-channel-preview-editor.md new file mode 100644 index 00000000..3bfb6ad7 --- /dev/null +++ b/docs/TASKS/content/008-multi-channel-preview-editor.md @@ -0,0 +1,38 @@ +# Task: Redesign content editor for shared multi-channel previews + +## Feature + +`docs/FEATURES/channels.md` + +## Goal + +Make content creation feel like planning a real social post instead of filling out legacy form fields. + +The editor should use one shared content body for every selected target channel, show selectable target channels as a vertical tab-style list, and preview the same content in platform-shaped cards for networks such as YouTube and X. + +## Scope + +- Keep content item editing in `frontend/src/features/content/views/ContentItemDetailView.vue`. +- Replace per-channel caption editing with a shared caption and shared hashtags. +- Let configured workspace channels be selected or unselected as targets. +- Show selected targets in a vertical rail and render preview cards that look closer to social platform previews. +- Convert shared hashtags into chip-style entry with suggestions from already used hashtags. +- Show a workspace hashtag feed so authors can reuse existing tags. +- Preserve existing save payloads and backend contracts. + +## Validation + +```bash +cd frontend +npm run build +``` + +## Acceptance Criteria + +- [x] Content authors select target channels with checkbox-like controls. +- [x] The same shared content appears on every selected channel preview. +- [x] The editor includes platform-specific preview treatment for YouTube and X/Twitter. +- [x] Saving still creates or revises a content item with the selected targets. +- [x] Hashtags commit to removable chips as they are entered. +- [x] Already used hashtags are available as suggestions. +- [x] The editor shows a workspace hashtag feed with usage counts. diff --git a/frontend/src/features/content/views/ContentItemDetailView.vue b/frontend/src/features/content/views/ContentItemDetailView.vue index 9cf9436f..4fac3efe 100644 --- a/frontend/src/features/content/views/ContentItemDetailView.vue +++ b/frontend/src/features/content/views/ContentItemDetailView.vue @@ -3,7 +3,20 @@ import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useSessionStorage } from '@vueuse/core'; - import { mdiArrowLeft } from '@mdi/js'; + import { + mdiArrowLeft, + mdiCheckboxBlankOutline, + mdiCheckboxMarked, + mdiClose, + mdiFacebook, + mdiInstagram, + mdiLinkedin, + mdiMusicNote, + mdiReddit, + mdiTwitter, + mdiWeb, + mdiYoutube, + } from '@mdi/js'; import { useChannelsStore } from '@/features/channels/stores/channelsStore.js'; import { useClientsStore } from '@/features/clients/stores/clientsStore.js'; import ContentApprovalPanel from '@/features/content/components/ContentApprovalPanel.vue'; @@ -51,6 +64,8 @@ const assetRevisionForms = reactive({}); const activeProductionTab = ref('comments'); const activeCalendarEvent = ref(null); + const activePlacementId = ref(''); + const hashtagInput = ref(''); const saveError = reactive({ message: '', @@ -87,6 +102,60 @@ .filter(Boolean)) .join(', ') ); + const selectedPlacements = computed(() => + form.placements.filter(placement => placement.channelId || placement.channelName || placement.network) + ); + const activePlacement = computed(() => { + if (!selectedPlacements.value.length) { + return null; + } + + 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())); + const feed = new Map(); + + for (const contentItem of contentItemsStore.items) { + if ( + contentWorkspaceId.value && + contentItem.workspaceId && + contentItem.workspaceId !== contentWorkspaceId.value + ) { + continue; + } + + for (const tag of parseHashtags(contentItem.hashtags)) { + const normalized = normalizeHashtag(tag); + const key = normalized.toLowerCase(); + + if (!normalized) { + continue; + } + + const existing = feed.get(key) ?? { + tag: normalized, + count: 0, + isSelected: selected.has(key), + }; + + existing.count += 1; + existing.isSelected = selected.has(key); + feed.set(key, existing); + } + } + + return Array.from(feed.values()) + .sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)); + }); + const hashtagSuggestions = computed(() => + workspaceHashtagFeed.value + .filter(entry => !entry.isSelected) + .slice(0, 12) + ); const operationalClient = computed(() => clientsStore.operationalClient); const campaignNameById = computed(() => new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name])) @@ -178,11 +247,47 @@ } function addPlacement(channel = null) { - form.placements.push(blankPlacement(channel)); + if (channel) { + const existing = form.placements.find(placement => placement.channelId === channel.id); + + if (existing) { + activePlacementId.value = existing.id; + return; + } + } + + const placement = blankPlacement(channel); + placement.message = form.body; + placement.hashtags = form.hashtags; + form.placements.push(placement); + activePlacementId.value = placement.id; } function removePlacement(placementId) { form.placements = form.placements.filter(placement => placement.id !== placementId); + + if (activePlacementId.value === placementId) { + activePlacementId.value = form.placements[0]?.id ?? ''; + } + } + + function toggleChannel(channel) { + const existing = form.placements.find(placement => placement.channelId === channel.id); + + if (existing) { + removePlacement(existing.id); + return; + } + + addPlacement(channel); + } + + function isChannelSelected(channelId) { + return form.placements.some(placement => placement.channelId === channelId); + } + + function setActivePlacement(placement) { + activePlacementId.value = placement.id; } function addMedia(placement) { @@ -195,10 +300,72 @@ function removeHashtag(tagToRemove) { form.hashtags = parseHashtags(form.hashtags) - .filter(tag => tag.toLowerCase() !== tagToRemove.toLowerCase()) + .filter(tag => normalizeHashtag(tag).toLowerCase() !== normalizeHashtag(tagToRemove).toLowerCase()) .join(' '); } + function normalizeHashtag(value) { + const cleaned = (value ?? '') + .trim() + .replace(/^#+/, '') + .replace(/[^\p{L}\p{N}_-]/gu, ''); + + return cleaned ? `#${cleaned}` : ''; + } + + function addHashtag(value) { + const normalized = normalizeHashtag(value); + + if (!normalized) { + return; + } + + const tags = parseHashtags(form.hashtags); + const exists = tags.some(tag => normalizeHashtag(tag).toLowerCase() === normalized.toLowerCase()); + + if (!exists) { + form.hashtags = [...tags, normalized].join(' '); + } + } + + function commitHashtagInput() { + const entries = hashtagInput.value + .split(/[,\s]+/) + .map(entry => entry.trim()) + .filter(Boolean); + + for (const entry of entries) { + addHashtag(entry); + } + + hashtagInput.value = ''; + } + + function handleHashtagKeydown(event) { + if (event.key === 'Enter' || event.key === ',' || event.key === 'Tab') { + if (hashtagInput.value.trim()) { + event.preventDefault(); + commitHashtagInput(); + } + } + + if (event.key === 'Backspace' && !hashtagInput.value && sharedPreviewTags.value.length) { + removeHashtag(sharedPreviewTags.value.at(-1)); + } + } + + function handleHashtagPaste(event) { + const pasted = event.clipboardData?.getData('text'); + + if (!pasted || !/[\s,]/.test(pasted)) { + return; + } + + event.preventDefault(); + hashtagInput.value = pasted; + commitHashtagInput(); + } + function parseHashtags(value) { return (value ?? '') .split(/[,\s]+/) @@ -267,8 +434,8 @@ channelId, channelName, variantLabel: placement.variantLabel ?? '', - message: placement.message ?? '', - hashtags: placement.hashtags ?? '', + message: form.body, + hashtags: form.hashtags, mediaItems: (placement.mediaItems ?? []).map(media => ({ id: media.id ?? crypto.randomUUID(), mediaType: media.mediaType ?? 'Image', @@ -289,7 +456,11 @@ body: form.body, hashtags: form.hashtags, changeSummary: form.changeSummary, - placements: form.placements, + placements: form.placements.map(placement => ({ + ...placement, + message: form.body, + hashtags: form.hashtags, + })), })); } @@ -301,6 +472,7 @@ form.hashtags = draft.hashtags ?? ''; form.changeSummary = draft.changeSummary ?? ''; form.placements = normalizePlacements(draft.placements ?? []); + activePlacementId.value = form.placements[0]?.id ?? ''; } function buildDraftFromItem() { @@ -600,6 +772,69 @@ return new Intl.DateTimeFormat(locale.value, { weekday: 'short', day: 'numeric' }).format(date); } + function channelIcon(network) { + const normalized = (network ?? '').toLowerCase(); + + if (normalized.includes('youtube')) { + return mdiYoutube; + } + + if (normalized === 'x' || normalized.includes('twitter')) { + return mdiTwitter; + } + + if (normalized.includes('instagram')) { + return mdiInstagram; + } + + if (normalized.includes('facebook')) { + return mdiFacebook; + } + + if (normalized.includes('linkedin')) { + return mdiLinkedin; + } + + if (normalized.includes('reddit')) { + return mdiReddit; + } + + if (normalized.includes('tiktok')) { + return mdiMusicNote; + } + + return mdiWeb; + } + + function previewNetworkClass(network) { + const normalized = (network ?? '').toLowerCase(); + + if (normalized.includes('youtube')) { + return 'youtube'; + } + + if (normalized === 'x' || normalized.includes('twitter')) { + return 'x'; + } + + if (normalized.includes('instagram') || normalized.includes('tiktok')) { + return 'vertical'; + } + + return 'standard'; + } + + function previewProfileName(placement = activePlacement.value) { + return placement?.channelName || placement?.network || 'Selected channel'; + } + + function previewHandle(placement = activePlacement.value) { + const channel = availableChannels.value.find(candidate => candidate.id === placement?.channelId); + const handle = channel?.handle || placement?.channelName || placement?.network || 'channel'; + + return handle.startsWith('@') ? handle : `@${handle.replace(/\s+/g, '').toLowerCase()}`; + } + function startOfDay(value) { const date = new Date(value); date.setHours(0, 0, 0, 0); @@ -673,6 +908,7 @@ } await Promise.all([ + contentItemsStore.fetchContentItems(), calendarStore.fetchSources(workspaceId), calendarStore.fetchEvents({ workspaceId, startDate, endDate }), workspaceStore.fetchMembers(workspaceId), @@ -877,11 +1113,11 @@ Shared hashtags
+ +{{ sharedPreviewText }}
{{ sharedPreviewText }}
+