feat: redesign content editor previews
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-09 11:14:05 -04:00
parent 030bf1b4ef
commit 581d286a1c
2 changed files with 727 additions and 172 deletions

View 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.

View File

@@ -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,200 +1130,205 @@
{{ 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 class="preview-editor">
<aside class="target-rail">
<template v-if="groupedChannels.length">
<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])"
class="target-group"
>
{{ 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
</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>
<v-btn variant="text" :ripple="false"
class="secondary-button"
type="button"
@click="addMedia(placement)"
>
Add media
</v-btn>
</div>
<div
v-if="placement.mediaItems.length"
class="media-list"
>
<div
v-for="media in placement.mediaItems"
:key="media.id"
class="media-card"
>
<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>
<span>{{ group.network }}</span>
<v-btn variant="text" :ripple="false"
class="link-button"
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="removeMedia(placement, media.id)"
@click="toggleChannel(channel)"
>
Remove media
<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>
</div>
</template>
<div
v-else
class="empty-note"
>
Add media per channel, for example a video for YouTube or an image for Instagram.
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>
<div
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>
<span>The shared content will preview here for every selected target.</span>
</div>
</div>
</div>
</div>
</main>
@@ -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);