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 { 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 @@
|
||||
<span>Shared hashtags</span>
|
||||
<div class="hashtags-input-shell">
|
||||
<div
|
||||
v-if="parseHashtags(form.hashtags).length"
|
||||
v-if="sharedPreviewTags.length"
|
||||
class="hashtags-editor"
|
||||
>
|
||||
<v-chip
|
||||
v-for="tag in parseHashtags(form.hashtags)"
|
||||
v-for="tag in sharedPreviewTags"
|
||||
:key="`form-${tag}`"
|
||||
class="hashtag-chip"
|
||||
size="small"
|
||||
@@ -894,202 +1130,207 @@
|
||||
{{ tag.startsWith('#') ? tag : `#${tag}` }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="form.hashtags"
|
||||
<input
|
||||
v-model="hashtagInput"
|
||||
class="hashtags-inline-input"
|
||||
placeholder="#launch #campaign #brand"
|
||||
density="compact"
|
||||
variant="plain"
|
||||
hide-details
|
||||
type="text"
|
||||
placeholder="Add hashtag"
|
||||
@keydown="handleHashtagKeydown"
|
||||
@paste="handleHashtagPaste"
|
||||
@blur="commitHashtagInput"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<div class="content-section preview-editor-section">
|
||||
<div class="section-title-row">
|
||||
<strong>Channels and variants</strong>
|
||||
<v-btn variant="text" :ripple="false"
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
@click="addPlacement()"
|
||||
>
|
||||
Add channel
|
||||
</v-btn>
|
||||
<strong>Target channels</strong>
|
||||
<span>{{ selectedPlacements.length }} selected</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="groupedChannels.length"
|
||||
class="channel-suggestions"
|
||||
>
|
||||
<v-btn variant="text" :ripple="false"
|
||||
v-for="group in groupedChannels"
|
||||
:key="group.network"
|
||||
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)"
|
||||
<div class="preview-editor">
|
||||
<aside class="target-rail">
|
||||
<template v-if="groupedChannels.length">
|
||||
<div
|
||||
v-for="group in groupedChannels"
|
||||
:key="group.network"
|
||||
class="target-group"
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class="form-grid compact-grid">
|
||||
<v-text-field
|
||||
v-model="placement.network"
|
||||
label="Network"
|
||||
placeholder="Instagram, YouTube..."
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="placement.channelId"
|
||||
:items="availableChannels.map(channel => ({
|
||||
title: `${channel.name}${channel.network ? ` · ${channel.network}` : ''}`,
|
||||
value: channel.id,
|
||||
}))"
|
||||
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>
|
||||
<article
|
||||
v-if="activePlacement"
|
||||
class="social-preview-card"
|
||||
:class="`is-${previewNetworkClass(activePlacement.network)}`"
|
||||
>
|
||||
<div class="preview-topbar">
|
||||
<div class="preview-profile">
|
||||
<div class="preview-avatar">
|
||||
<v-icon :icon="channelIcon(activePlacement.network)" />
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ previewProfileName(activePlacement) }}</strong>
|
||||
<span>{{ previewHandle(activePlacement) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn variant="text" :ripple="false"
|
||||
class="secondary-button"
|
||||
class="preview-remove"
|
||||
type="button"
|
||||
@click="addMedia(placement)"
|
||||
:aria-label="`Remove ${previewProfileName(activePlacement)}`"
|
||||
@click="removePlacement(activePlacement.id)"
|
||||
>
|
||||
Add media
|
||||
<v-icon :icon="mdiClose" />
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="placement.mediaItems.length"
|
||||
class="media-list"
|
||||
v-if="previewNetworkClass(activePlacement.network) === 'youtube'"
|
||||
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
|
||||
v-for="media in placement.mediaItems"
|
||||
:key="media.id"
|
||||
class="media-card"
|
||||
v-if="sharedPreviewTags.length"
|
||||
class="preview-tags"
|
||||
>
|
||||
<div class="form-grid compact-grid">
|
||||
<v-select
|
||||
v-model="media.mediaType"
|
||||
: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)"
|
||||
<span
|
||||
v-for="tag in sharedPreviewTags"
|
||||
:key="`x-${tag}`"
|
||||
>
|
||||
Remove media
|
||||
</v-btn>
|
||||
{{ tag.startsWith('#') ? tag : `#${tag}` }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="x-engagement">
|
||||
<span>Reply</span>
|
||||
<span>Repost</span>
|
||||
<span>Like</span>
|
||||
<span>Share</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-note"
|
||||
>
|
||||
Add at least one channel to define where this content will be published.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="preview-empty-state"
|
||||
>
|
||||
<strong>Select one or more channels</strong>
|
||||
<span>The shared content will preview here for every selected target.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user