feat: redesign content editor previews
This commit is contained in:
38
docs/TASKS/content/008-multi-channel-preview-editor.md
Normal file
38
docs/TASKS/content/008-multi-channel-preview-editor.md
Normal file
@@ -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.
|
||||||
@@ -3,7 +3,20 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useSessionStorage } from '@vueuse/core';
|
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 { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||||
import ContentApprovalPanel from '@/features/content/components/ContentApprovalPanel.vue';
|
import ContentApprovalPanel from '@/features/content/components/ContentApprovalPanel.vue';
|
||||||
@@ -51,6 +64,8 @@
|
|||||||
const assetRevisionForms = reactive({});
|
const assetRevisionForms = reactive({});
|
||||||
const activeProductionTab = ref('comments');
|
const activeProductionTab = ref('comments');
|
||||||
const activeCalendarEvent = ref(null);
|
const activeCalendarEvent = ref(null);
|
||||||
|
const activePlacementId = ref('');
|
||||||
|
const hashtagInput = ref('');
|
||||||
|
|
||||||
const saveError = reactive({
|
const saveError = reactive({
|
||||||
message: '',
|
message: '',
|
||||||
@@ -87,6 +102,60 @@
|
|||||||
.filter(Boolean))
|
.filter(Boolean))
|
||||||
.join(', ')
|
.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 operationalClient = computed(() => clientsStore.operationalClient);
|
||||||
const campaignNameById = computed(() =>
|
const campaignNameById = computed(() =>
|
||||||
new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name]))
|
new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name]))
|
||||||
@@ -178,11 +247,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addPlacement(channel = null) {
|
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) {
|
function removePlacement(placementId) {
|
||||||
form.placements = form.placements.filter(placement => placement.id !== 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) {
|
function addMedia(placement) {
|
||||||
@@ -195,10 +300,72 @@
|
|||||||
|
|
||||||
function removeHashtag(tagToRemove) {
|
function removeHashtag(tagToRemove) {
|
||||||
form.hashtags = parseHashtags(form.hashtags)
|
form.hashtags = parseHashtags(form.hashtags)
|
||||||
.filter(tag => tag.toLowerCase() !== tagToRemove.toLowerCase())
|
.filter(tag => normalizeHashtag(tag).toLowerCase() !== normalizeHashtag(tagToRemove).toLowerCase())
|
||||||
.join(' ');
|
.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) {
|
function parseHashtags(value) {
|
||||||
return (value ?? '')
|
return (value ?? '')
|
||||||
.split(/[,\s]+/)
|
.split(/[,\s]+/)
|
||||||
@@ -267,8 +434,8 @@
|
|||||||
channelId,
|
channelId,
|
||||||
channelName,
|
channelName,
|
||||||
variantLabel: placement.variantLabel ?? '',
|
variantLabel: placement.variantLabel ?? '',
|
||||||
message: placement.message ?? '',
|
message: form.body,
|
||||||
hashtags: placement.hashtags ?? '',
|
hashtags: form.hashtags,
|
||||||
mediaItems: (placement.mediaItems ?? []).map(media => ({
|
mediaItems: (placement.mediaItems ?? []).map(media => ({
|
||||||
id: media.id ?? crypto.randomUUID(),
|
id: media.id ?? crypto.randomUUID(),
|
||||||
mediaType: media.mediaType ?? 'Image',
|
mediaType: media.mediaType ?? 'Image',
|
||||||
@@ -289,7 +456,11 @@
|
|||||||
body: form.body,
|
body: form.body,
|
||||||
hashtags: form.hashtags,
|
hashtags: form.hashtags,
|
||||||
changeSummary: form.changeSummary,
|
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.hashtags = draft.hashtags ?? '';
|
||||||
form.changeSummary = draft.changeSummary ?? '';
|
form.changeSummary = draft.changeSummary ?? '';
|
||||||
form.placements = normalizePlacements(draft.placements ?? []);
|
form.placements = normalizePlacements(draft.placements ?? []);
|
||||||
|
activePlacementId.value = form.placements[0]?.id ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDraftFromItem() {
|
function buildDraftFromItem() {
|
||||||
@@ -600,6 +772,69 @@
|
|||||||
return new Intl.DateTimeFormat(locale.value, { weekday: 'short', day: 'numeric' }).format(date);
|
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) {
|
function startOfDay(value) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
date.setHours(0, 0, 0, 0);
|
date.setHours(0, 0, 0, 0);
|
||||||
@@ -673,6 +908,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
contentItemsStore.fetchContentItems(),
|
||||||
calendarStore.fetchSources(workspaceId),
|
calendarStore.fetchSources(workspaceId),
|
||||||
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
|
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
|
||||||
workspaceStore.fetchMembers(workspaceId),
|
workspaceStore.fetchMembers(workspaceId),
|
||||||
@@ -877,11 +1113,11 @@
|
|||||||
<span>Shared hashtags</span>
|
<span>Shared hashtags</span>
|
||||||
<div class="hashtags-input-shell">
|
<div class="hashtags-input-shell">
|
||||||
<div
|
<div
|
||||||
v-if="parseHashtags(form.hashtags).length"
|
v-if="sharedPreviewTags.length"
|
||||||
class="hashtags-editor"
|
class="hashtags-editor"
|
||||||
>
|
>
|
||||||
<v-chip
|
<v-chip
|
||||||
v-for="tag in parseHashtags(form.hashtags)"
|
v-for="tag in sharedPreviewTags"
|
||||||
:key="`form-${tag}`"
|
:key="`form-${tag}`"
|
||||||
class="hashtag-chip"
|
class="hashtag-chip"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -894,202 +1130,207 @@
|
|||||||
{{ tag.startsWith('#') ? tag : `#${tag}` }}
|
{{ tag.startsWith('#') ? tag : `#${tag}` }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</div>
|
</div>
|
||||||
<v-text-field
|
<input
|
||||||
v-model="form.hashtags"
|
v-model="hashtagInput"
|
||||||
class="hashtags-inline-input"
|
class="hashtags-inline-input"
|
||||||
placeholder="#launch #campaign #brand"
|
type="text"
|
||||||
density="compact"
|
placeholder="Add hashtag"
|
||||||
variant="plain"
|
@keydown="handleHashtagKeydown"
|
||||||
hide-details
|
@paste="handleHashtagPaste"
|
||||||
|
@blur="commitHashtagInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="workspaceHashtagFeed.length"
|
||||||
|
class="hashtag-feed"
|
||||||
|
>
|
||||||
|
<div class="hashtag-feed-heading">
|
||||||
|
<strong>Workspace hashtag feed</strong>
|
||||||
|
<span>{{ workspaceHashtagFeed.length }} used</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hashtag-suggestions">
|
||||||
|
<v-btn variant="text" :ripple="false"
|
||||||
|
v-for="entry in hashtagSuggestions"
|
||||||
|
:key="`suggestion-${entry.tag}`"
|
||||||
|
class="hashtag-suggestion"
|
||||||
|
type="button"
|
||||||
|
@click="addHashtag(entry.tag)"
|
||||||
|
>
|
||||||
|
<span>{{ entry.tag }}</span>
|
||||||
|
<small>{{ entry.count }}</small>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-section">
|
<div class="content-section preview-editor-section">
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<strong>Channels and variants</strong>
|
<strong>Target channels</strong>
|
||||||
<v-btn variant="text" :ripple="false"
|
<span>{{ selectedPlacements.length }} selected</span>
|
||||||
class="secondary-button"
|
|
||||||
type="button"
|
|
||||||
@click="addPlacement()"
|
|
||||||
>
|
|
||||||
Add channel
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="preview-editor">
|
||||||
v-if="groupedChannels.length"
|
<aside class="target-rail">
|
||||||
class="channel-suggestions"
|
<template v-if="groupedChannels.length">
|
||||||
>
|
<div
|
||||||
<v-btn variant="text" :ripple="false"
|
v-for="group in groupedChannels"
|
||||||
v-for="group in groupedChannels"
|
:key="group.network"
|
||||||
:key="group.network"
|
class="target-group"
|
||||||
class="network-pill"
|
|
||||||
type="button"
|
|
||||||
@click="addPlacement(group.channels[0])"
|
|
||||||
>
|
|
||||||
{{ group.network }}
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="form.placements.length"
|
|
||||||
class="placement-list"
|
|
||||||
>
|
|
||||||
<article
|
|
||||||
v-for="placement in form.placements"
|
|
||||||
:key="placement.id"
|
|
||||||
class="placement-card"
|
|
||||||
>
|
|
||||||
<div class="placement-header">
|
|
||||||
<div>
|
|
||||||
<strong>{{ placement.channelName || placement.network || 'Channel' }}</strong>
|
|
||||||
<span>{{ placement.variantLabel || 'Custom variant' }}</span>
|
|
||||||
</div>
|
|
||||||
<v-btn variant="text" :ripple="false"
|
|
||||||
class="link-button"
|
|
||||||
type="button"
|
|
||||||
@click="removePlacement(placement.id)"
|
|
||||||
>
|
>
|
||||||
Remove
|
<span>{{ group.network }}</span>
|
||||||
|
|
||||||
|
<v-btn variant="text" :ripple="false"
|
||||||
|
v-for="channel in group.channels"
|
||||||
|
:key="channel.id"
|
||||||
|
class="target-channel"
|
||||||
|
:class="{
|
||||||
|
selected: isChannelSelected(channel.id),
|
||||||
|
active: activePlacement?.channelId === channel.id,
|
||||||
|
}"
|
||||||
|
type="button"
|
||||||
|
@click="toggleChannel(channel)"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
class="target-check"
|
||||||
|
:icon="isChannelSelected(channel.id) ? mdiCheckboxMarked : mdiCheckboxBlankOutline"
|
||||||
|
/>
|
||||||
|
<v-icon
|
||||||
|
class="target-network"
|
||||||
|
:icon="channelIcon(channel.network)"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{{ channel.name }}</strong>
|
||||||
|
<small>{{ channel.handle || channel.network }}</small>
|
||||||
|
</span>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="empty-note"
|
||||||
|
>
|
||||||
|
Add workspace channels before choosing publication targets.
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="preview-stage">
|
||||||
|
<div
|
||||||
|
v-if="selectedPlacements.length"
|
||||||
|
class="selected-preview-tabs"
|
||||||
|
>
|
||||||
|
<v-btn variant="text" :ripple="false"
|
||||||
|
v-for="placement in selectedPlacements"
|
||||||
|
:key="placement.id"
|
||||||
|
class="selected-preview-tab"
|
||||||
|
:class="{ active: activePlacement?.id === placement.id }"
|
||||||
|
type="button"
|
||||||
|
@click="setActivePlacement(placement)"
|
||||||
|
>
|
||||||
|
<v-icon :icon="channelIcon(placement.network)" />
|
||||||
|
<span>{{ placement.channelName || placement.network }}</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-grid compact-grid">
|
<article
|
||||||
<v-text-field
|
v-if="activePlacement"
|
||||||
v-model="placement.network"
|
class="social-preview-card"
|
||||||
label="Network"
|
:class="`is-${previewNetworkClass(activePlacement.network)}`"
|
||||||
placeholder="Instagram, YouTube..."
|
>
|
||||||
variant="outlined"
|
<div class="preview-topbar">
|
||||||
hide-details
|
<div class="preview-profile">
|
||||||
/>
|
<div class="preview-avatar">
|
||||||
|
<v-icon :icon="channelIcon(activePlacement.network)" />
|
||||||
<v-select
|
</div>
|
||||||
v-model="placement.channelId"
|
<div>
|
||||||
:items="availableChannels.map(channel => ({
|
<strong>{{ previewProfileName(activePlacement) }}</strong>
|
||||||
title: `${channel.name}${channel.network ? ` · ${channel.network}` : ''}`,
|
<span>{{ previewHandle(activePlacement) }}</span>
|
||||||
value: channel.id,
|
</div>
|
||||||
}))"
|
</div>
|
||||||
label="Channel"
|
|
||||||
placeholder="Select a configured channel"
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
clearable
|
|
||||||
@update:model-value="syncPlacementChannel(placement, $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="placement.channelName"
|
|
||||||
label="Channel name"
|
|
||||||
placeholder="IG Feed, YouTube Main..."
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="placement.variantLabel"
|
|
||||||
label="Variant label"
|
|
||||||
placeholder="Reel version, Shorts version..."
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-textarea
|
|
||||||
v-model="placement.message"
|
|
||||||
class="field-wide"
|
|
||||||
label="Channel-specific caption"
|
|
||||||
rows="3"
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="placement.hashtags"
|
|
||||||
class="field-wide"
|
|
||||||
label="Channel-specific hashtags"
|
|
||||||
placeholder="#product #launch"
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="media-section">
|
|
||||||
<div class="section-title-row">
|
|
||||||
<strong>Media</strong>
|
|
||||||
<v-btn variant="text" :ripple="false"
|
<v-btn variant="text" :ripple="false"
|
||||||
class="secondary-button"
|
class="preview-remove"
|
||||||
type="button"
|
type="button"
|
||||||
@click="addMedia(placement)"
|
:aria-label="`Remove ${previewProfileName(activePlacement)}`"
|
||||||
|
@click="removePlacement(activePlacement.id)"
|
||||||
>
|
>
|
||||||
Add media
|
<v-icon :icon="mdiClose" />
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="placement.mediaItems.length"
|
v-if="previewNetworkClass(activePlacement.network) === 'youtube'"
|
||||||
class="media-list"
|
class="youtube-preview"
|
||||||
>
|
>
|
||||||
|
<div class="youtube-player">
|
||||||
|
<v-icon :icon="mdiYoutube" />
|
||||||
|
</div>
|
||||||
|
<div class="youtube-meta">
|
||||||
|
<strong>{{ form.title || 'Untitled video' }}</strong>
|
||||||
|
<span>{{ previewProfileName(activePlacement) }}</span>
|
||||||
|
<p>{{ sharedPreviewText }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="previewNetworkClass(activePlacement.network) === 'x'"
|
||||||
|
class="x-preview"
|
||||||
|
>
|
||||||
|
<p>{{ sharedPreviewText }}</p>
|
||||||
<div
|
<div
|
||||||
v-for="media in placement.mediaItems"
|
v-if="sharedPreviewTags.length"
|
||||||
:key="media.id"
|
class="preview-tags"
|
||||||
class="media-card"
|
|
||||||
>
|
>
|
||||||
<div class="form-grid compact-grid">
|
<span
|
||||||
<v-select
|
v-for="tag in sharedPreviewTags"
|
||||||
v-model="media.mediaType"
|
:key="`x-${tag}`"
|
||||||
:items="['Image', 'Video', 'Document']"
|
|
||||||
label="Media type"
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="media.label"
|
|
||||||
label="Label"
|
|
||||||
placeholder="Cover image, YouTube video..."
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="media.url"
|
|
||||||
class="field-wide"
|
|
||||||
label="Media URL / reference"
|
|
||||||
placeholder="Google Drive link or asset URL"
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-btn variant="text" :ripple="false"
|
|
||||||
class="link-button"
|
|
||||||
type="button"
|
|
||||||
@click="removeMedia(placement, media.id)"
|
|
||||||
>
|
>
|
||||||
Remove media
|
{{ tag.startsWith('#') ? tag : `#${tag}` }}
|
||||||
</v-btn>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="x-engagement">
|
||||||
|
<span>Reply</span>
|
||||||
|
<span>Repost</span>
|
||||||
|
<span>Like</span>
|
||||||
|
<span>Share</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="empty-note"
|
class="feed-preview"
|
||||||
>
|
>
|
||||||
Add media per channel, for example a video for YouTube or an image for Instagram.
|
<div class="feed-media">
|
||||||
|
<v-icon :icon="channelIcon(activePlacement.network)" />
|
||||||
|
</div>
|
||||||
|
<p>{{ sharedPreviewText }}</p>
|
||||||
|
<div
|
||||||
|
v-if="sharedPreviewTags.length"
|
||||||
|
class="preview-tags"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="tag in sharedPreviewTags"
|
||||||
|
:key="`feed-${tag}`"
|
||||||
|
>
|
||||||
|
{{ tag.startsWith('#') ? tag : `#${tag}` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="empty-note"
|
class="preview-empty-state"
|
||||||
>
|
>
|
||||||
Add at least one channel to define where this content will be published.
|
<strong>Select one or more channels</strong>
|
||||||
</div>
|
<span>The shared content will preview here for every selected target.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1579,7 +1820,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hashtags-input-shell {
|
.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);
|
background: var(--app-color-surface);
|
||||||
border-color: var(--app-border-subtle);
|
border-color: var(--app-border-subtle);
|
||||||
}
|
}
|
||||||
@@ -1589,15 +1830,291 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hashtags-inline-input {
|
.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);
|
color: var(--app-color-on-surface);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hashtags-inline-input::placeholder {
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.hashtag-chip {
|
.hashtag-chip {
|
||||||
@apply font-bold;
|
@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 {
|
.network-pill {
|
||||||
@apply rounded-full border px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
|
@apply rounded-full border px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
|
||||||
background: var(--app-control-subtle);
|
background: var(--app-control-subtle);
|
||||||
|
|||||||
Reference in New Issue
Block a user