From 581d286a1c67b3ea83318c01cc42badf0fb4f829 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Sat, 9 May 2026 11:14:05 -0400 Subject: [PATCH] feat: redesign content editor previews --- .../008-multi-channel-preview-editor.md | 38 + .../content/views/ContentItemDetailView.vue | 861 ++++++++++++++---- 2 files changed, 727 insertions(+), 172 deletions(-) create mode 100644 docs/TASKS/content/008-multi-channel-preview-editor.md 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
-
+ +
+
+ Workspace hashtag feed + {{ workspaceHashtagFeed.length }} used +
+ +
+ + {{ entry.tag }} + {{ entry.count }} + +
+
-
+
- Channels and variants - - Add channel - + Target channels + {{ selectedPlacements.length }} selected
-
- - {{ group.network }} - -
- -
-
-
-
- {{ placement.channelName || placement.network || 'Channel' }} - {{ placement.variantLabel || 'Custom variant' }} -
- + + +
+
+ + + {{ placement.channelName || placement.network }}
-
- - - - - - - - - - - -
- -
-
- Media +
-
-
+ -
- Add at least one channel to define where this content will be published. -
+
+ Select one or more channels + The shared content will preview here for every selected target. +
+
+ @@ -1579,7 +1820,7 @@ } .hashtags-input-shell { - @apply flex flex-wrap items-center gap-2 rounded-[1rem] border px-4 py-3; + @apply flex min-h-14 flex-wrap items-center gap-2 rounded-[1rem] border px-4 py-3; background: var(--app-color-surface); border-color: var(--app-border-subtle); } @@ -1589,15 +1830,291 @@ } .hashtags-inline-input { - @apply min-w-[12rem] flex-1 border-0 bg-transparent p-0 text-sm; + @apply min-w-[10rem] flex-1 border-0 bg-transparent p-0 text-sm; color: var(--app-color-on-surface); outline: none; } + .hashtags-inline-input::placeholder { + color: var(--app-text-muted); + } + .hashtag-chip { @apply font-bold; } + .hashtag-feed { + @apply flex flex-col gap-2 rounded-[1rem] border p-3; + background: rgba(15, 118, 110, 0.04); + border-color: rgba(15, 118, 110, 0.12); + } + + .hashtag-feed-heading { + @apply flex items-center justify-between gap-3; + } + + .hashtag-feed-heading strong { + @apply text-xs font-bold uppercase tracking-[0.16em]; + color: var(--app-color-on-surface); + } + + .hashtag-feed-heading span { + @apply text-xs font-semibold; + color: var(--app-text-muted); + } + + .hashtag-suggestions { + @apply flex flex-wrap gap-2; + } + + .hashtag-suggestion { + @apply inline-flex min-h-8 items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold; + background: rgba(15, 118, 110, 0.06); + border-color: rgba(15, 118, 110, 0.14); + color: var(--app-color-on-tertiary); + } + + .hashtag-suggestion small { + @apply rounded-full px-1.5 py-0.5 text-[0.65rem] font-black leading-none; + background: rgba(15, 118, 110, 0.12); + color: var(--app-color-on-tertiary); + } + + .hashtag-suggestion:hover { + background: var(--app-color-on-tertiary); + color: var(--app-color-on-primary); + } + + .hashtag-suggestion:hover small { + background: rgba(255, 255, 255, 0.2); + color: var(--app-color-on-primary); + } + + .preview-editor-section { + @apply rounded-[1.25rem] border p-4; + background: #f8fafc; + border-color: var(--app-border-subtle); + } + + .preview-editor { + @apply grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]; + } + + .target-rail { + @apply flex min-w-0 flex-col gap-4 rounded-[1rem] border p-3; + background: var(--app-color-on-primary); + border-color: var(--app-border-subtle); + } + + .target-group { + @apply flex flex-col gap-2; + } + + .target-group > span { + @apply px-2 text-xs font-bold uppercase tracking-[0.16em]; + color: var(--app-text-muted); + } + + .target-channel { + @apply grid min-h-16 w-full grid-cols-[1.25rem_2rem_minmax(0,1fr)] items-center gap-3 rounded-[0.875rem] border px-3 py-2 text-left transition; + background: var(--app-control-subtle); + border-color: transparent; + color: var(--app-color-on-surface); + } + + .target-channel:hover, + .target-channel.selected { + background: rgba(15, 118, 110, 0.08); + border-color: rgba(15, 118, 110, 0.2); + } + + .target-channel.active { + background: var(--app-color-on-surface); + color: var(--app-color-on-primary); + } + + .target-check { + @apply text-lg; + color: var(--app-color-on-tertiary); + } + + .target-channel.active .target-check { + color: var(--app-color-on-primary); + } + + .target-network, + .preview-avatar { + @apply grid h-8 w-8 place-items-center rounded-full; + background: rgba(15, 118, 110, 0.12); + color: var(--app-color-on-tertiary); + } + + .target-channel.active .target-network { + background: rgba(255, 255, 255, 0.18); + color: var(--app-color-on-primary); + } + + .target-channel span { + @apply flex min-w-0 flex-col gap-0.5; + } + + .target-channel strong, + .target-channel small { + @apply block truncate; + } + + .target-channel strong { + @apply text-sm font-bold; + } + + .target-channel small { + @apply text-xs; + color: var(--app-text-muted); + } + + .target-channel.active small { + color: rgba(255, 255, 255, 0.72); + } + + .preview-stage { + @apply flex min-w-0 flex-col gap-3; + } + + .selected-preview-tabs { + @apply flex gap-2 overflow-x-auto pb-1; + } + + .selected-preview-tab { + @apply inline-flex min-h-10 shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-sm font-bold transition; + background: var(--app-color-on-primary); + border-color: var(--app-border-subtle); + color: var(--app-color-on-surface); + } + + .selected-preview-tab.active { + background: var(--app-color-on-surface); + border-color: var(--app-color-on-surface); + color: var(--app-color-on-primary); + } + + .social-preview-card { + @apply flex min-h-[28rem] flex-col gap-4 rounded-[1.25rem] border p-4 shadow-sm; + background: var(--app-color-on-primary); + border-color: var(--app-border-subtle); + } + + .preview-topbar, + .preview-profile { + @apply flex items-center gap-3; + } + + .preview-topbar { + @apply justify-between; + } + + .preview-profile div:last-child { + @apply flex min-w-0 flex-col; + } + + .preview-profile strong { + @apply truncate text-sm font-black; + color: var(--app-color-on-surface); + } + + .preview-profile span, + .youtube-meta span, + .x-engagement span { + @apply text-xs; + color: var(--app-text-muted); + } + + .preview-remove { + @apply grid h-9 w-9 place-items-center rounded-full p-0; + color: var(--app-text-muted); + } + + .youtube-preview, + .feed-preview, + .x-preview { + @apply flex flex-1 flex-col gap-4; + } + + .youtube-player { + @apply grid aspect-video w-full place-items-center rounded-[1rem]; + background: #111827; + color: #ef4444; + } + + .youtube-player :deep(.v-icon) { + @apply text-6xl; + } + + .youtube-meta { + @apply flex flex-col gap-2; + } + + .youtube-meta strong { + @apply text-xl font-black; + color: var(--app-color-on-surface); + } + + .youtube-meta p, + .x-preview p, + .feed-preview p { + @apply whitespace-pre-line text-sm leading-6; + color: var(--app-color-on-surface); + } + + .x-preview { + @apply rounded-[1rem] border p-4; + border-color: var(--app-border-subtle); + } + + .x-preview p { + @apply text-lg leading-8; + } + + .x-engagement { + @apply mt-auto grid grid-cols-4 gap-2 border-t pt-3 text-center; + border-color: var(--app-border-subtle); + } + + .feed-media { + @apply grid aspect-[4/5] w-full place-items-center rounded-[1rem]; + background: linear-gradient(135deg, #0f766e, #f97316); + color: white; + } + + .feed-media :deep(.v-icon) { + @apply text-5xl; + } + + .preview-tags { + @apply flex flex-wrap gap-2; + } + + .preview-tags span { + @apply rounded-full px-3 py-1 text-xs font-bold; + background: rgba(15, 118, 110, 0.1); + color: var(--app-color-on-tertiary); + } + + .preview-empty-state { + @apply grid min-h-[24rem] place-items-center rounded-[1.25rem] border p-8 text-center; + background: var(--app-color-on-primary); + border-color: var(--app-border-subtle); + } + + .preview-empty-state strong { + @apply text-lg font-black; + color: var(--app-color-on-surface); + } + + .preview-empty-state span { + @apply mt-2 block max-w-sm text-sm leading-6; + color: var(--app-text-muted); + } + .network-pill { @apply rounded-full border px-4 py-2 text-xs font-bold uppercase tracking-[0.16em]; background: var(--app-control-subtle);