Files
social-media/frontend/src/features/content/views/ContentItemDetailView.vue
Jonathan Bourdon 581d286a1c
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 19s
feat: redesign content editor previews
2026-05-09 11:14:05 -04:00

2281 lines
80 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useSessionStorage } from '@vueuse/core';
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';
import ContentCommentComposer from '@/features/content/components/ContentCommentComposer.vue';
import ContentCommentFeed from '@/features/content/components/ContentCommentFeed.vue';
import { useCalendarIntegrationsStore } from '@/features/content/stores/calendarIntegrationsStore.js';
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const workspaceStore = useWorkspaceStore();
const campaignsStore = useCampaignsStore();
const clientsStore = useClientsStore();
const channelsStore = useChannelsStore();
const calendarStore = useCalendarIntegrationsStore();
const contentItemsStore = useContentItemsStore();
const detailStore = useContentItemDetailStore();
const editorDrafts = useSessionStorage('content-editor-drafts', {}, {
mergeDefaults: true,
});
const form = reactive({
title: '',
campaignId: '',
dueDate: '',
body: '',
hashtags: '',
changeSummary: '',
placements: [],
});
const assetForm = reactive({
assetType: 'Image',
displayName: '',
googleDriveFileId: '',
googleDriveLink: '',
previewUrl: '',
});
const assetRevisionForms = reactive({});
const activeProductionTab = ref('comments');
const activeCalendarEvent = ref(null);
const activePlacementId = ref('');
const hashtagInput = ref('');
const saveError = reactive({
message: '',
});
const isCreateMode = computed(() => route.name === 'content-item-create');
const contentItemId = computed(() => isCreateMode.value ? null : route.params.id);
const item = computed(() => detailStore.item);
const availableCampaigns = computed(() => campaignsStore.campaigns);
const contentWorkspaceId = computed(() => item.value?.workspaceId ?? workspaceStore.activeWorkspaceId);
const availableChannels = computed(() =>
channelsStore.channels.filter(channel =>
!contentWorkspaceId.value || channel.workspaceId === contentWorkspaceId.value
)
);
const groupedChannels = computed(() => {
const groups = new Map();
for (const channel of availableChannels.value) {
const network = channel.network || 'Other';
const existing = groups.get(network) ?? [];
existing.push(channel);
groups.set(network, existing);
}
return Array.from(groups.entries()).map(([network, channels]) => ({
network,
channels,
}));
});
const placementSummary = computed(() =>
uniqueTargets(form.placements
.map(placement => placement.channelName || placement.network)
.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]))
);
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.campaignId ?? 'default'}` : String(route.params.id));
const approvalMode = computed(() => workspaceStore.activeWorkspace?.approvalMode ?? 'Required');
const productionTabs = computed(() => [
{ key: 'comments', label: 'Comments', count: detailStore.comments.length },
{ key: 'revisions', label: 'Revisions', count: detailStore.revisions.length },
{ key: 'assets', label: 'Assets', count: detailStore.assets.length },
{ key: 'activity', label: 'Activity', count: detailStore.activity.length },
]);
const workspaceMembers = computed(() =>
contentWorkspaceId.value
? workspaceStore.membersByWorkspace[contentWorkspaceId.value] ?? []
: []
);
const selectedDateKey = computed(() => /^\d{4}-\d{2}-\d{2}$/.test(form.dueDate) ? form.dueDate : '');
const contextAnchorDate = computed(() => selectedDateKey.value ? parseDateKey(selectedDateKey.value) : startOfDay(new Date()));
const calendarFetchRange = computed(() => {
const start = startOfMonth(contextAnchorDate.value);
const end = endOfMonth(contextAnchorDate.value);
return {
startDate: dateKey(addDays(start, -7)),
endDate: dateKey(addDays(end, 7)),
};
});
const visibleCalendarEventsByDate = computed(() => {
const groups = new Map();
for (const event of calendarStore.visibleEvents) {
const key = dateKey(event.startDate);
const existing = groups.get(key) ?? [];
existing.push(event);
groups.set(key, existing);
}
return groups;
});
const selectedDateCalendarEvents = computed(() =>
selectedDateKey.value
? visibleCalendarEventsByDate.value.get(selectedDateKey.value) ?? []
: []
);
const dateContextDays = computed(() => {
const anchor = contextAnchorDate.value;
return Array.from({ length: 7 }, (_, index) => {
const date = addDays(anchor, index - 3);
const key = dateKey(date);
return {
key,
date,
isSelected: key === selectedDateKey.value,
events: visibleCalendarEventsByDate.value.get(key) ?? [],
};
});
});
function blankPlacement(channel = null) {
return {
id: crypto.randomUUID(),
network: channel?.network ?? '',
channelId: channel?.id ?? '',
channelName: channel?.name ?? '',
variantLabel: '',
message: form.body,
hashtags: form.hashtags,
mediaItems: [],
};
}
function blankMedia() {
return {
id: crypto.randomUUID(),
mediaType: 'Image',
label: '',
url: '',
};
}
function syncPlacementChannel(placement, value) {
const channel = availableChannels.value.find(candidate => candidate.id === value);
placement.channelId = value;
placement.channelName = channel?.name ?? '';
placement.network = channel?.network ?? placement.network;
}
function addPlacement(channel = null) {
if (channel) {
const existing = form.placements.find(placement => placement.channelId === channel.id);
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) {
placement.mediaItems.push(blankMedia());
}
function removeMedia(placement, mediaId) {
placement.mediaItems = placement.mediaItems.filter(media => media.id !== mediaId);
}
function removeHashtag(tagToRemove) {
form.hashtags = parseHashtags(form.hashtags)
.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]+/)
.map(tag => tag.trim())
.filter(Boolean);
}
function uniqueTargets(targets) {
const seen = new Set();
const unique = [];
for (const target of targets) {
const normalized = target.trim();
const key = normalized.toLowerCase();
if (!normalized || seen.has(key)) {
continue;
}
seen.add(key);
unique.push(normalized);
}
return unique;
}
function parseTargets(value) {
return uniqueTargets((value ?? '')
.split(',')
.map(entry => entry.trim())
.filter(Boolean));
}
function normalizePlacements(placements) {
const seen = new Set();
const normalizedPlacements = [];
for (const placement of placements) {
const targetLabel = (placement.channelName || placement.network || '').trim();
const knownChannel = channelsStore.channels.find(candidate =>
candidate.id === placement.channelId ||
candidate.name.toLowerCase() === targetLabel.toLowerCase()
);
const channel = availableChannels.value.find(candidate =>
candidate.id === placement.channelId ||
candidate.name.toLowerCase() === targetLabel.toLowerCase()
);
if (knownChannel && !channel) {
continue;
}
const channelId = channel?.id ?? placement.channelId ?? '';
const channelName = channel?.name ?? placement.channelName ?? '';
const network = channel?.network ?? placement.network ?? '';
const key = (channelName || network || placement.id || crypto.randomUUID()).toLowerCase();
if ((channelName || network) && seen.has(key)) {
continue;
}
seen.add(key);
normalizedPlacements.push({
id: placement.id ?? crypto.randomUUID(),
network,
channelId,
channelName,
variantLabel: placement.variantLabel ?? '',
message: form.body,
hashtags: form.hashtags,
mediaItems: (placement.mediaItems ?? []).map(media => ({
id: media.id ?? crypto.randomUUID(),
mediaType: media.mediaType ?? 'Image',
label: media.label ?? '',
url: media.url ?? '',
})),
});
}
return normalizedPlacements;
}
function serializeDraft() {
return JSON.parse(JSON.stringify({
title: form.title,
campaignId: form.campaignId,
dueDate: form.dueDate,
body: form.body,
hashtags: form.hashtags,
changeSummary: form.changeSummary,
placements: form.placements.map(placement => ({
...placement,
message: form.body,
hashtags: form.hashtags,
})),
}));
}
function restoreDraft(draft) {
form.title = draft.title ?? '';
form.campaignId = draft.campaignId ?? availableCampaigns.value[0]?.id ?? '';
form.dueDate = draft.dueDate ?? '';
form.body = draft.body ?? '';
form.hashtags = draft.hashtags ?? '';
form.changeSummary = draft.changeSummary ?? '';
form.placements = normalizePlacements(draft.placements ?? []);
activePlacementId.value = form.placements[0]?.id ?? '';
}
function buildDraftFromItem() {
const campaignId = item.value?.campaignId ?? '';
const placements = parseTargets(item.value?.publicationTargets).map(target => {
const channel = availableChannels.value.find(candidate => candidate.name.toLowerCase() === target.toLowerCase());
return {
id: crypto.randomUUID(),
network: channel?.network ?? '',
channelId: channel?.id ?? '',
channelName: channel?.name ?? target,
variantLabel: '',
message: item.value?.publicationMessage ?? '',
hashtags: item.value?.hashtags ?? '',
mediaItems: [],
};
});
restoreDraft({
title: item.value?.title ?? '',
campaignId,
dueDate: item.value?.dueDate ? new Date(item.value.dueDate).toISOString().slice(0, 10) : '',
body: item.value?.publicationMessage ?? '',
hashtags: item.value?.hashtags ?? '',
changeSummary: '',
placements: placements.length ? placements : [blankPlacement()],
});
}
function buildDraftForNew() {
const campaignIdFromRoute = typeof route.query.campaignId === 'string' ? route.query.campaignId : '';
const titleFromRoute = typeof route.query.title === 'string' ? route.query.title : '';
const dateFromRoute = typeof route.query.date === 'string' ? route.query.date : '';
restoreDraft({
title: titleFromRoute,
campaignId: availableCampaigns.value.some(campaign => campaign.id === campaignIdFromRoute)
? campaignIdFromRoute
: availableCampaigns.value[0]?.id ?? '',
dueDate: /^\d{4}-\d{2}-\d{2}$/.test(dateFromRoute) ? dateFromRoute : '',
body: titleFromRoute,
hashtags: '',
changeSummary: '',
placements: [],
});
}
async function hydrateEditor() {
saveError.message = '';
if (isCreateMode.value) {
const draft = editorDrafts.value[editorKey.value];
if (draft) {
restoreDraft(draft);
} else {
buildDraftForNew();
}
return;
}
if (!contentItemId.value) {
return;
}
await detailStore.fetchContentItemDetail(contentItemId.value);
const draft = editorDrafts.value[editorKey.value];
if (draft) {
restoreDraft(draft);
return;
}
buildDraftFromItem();
}
function persistDraft() {
editorDrafts.value = {
...editorDrafts.value,
[editorKey.value]: serializeDraft(),
};
}
function clearDraft(key = editorKey.value) {
const nextDrafts = { ...editorDrafts.value };
delete nextDrafts[key];
editorDrafts.value = nextDrafts;
}
async function saveContent() {
saveError.message = '';
if (!form.title.trim() || !form.campaignId || !form.placements.length) {
saveError.message = 'Title, campaign, and at least one channel are required.';
return;
}
if (isCreateMode.value && !operationalClient.value?.id) {
saveError.message = 'This workspace needs an operational account before content can be created.';
return;
}
const payload = {
title: form.title.trim(),
campaignId: form.campaignId,
publicationMessage: form.body.trim(),
publicationTargets: placementSummary.value,
hashtags: form.hashtags.trim(),
dueDate: form.dueDate ? new Date(form.dueDate).toISOString() : null,
};
if (isCreateMode.value) {
try {
const created = await contentItemsStore.createContentItem({
clientId: operationalClient.value.id,
...payload,
});
editorDrafts.value = {
...editorDrafts.value,
[String(created.id)]: serializeDraft(),
};
clearDraft();
await router.replace({ name: 'content-item-detail', params: { id: created.id } });
} catch (error) {
saveError.message = 'The content item could not be created.';
}
return;
}
try {
await detailStore.createRevision(contentItemId.value, {
...payload,
changeSummary: form.changeSummary.trim() || 'Updated content plan',
});
persistDraft();
form.changeSummary = '';
} catch (error) {
saveError.message = 'The content revision could not be saved.';
}
}
async function submitDecision(approvalId, payload) {
if (!contentItemId.value) {
return;
}
await detailStore.submitDecision(contentItemId.value, approvalId, payload);
}
async function submitComment(payload) {
if (!contentItemId.value || !payload?.body?.trim()) {
return;
}
await detailStore.addComment(contentItemId.value, payload);
}
function inferGoogleDriveFileId(value) {
const trimmed = value.trim();
const filePathMatch = trimmed.match(/\/d\/([^/]+)/);
const queryMatch = trimmed.match(/[?&]id=([^&]+)/);
return filePathMatch?.[1] ?? queryMatch?.[1] ?? '';
}
function resetAssetForm() {
assetForm.assetType = 'Image';
assetForm.displayName = '';
assetForm.googleDriveFileId = '';
assetForm.googleDriveLink = '';
assetForm.previewUrl = '';
}
async function linkGoogleDriveAsset() {
if (!contentItemId.value || !assetForm.displayName.trim() || !assetForm.googleDriveLink.trim()) {
return;
}
const googleDriveFileId = assetForm.googleDriveFileId.trim() || inferGoogleDriveFileId(assetForm.googleDriveLink);
if (!googleDriveFileId) {
saveError.message = 'A Google Drive file id is required when it cannot be read from the link.';
return;
}
await detailStore.addGoogleDriveAsset(contentItemId.value, {
assetType: assetForm.assetType,
displayName: assetForm.displayName.trim(),
googleDriveFileId,
googleDriveLink: assetForm.googleDriveLink.trim(),
previewUrl: assetForm.previewUrl.trim() || null,
});
resetAssetForm();
}
function assetRevisionForm(assetId) {
if (!assetRevisionForms[assetId]) {
assetRevisionForms[assetId] = {
sourceReference: '',
previewUrl: '',
notes: '',
};
}
return assetRevisionForms[assetId];
}
async function addAssetRevision(asset) {
const draft = assetRevisionForm(asset.id);
if (!contentItemId.value || !draft.sourceReference.trim()) {
return;
}
await detailStore.addAssetRevision(contentItemId.value, asset.id, {
sourceReference: draft.sourceReference.trim(),
previewUrl: draft.previewUrl.trim() || null,
notes: draft.notes.trim() || null,
});
assetRevisionForms[asset.id] = {
sourceReference: '',
previewUrl: '',
notes: '',
};
}
async function navigateBackToContent() {
const returnTo = typeof route.query.returnTo === 'string' ? route.query.returnTo : '';
const previousPath = router.options.history.state.back;
if (returnTo.startsWith('/app/content')) {
await router.push(returnTo);
return;
}
if (typeof previousPath === 'string' && previousPath.startsWith('/app/content')) {
router.back();
return;
}
await router.push({ name: 'content-items' });
}
function showCalendarEvent(event) {
activeCalendarEvent.value = {
...event,
source: calendarStore.sourceById(event.calendarSourceId),
};
}
function closeCalendarEvent() {
activeCalendarEvent.value = null;
}
async function navigateToCalendarDay(event = activeCalendarEvent.value) {
const targetDate = event?.startDate ? dateKey(event.startDate) : selectedDateKey.value;
if (!targetDate) {
return;
}
await router.push({
name: 'content-items',
query: {
view: 'month',
date: targetDate,
},
});
}
function selectContextDate(key) {
form.dueDate = key;
}
function calendarEventSource(event) {
return calendarStore.sourceById(event.calendarSourceId);
}
function calendarEventColor(event) {
return calendarEventSource(event)?.color ?? 'var(--app-color-on-tertiary)';
}
function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : '';
}
function formatCalendarDate(value) {
return value
? new Intl.DateTimeFormat(locale.value, { month: 'short', day: 'numeric', year: 'numeric' }).format(parseCalendarDate(value))
: '';
}
function formatContextDay(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) {
const date = new Date(value);
date.setHours(0, 0, 0, 0);
return date;
}
function startOfMonth(value) {
const date = startOfDay(value);
date.setDate(1);
return date;
}
function endOfMonth(value) {
const date = startOfMonth(value);
date.setMonth(date.getMonth() + 1);
date.setDate(0);
return date;
}
function addDays(value, amount) {
const date = startOfDay(value);
date.setDate(date.getDate() + amount);
return date;
}
function parseDateKey(value) {
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
}
function dateKey(value) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
return value.slice(0, 10);
}
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function parseCalendarDate(value) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
return parseDateKey(value.slice(0, 10));
}
return new Date(value);
}
watch(
() => [
isCreateMode.value,
route.params.id,
route.query.campaignId,
availableCampaigns.value.length,
availableChannels.value.length,
],
async () => {
await hydrateEditor();
},
{ immediate: true }
);
watch(
() => [
contentWorkspaceId.value,
calendarFetchRange.value.startDate,
calendarFetchRange.value.endDate,
],
async ([workspaceId, startDate, endDate]) => {
if (!workspaceId) {
return;
}
await Promise.all([
contentItemsStore.fetchContentItems(),
calendarStore.fetchSources(workspaceId),
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
workspaceStore.fetchMembers(workspaceId),
]);
},
{ immediate: true }
);
watch(
() => [
form.title,
form.campaignId,
form.dueDate,
form.body,
form.hashtags,
form.changeSummary,
form.placements,
],
() => {
persistDraft();
},
{ deep: true }
);
onBeforeUnmount(() => {
detailStore.reset();
});
</script>
<template>
<section class="editor-shell">
<v-btn variant="text" :ripple="false"
class="back-button"
type="button"
@click="navigateBackToContent"
>
<v-icon :icon="mdiArrowLeft" />
Back to content
</v-btn>
<div
v-if="!isCreateMode && detailStore.isLoading"
class="page-message"
>
Loading content item...
</div>
<div
v-else-if="!isCreateMode && detailStore.error"
class="page-message error"
>
{{ detailStore.error }}
</div>
<template v-else>
<div class="editor-header">
<div>
<div class="eyebrow">{{ isCreateMode ? 'New content' : 'Content item' }}</div>
<h1>{{ form.title || 'Untitled content' }}</h1>
<p>
{{ campaignNameById.get(form.campaignId) || 'Choose a campaign' }}
<template v-if="!isCreateMode && item">
· {{ item.status }}
</template>
</p>
</div>
<div class="header-actions">
<div
v-if="!isCreateMode && item"
class="status-badges"
>
<span class="meta-chip">{{ item.status }}</span>
<span class="meta-chip">{{ item.currentRevisionLabel }}</span>
</div>
<v-btn variant="text" :ripple="false"
class="primary-button"
:disabled="contentItemsStore.isCreating || detailStore.actions.revision"
@click="saveContent"
>
{{ isCreateMode
? (contentItemsStore.isCreating ? 'Creating...' : 'Create content')
: (detailStore.actions.revision ? 'Saving...' : 'Save revision') }}
</v-btn>
</div>
</div>
<div
v-if="saveError.message"
class="page-message error"
>
{{ saveError.message }}
</div>
<div class="editor-grid">
<section class="work-panel">
<ContentApprovalPanel
:approvals="detailStore.approvals"
:approval-mode="approvalMode"
:content-status="item?.status ?? 'Draft'"
:is-create-mode="isCreateMode"
:is-submitting-decision="detailStore.actions.decision"
@submit-decision="submitDecision"
/>
<main class="content-panel">
<div class="content-section">
<div class="section-title-row">
<strong>Content</strong>
<span>{{ placementSummary || 'No channels selected yet' }}</span>
</div>
<div class="form-grid">
<v-text-field
v-model="form.title"
label="Title"
variant="outlined"
hide-details
/>
<v-select
v-model="form.campaignId"
:items="availableCampaigns"
label="Campaign"
item-title="name"
item-value="id"
placeholder="Select a campaign"
variant="outlined"
hide-details
/>
<v-text-field
v-model="form.dueDate"
label="Due date"
type="date"
variant="outlined"
hide-details
/>
<div class="date-context field-wide">
<div class="date-context-days">
<v-btn variant="text" :ripple="false"
v-for="day in dateContextDays"
:key="day.key"
class="date-context-day"
:class="{
active: day.isSelected,
marked: day.events.length,
}"
type="button"
@click="selectContextDate(day.key)"
>
<span>{{ formatContextDay(day.date) }}</span>
<strong>{{ day.events.length }}</strong>
</v-btn>
</div>
<div
v-if="selectedDateCalendarEvents.length"
class="date-context-panel"
>
<v-btn variant="text" :ripple="false"
v-for="event in selectedDateCalendarEvents"
:key="event.id"
class="calendar-context-pill"
:style="{ '--calendar-color': calendarEventColor(event) }"
type="button"
@click="showCalendarEvent(event)"
>
{{ event.title }}
</v-btn>
</div>
<div
v-else-if="selectedDateKey"
class="date-context-empty"
>
{{ t('contentItems.dateContext.noEvents') }}
</div>
</div>
<v-text-field
v-model="form.changeSummary"
class="field-wide"
label="Change summary"
placeholder="What changed in this revision?"
variant="outlined"
hide-details
/>
<v-textarea
v-model="form.body"
class="field-wide"
label="Shared brief / base caption"
rows="4"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>Shared hashtags</span>
<div class="hashtags-input-shell">
<div
v-if="sharedPreviewTags.length"
class="hashtags-editor"
>
<v-chip
v-for="tag in sharedPreviewTags"
:key="`form-${tag}`"
class="hashtag-chip"
size="small"
rounded="pill"
variant="tonal"
color="deep-orange"
closable
@click:close="removeHashtag(tag)"
>
{{ tag.startsWith('#') ? tag : `#${tag}` }}
</v-chip>
</div>
<input
v-model="hashtagInput"
class="hashtags-inline-input"
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 preview-editor-section">
<div class="section-title-row">
<strong>Target channels</strong>
<span>{{ selectedPlacements.length }} selected</span>
</div>
<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"
>
<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>
<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="preview-remove"
type="button"
:aria-label="`Remove ${previewProfileName(activePlacement)}`"
@click="removePlacement(activePlacement.id)"
>
<v-icon :icon="mdiClose" />
</v-btn>
</div>
<div
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-if="sharedPreviewTags.length"
class="preview-tags"
>
<span
v-for="tag in sharedPreviewTags"
:key="`x-${tag}`"
>
{{ 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="feed-preview"
>
<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>
</article>
<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>
<aside class="panel side-panel">
<div class="panel-heading">
<strong>Production</strong>
<span v-if="!isCreateMode">{{ item?.currentRevisionLabel ?? 'Draft' }}</span>
</div>
<div
v-if="isCreateMode"
class="empty-note"
>
Save the content first to start comments, revisions, assets, and activity.
</div>
<template v-else>
<div class="tab-strip">
<v-btn variant="text" :ripple="false"
v-for="tab in productionTabs"
:key="tab.key"
class="tab-button"
:class="{ active: activeProductionTab === tab.key }"
type="button"
@click="activeProductionTab = tab.key"
>
{{ tab.label }}
<span>{{ tab.count }}</span>
</v-btn>
</div>
<template v-if="activeProductionTab === 'comments'">
<ContentCommentComposer
:members="workspaceMembers"
:is-posting="detailStore.actions.comment"
@submit-comment="submitComment"
/>
<ContentCommentFeed
:comments="detailStore.comments"
:members="workspaceMembers"
:is-posting="detailStore.actions.comment"
@submit-comment="submitComment"
/>
</template>
<template v-else-if="activeProductionTab === 'revisions'">
<div class="timeline-list">
<article
v-for="revision in detailStore.revisions"
:key="revision.id"
class="timeline-row"
>
<div class="timeline-row-header">
<strong>{{ revision.revisionLabel }}</strong>
<small>{{ formatDateTime(revision.createdAt) }}</small>
</div>
<span>{{ revision.title }}</span>
<p v-if="revision.changeSummary">{{ revision.changeSummary }}</p>
<small>{{ revision.publicationTargets }}</small>
</article>
<div
v-if="!detailStore.revisions.length"
class="empty-note"
>
No revisions have been saved yet.
</div>
</div>
</template>
<template v-else-if="activeProductionTab === 'assets'">
<div class="panel-stack asset-form">
<v-select
v-model="assetForm.assetType"
:items="['Image', 'Video', 'Document', 'Other']"
label="Type"
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetForm.displayName"
label="Name"
placeholder="Final reel, cover image..."
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetForm.googleDriveLink"
class="field-wide"
label="Google Drive link"
type="url"
placeholder="https://drive.google.com/..."
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetForm.googleDriveFileId"
label="File id"
placeholder="Optional if link includes it"
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetForm.previewUrl"
label="Preview URL"
type="url"
variant="outlined"
hide-details
/>
<v-btn variant="text" :ripple="false"
class="primary-button field-wide"
:disabled="detailStore.actions.asset"
@click="linkGoogleDriveAsset"
>
{{ detailStore.actions.asset ? 'Linking...' : 'Link asset' }}
</v-btn>
</div>
<div class="timeline-list">
<article
v-for="asset in detailStore.assets"
:key="asset.id"
class="asset-card"
>
<div class="timeline-row-header">
<div>
<strong>{{ asset.displayName }}</strong>
<span>{{ asset.assetType }} · {{ asset.sourceType }}</span>
</div>
<span class="revision-pill">v{{ asset.currentRevisionNumber }}</span>
</div>
<a
class="asset-link"
:href="asset.googleDriveLink"
target="_blank"
rel="noreferrer"
>
Open source
</a>
<div class="asset-revisions">
<div
v-for="revision in asset.revisions"
:key="revision.id"
class="asset-revision-row"
>
<span>v{{ revision.revisionNumber }}</span>
<small>{{ formatDateTime(revision.createdAt) }}</small>
<p v-if="revision.notes">{{ revision.notes }}</p>
</div>
</div>
<div class="panel-stack compact-form">
<v-text-field
v-model="assetRevisionForm(asset.id).sourceReference"
class="field-wide"
label="New revision reference"
type="url"
placeholder="Updated Drive link or production reference"
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetRevisionForm(asset.id).notes"
class="field-wide"
label="Notes"
placeholder="What changed?"
variant="outlined"
hide-details
/>
<v-btn variant="text" :ripple="false"
class="secondary-button"
:disabled="detailStore.actions.assetRevision"
@click="addAssetRevision(asset)"
>
{{ detailStore.actions.assetRevision ? 'Adding...' : 'Add revision' }}
</v-btn>
</div>
</article>
<div
v-if="!detailStore.assets.length"
class="empty-note"
>
No production assets have been linked yet.
</div>
</div>
</template>
<template v-else>
<div class="timeline-list">
<article
v-for="entry in detailStore.activity"
:key="entry.id"
class="timeline-row"
>
<div class="timeline-row-header">
<strong>{{ entry.eventType }}</strong>
<small>{{ formatDateTime(entry.createdAt) }}</small>
</div>
<span>{{ entry.summary }}</span>
</article>
<div
v-if="!detailStore.activity.length"
class="empty-note"
>
No activity has been recorded yet.
</div>
</div>
</template>
</template>
</aside>
</div>
<div
v-if="activeCalendarEvent"
class="calendar-event-popover"
role="dialog"
aria-modal="true"
>
<div class="calendar-event-card">
<div class="calendar-event-header">
<div>
<span>{{ activeCalendarEvent.source?.displayTitle ?? t('contentItems.calendar.importedEvent') }}</span>
<strong>{{ activeCalendarEvent.title }}</strong>
</div>
<v-btn variant="text" :ripple="false"
class="link-button"
type="button"
@click="closeCalendarEvent"
>
{{ t('close') }}
</v-btn>
</div>
<p>{{ formatCalendarDate(activeCalendarEvent.startDate) }}</p>
<p v-if="activeCalendarEvent.description">{{ activeCalendarEvent.description }}</p>
<p v-if="activeCalendarEvent.location">{{ activeCalendarEvent.location }}</p>
<div class="calendar-event-actions">
<v-btn variant="text" :ripple="false"
class="secondary-button"
type="button"
@click="navigateToCalendarDay(activeCalendarEvent)"
>
{{ t('contentItems.dateContext.viewDay') }}
</v-btn>
</div>
</div>
</div>
</template>
</section>
</template>
<style scoped>
@reference "@/assets/main.css";
.editor-shell {
@apply mx-auto flex w-full max-w-[110rem] flex-col gap-5 px-5 py-8 md:px-8;
}
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm;
background: rgba(255, 255, 255, 0.9);
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.page-message.error {
color: var(--app-danger-muted);
}
.editor-header {
@apply flex flex-col gap-4 rounded-[1.75rem] border p-6 lg:flex-row lg:items-start lg:justify-between;
background: rgba(255, 255, 255, 0.92);
border-color: var(--app-border-subtle);
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: var(--app-color-on-tertiary);
}
.editor-header h1 {
@apply mt-2 text-4xl font-black;
color: var(--app-color-on-surface);
}
.editor-header p,
.panel-heading span,
.timeline-row span,
.timeline-row small,
.empty-note,
.placement-header span,
.section-title-row span {
@apply text-sm leading-6;
color: var(--app-text-muted);
}
.header-actions,
.status-badges {
@apply flex flex-wrap items-center gap-3;
}
.meta-chip {
@apply rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.back-button,
.primary-button,
.secondary-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
}
.back-button {
@apply w-fit border;
background: rgba(255, 255, 255, 0.88);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.back-button:hover {
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.primary-button {
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.secondary-button {
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.editor-grid {
@apply grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem];
}
.work-panel {
@apply grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] items-start gap-4 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: var(--app-border-subtle);
}
.panel {
@apply flex min-h-0 flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: var(--app-border-subtle);
}
.side-panel {
@apply xl:sticky xl:top-28 xl:max-h-[calc(100vh-9rem)] xl:overflow-y-auto;
}
.content-panel {
@apply flex min-h-0 flex-col gap-6;
}
.panel-heading,
.section-title-row,
.placement-header {
@apply flex items-start justify-between gap-3;
}
.panel-heading strong,
.section-title-row strong,
.placement-header strong,
.timeline-row strong {
color: var(--app-color-on-surface);
}
.panel-stack,
.content-section,
.placement-list,
.media-section,
.media-list,
.card-stack,
.timeline-list {
@apply flex flex-col gap-4;
}
.form-grid {
@apply grid gap-4 md:grid-cols-2;
}
.compact-grid {
@apply md:grid-cols-2;
}
.field {
@apply flex flex-col gap-2;
}
.field-wide {
@apply md:col-span-2;
}
.field span {
@apply text-sm font-semibold;
color: var(--app-color-on-surface);
}
.field input,
.field select,
.field textarea {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: var(--app-color-surface);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
outline: none;
}
.field textarea {
min-height: 7rem;
resize: vertical;
}
.date-context {
@apply flex flex-col gap-3 rounded-[1rem] border p-3;
background: rgba(15, 118, 110, 0.04);
border-color: rgba(15, 118, 110, 0.14);
}
.date-context-days {
@apply grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-7;
}
.date-context-day {
@apply flex min-h-14 flex-col items-start justify-between rounded-[0.875rem] border px-3 py-2 text-left transition;
background: rgba(255, 253, 248, 0.9);
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.date-context-day span {
@apply text-xs font-bold uppercase tracking-[0.12em];
}
.date-context-day strong {
@apply h-5 min-w-5 rounded-full px-1.5 text-center text-xs leading-5;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.date-context-day.marked {
border-color: rgba(15, 118, 110, 0.32);
color: var(--app-color-on-tertiary);
}
.date-context-day.marked strong,
.date-context-day.active strong {
background: var(--app-color-on-tertiary);
color: var(--app-color-on-primary);
}
.date-context-day.active {
background: var(--app-color-on-surface);
border-color: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.date-context-panel {
@apply flex flex-wrap gap-2;
}
.calendar-context-pill {
@apply rounded-full border px-3 py-1.5 text-xs font-bold transition;
background: color-mix(in srgb, var(--calendar-color) 12%, white);
border-color: color-mix(in srgb, var(--calendar-color) 34%, white);
color: var(--app-color-on-surface);
}
.calendar-context-pill:hover {
background: var(--calendar-color);
color: var(--app-color-on-primary);
}
.date-context-empty {
@apply text-sm leading-6;
color: var(--app-text-muted);
}
.hashtags-input-shell {
@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);
}
.hashtags-editor {
@apply flex flex-wrap gap-2;
}
.hashtags-inline-input {
@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);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.channel-suggestions {
@apply flex flex-wrap gap-2;
}
.placement-card,
.media-card {
@apply rounded-[1.25rem] border p-4;
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.identity-row {
@apply flex items-center gap-3;
}
.identity-row.align-start {
@apply items-start;
}
.timeline-row {
@apply flex flex-col gap-3 rounded-[1rem] border p-4;
background: #f8fafc;
border-color: var(--app-border-subtle);
}
.timeline-row-header {
@apply flex items-start justify-between gap-3;
}
.timeline-row p,
.asset-revision-row p {
@apply text-sm leading-6;
color: var(--app-color-on-surface);
}
.timeline-actions {
@apply flex items-center justify-between gap-3;
}
.timeline-list.compact .timeline-row {
@apply p-3;
}
.tab-strip {
@apply grid grid-cols-2 gap-2;
}
.tab-button {
@apply flex items-center justify-between gap-2 rounded-[1rem] border px-3 py-2 text-sm font-bold transition;
background: var(--app-control-subtle);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.tab-button.active {
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.tab-button span {
@apply rounded-full px-2 py-0.5 text-xs;
background: rgba(255, 255, 255, 0.22);
}
.asset-form {
@apply grid gap-4 sm:grid-cols-2;
}
.asset-card {
@apply flex flex-col gap-4 rounded-[1rem] border p-4;
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.asset-card .timeline-row-header span,
.asset-revision-row small {
@apply text-sm leading-6;
color: var(--app-text-muted);
}
.asset-link {
@apply w-fit text-sm font-semibold;
color: var(--app-color-on-tertiary);
}
.revision-pill {
@apply rounded-full px-3 py-1 text-xs font-bold;
background: rgba(15, 118, 110, 0.12);
color: var(--app-color-on-tertiary);
}
.asset-revisions {
@apply flex flex-col gap-2;
}
.asset-revision-row {
@apply rounded-[0.875rem] border px-3 py-2;
background: rgba(255, 255, 255, 0.7);
border-color: var(--app-border-subtle);
}
.asset-revision-row span {
@apply mr-2 text-sm font-bold;
color: var(--app-color-on-surface);
}
.compact-form {
@apply gap-3;
}
.link-button {
@apply text-sm font-semibold;
color: var(--app-color-on-tertiary);
}
.calendar-event-popover {
@apply fixed inset-0 z-50 flex items-center justify-center bg-slate-950/35 p-4;
}
.calendar-event-card {
@apply flex w-full max-w-md flex-col gap-4 rounded-[1.25rem] border p-5 shadow-2xl;
background: var(--app-color-surface);
border-color: var(--app-border-subtle);
}
.calendar-event-header {
@apply flex items-start justify-between gap-4;
}
.calendar-event-header div {
@apply flex flex-col gap-1;
}
.calendar-event-header span,
.calendar-event-card p {
@apply text-sm leading-6;
color: var(--app-text-muted);
}
.calendar-event-header strong {
@apply text-xl font-black;
color: var(--app-color-on-surface);
}
.calendar-event-actions {
@apply flex justify-end;
}
@media (max-width: 1279px) {
.side-panel {
position: static;
max-height: none;
overflow: visible;
}
}
</style>