feat: implement post editor sidebar with tabbed navigation and add support for LinkedIn and Pinterest platform settings

This commit is contained in:
Paulo Castellano 2026-05-02 14:50:36 -03:00
parent 17ca1f7b0e
commit 02f45e31e7
18 changed files with 918 additions and 438 deletions

View file

@ -113,6 +113,26 @@
'story' => 'Story',
],
],
'linkedin' => [
'settings' => 'LinkedIn Settings',
'settings_page' => 'LinkedIn Page Settings',
'posting_to' => 'Posting to',
'variant_label' => 'Post type',
'variant' => [
'post' => 'Post',
'carousel' => 'Carousel',
],
],
'pinterest' => [
'settings' => 'Pinterest Settings',
'posting_to' => 'Posting to',
'variant_label' => 'Pin type',
'variant' => [
'pin' => 'Pin',
'video_pin' => 'Video Pin',
'carousel' => 'Carousel',
],
],
'warnings' => [
'no_variant' => 'Pick a post type to continue.',
'requires_media' => 'This post type requires at least one image or video.',
@ -190,6 +210,10 @@
'caption_placeholder' => 'Write your caption...',
'compose_title' => 'Create a post',
'compose_subtitle' => 'Compose your message and add media',
'preview_empty' => [
'title' => 'No platform selected',
'description' => 'Select a platform to publish to see the preview.',
],
'drag_drop' => 'Drag & drop or click to upload',
'publish_to' => 'Publish to',
'organize' => 'Organize',

View file

@ -113,6 +113,26 @@
'story' => 'Historia',
],
],
'linkedin' => [
'settings' => 'Configuración de LinkedIn',
'settings_page' => 'Configuración de la Página de LinkedIn',
'posting_to' => 'Publicando en',
'variant_label' => 'Tipo de publicación',
'variant' => [
'post' => 'Publicación',
'carousel' => 'Carrusel',
],
],
'pinterest' => [
'settings' => 'Configuración de Pinterest',
'posting_to' => 'Publicando en',
'variant_label' => 'Tipo de pin',
'variant' => [
'pin' => 'Pin',
'video_pin' => 'Video Pin',
'carousel' => 'Carrusel',
],
],
'warnings' => [
'no_variant' => 'Elige un tipo de publicación para continuar.',
'requires_media' => 'Este tipo requiere al menos una imagen o video.',
@ -191,6 +211,10 @@
'caption_placeholder' => 'Escribe tu descripción...',
'compose_title' => 'Crear un post',
'compose_subtitle' => 'Compón tu mensaje y agrega media',
'preview_empty' => [
'title' => 'Ninguna plataforma seleccionada',
'description' => 'Selecciona una plataforma para publicar y ver la vista previa.',
],
'drag_drop' => 'Arrastra y suelta o haz clic para subir',
'publish_to' => 'Publicar en',
'organize' => 'Organizar',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -113,6 +113,26 @@
'story' => 'Story',
],
],
'linkedin' => [
'settings' => 'Configurações do LinkedIn',
'settings_page' => 'Configurações da Página do LinkedIn',
'posting_to' => 'Publicando em',
'variant_label' => 'Tipo de publicação',
'variant' => [
'post' => 'Post',
'carousel' => 'Carrossel',
],
],
'pinterest' => [
'settings' => 'Configurações do Pinterest',
'posting_to' => 'Publicando em',
'variant_label' => 'Tipo de pin',
'variant' => [
'pin' => 'Pin',
'video_pin' => 'Video Pin',
'carousel' => 'Carrossel',
],
],
'warnings' => [
'no_variant' => 'Escolha um tipo de publicação para continuar.',
'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.',
@ -191,6 +211,10 @@
'caption_placeholder' => 'Escreva sua legenda...',
'compose_title' => 'Crie um post',
'compose_subtitle' => 'Componha sua mensagem e adicione mídia',
'preview_empty' => [
'title' => 'Nenhuma plataforma selecionada',
'description' => 'Selecione uma plataforma para publicar e ver o preview.',
],
'drag_drop' => 'Arraste e solte ou clique para enviar',
'publish_to' => 'Publicar em',
'organize' => 'Organizar',

View file

@ -9,7 +9,7 @@ withDefaults(defineProps<Props>(), {
</script>
<template>
<div class="relative mx-auto select-none">
<div class="relative mx-auto select-none origin-top scale-[0.9]">
<!-- Phone Frame - iPhone 14 Pro style -->
<div class="relative bg-[#1a1a1a] rounded-[44px] p-[10px] shadow-2xl ring-1 ring-white/10">
<!-- Inner frame -->

View file

@ -58,7 +58,7 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandFacebook class="h-4 w-4" />
<IconBrandFacebook class="size-5" />
<span>{{ $t('posts.form.facebook.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
</span>

View file

@ -71,7 +71,7 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandInstagram class="h-4 w-4" />
<IconBrandInstagram class="size-5" />
<span>{{ $t('posts.form.instagram.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
</span>

View file

@ -0,0 +1,117 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandLinkedin, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface Props {
socialAccount: SocialAccount | null;
platform: string;
contentType: string;
media: MediaItem[];
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
});
const emit = defineEmits<{
'update:contentType': [value: string];
}>();
const open = ref(false);
const isPage = computed(() => props.platform === 'linkedin-page');
const variants = computed(() =>
isPage.value
? [
{ value: ContentType.LinkedInPagePost, labelKey: 'posts.form.linkedin.variant.post' },
{ value: ContentType.LinkedInPageCarousel, labelKey: 'posts.form.linkedin.variant.carousel' },
]
: [
{ value: ContentType.LinkedInPost, labelKey: 'posts.form.linkedin.variant.post' },
{ value: ContentType.LinkedInCarousel, labelKey: 'posts.form.linkedin.variant.carousel' },
],
);
const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);
};
const warning = computed(() => getMediaValidationWarning(props.contentType, props.media));
</script>
<template>
<div class="rounded-lg border">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandLinkedin class="size-5" />
<span>{{ isPage ? $t('posts.form.linkedin.settings_page') : $t('posts.form.linkedin.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.linkedin.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.linkedin.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
{{ $t(variant.labelKey) }}
</button>
</div>
</div>
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>
</div>
</template>

View file

@ -0,0 +1,108 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandPinterest, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface Props {
socialAccount: SocialAccount | null;
contentType: string;
media: MediaItem[];
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
});
const emit = defineEmits<{
'update:contentType': [value: string];
}>();
const open = ref(false);
const variants = [
{ value: ContentType.PinterestPin, labelKey: 'posts.form.pinterest.variant.pin' },
{ value: ContentType.PinterestVideoPin, labelKey: 'posts.form.pinterest.variant.video_pin' },
{ value: ContentType.PinterestCarousel, labelKey: 'posts.form.pinterest.variant.carousel' },
];
const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);
};
const warning = computed(() => getMediaValidationWarning(props.contentType, props.media));
</script>
<template>
<div class="rounded-lg border">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandPinterest class="size-5" />
<span>{{ $t('posts.form.pinterest.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.pinterest.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.pinterest.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
{{ $t(variant.labelKey) }}
</button>
</div>
</div>
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>
</div>
</template>

View file

@ -0,0 +1,300 @@
<script setup lang="ts">
import {
IconHash,
IconLibraryPhoto,
IconLoader2,
IconMessage2,
IconMoodSmile,
IconSparkles,
IconTrash,
} from '@tabler/icons-vue';
import { ref } from 'vue';
import EmojiPicker from '@/components/posts/EmojiPicker.vue';
import HashtagsModal from '@/components/posts/HashtagsModal.vue';
import MediaPickerDialog from '@/components/posts/MediaPickerDialog.vue';
import { Button } from '@/components/ui/button';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { readFileMetadata } from '@/composables/useMedia';
import { store as storeAsset } from '@/routes/app/assets';
interface MediaItem {
id: string;
path: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
size?: number;
meta?: { width?: number; height?: number; duration?: number };
}
interface Hashtag {
id: string;
name: string;
hashtags: string;
}
const props = defineProps<{
isReadOnly: boolean;
hashtags: Hashtag[];
}>();
const content = defineModel<string>('content', { required: true });
const media = defineModel<MediaItem[]>('media', { required: true });
const emit = defineEmits<{
(e: 'focus-assistant'): void;
}>();
const isDragging = ref(false);
const uploading = ref(false);
const emojiOpen = ref(false);
const mediaPickerDialog = ref<InstanceType<typeof MediaPickerDialog> | null>(null);
const hashtagsModal = ref<InstanceType<typeof HashtagsModal> | null>(null);
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const handleDrop = (event: DragEvent) => {
isDragging.value = false;
if (event.dataTransfer?.files) {
uploadFiles(Array.from(event.dataTransfer.files));
}
};
const uploadFiles = async (files: File[]) => {
uploading.value = true;
for (const file of files) {
const clientMeta = await readFileMetadata(file);
const formData = new FormData();
formData.append('media', file);
if (clientMeta.width) formData.append('meta[width]', String(clientMeta.width));
if (clientMeta.height) formData.append('meta[height]', String(clientMeta.height));
if (clientMeta.duration) formData.append('meta[duration]', String(clientMeta.duration));
try {
const response = await fetch(storeAsset.url(), {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
if (!response.ok) continue;
const data = await response.json();
media.value = [
...media.value,
{
id: data.id,
path: data.path,
url: data.url,
type: data.type,
mime_type: data.mime_type,
original_filename: data.original_filename,
size: data.size,
meta: data.meta,
},
];
} catch {
// ignore
}
}
uploading.value = false;
};
const removeMedia = (mediaId: string) => {
media.value = media.value.filter((m) => m.id !== mediaId);
};
const addMediaFromGallery = (picked: MediaItem[]) => {
const existingIds = new Set(media.value.map((m) => m.id));
const additions = picked.filter((m) => !existingIds.has(m.id));
if (additions.length === 0) return;
media.value = [...media.value, ...additions];
};
const appendHashtags = (hashtag: Hashtag) => {
if (props.isReadOnly) return;
const separator = content.value.trim() ? '\n\n' : '';
content.value += separator + hashtag.hashtags;
};
const appendEmoji = (emoji: string) => {
if (props.isReadOnly) return;
content.value += emoji;
emojiOpen.value = false;
};
</script>
<template>
<div class="max-w-2xl mx-auto py-8 px-6">
<div
class="rounded-lg border bg-card shadow-sm transition-shadow focus-within:shadow-md"
:class="isDragging ? 'border-primary ring-2 ring-primary/20' : ''"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<div class="flex items-start gap-3 border-b px-5 py-4">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<IconMessage2 class="h-5 w-5" />
</div>
<div class="min-w-0">
<h2 class="text-sm font-semibold text-foreground">{{ $t('posts.edit.compose_title') }}</h2>
<p class="text-xs text-muted-foreground">{{ $t('posts.edit.compose_subtitle') }}</p>
</div>
</div>
<div class="relative">
<Textarea
v-model="content"
:placeholder="$t('posts.edit.caption_placeholder')"
:disabled="isReadOnly"
class="min-h-[240px] resize-none rounded-none border-0 bg-transparent px-5 py-4 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<span class="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground/70">
{{ content.length }}
</span>
</div>
<div v-if="!isReadOnly" class="flex items-center gap-1 border-t px-3 py-2">
<Popover v-model:open="emojiOpen">
<PopoverAnchor as-child>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="emojiOpen = !emojiOpen"
>
<IconMoodSmile class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.emoji_picker.search') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</PopoverAnchor>
<PopoverContent class="w-auto p-0" align="start">
<EmojiPicker @select="appendEmoji" />
</PopoverContent>
</Popover>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="hashtagsModal?.open()"
>
<IconHash class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.hashtags') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="emit('focus-assistant')"
>
<IconSparkles class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.tabs.writing_assistant') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="mediaPickerDialog?.open()"
>
<IconLibraryPhoto class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.add_media') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<div class="flex-1" />
<span v-if="uploading" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconLoader2 class="h-3.5 w-3.5 animate-spin" />
</span>
</div>
<div v-if="media.length > 0" class="border-t px-5 py-4">
<div class="grid grid-cols-4 gap-2">
<div
v-for="item in media"
:key="item.id"
class="group relative aspect-square overflow-hidden rounded-lg border bg-muted"
>
<video
v-if="item.type === 'video' || item.mime_type?.startsWith('video/')"
:src="item.url"
class="h-full w-full object-cover"
muted
/>
<img
v-else
:src="item.url"
:alt="item.original_filename"
class="h-full w-full object-cover"
loading="lazy"
/>
<button
v-if="!isReadOnly"
type="button"
class="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-black/80 group-hover:opacity-100"
@click="removeMedia(item.id)"
>
<IconTrash class="h-3 w-3" />
</button>
</div>
<button
v-if="!isReadOnly"
type="button"
class="flex aspect-square items-center justify-center rounded-lg border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary/50 hover:text-primary"
@click="mediaPickerDialog?.open()"
>
<IconLibraryPhoto class="h-6 w-6" />
</button>
</div>
</div>
<div v-if="isDragging && !isReadOnly" class="border-t bg-primary/5 px-5 py-6 text-center text-sm text-primary">
{{ $t('posts.edit.drag_drop') }}
</div>
</div>
<HashtagsModal ref="hashtagsModal" :hashtags="hashtags" @select="appendHashtags" />
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
</div>
</template>

View file

@ -0,0 +1,162 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import CommentsTab from '@/components/posts/editor/CommentsTab.vue';
import PreviewTab from '@/components/posts/editor/PreviewTab.vue';
import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue';
import WritingAssistantTab from '@/components/posts/editor/WritingAssistantTab.vue';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface MediaItem {
id: string;
path: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
size?: number;
meta?: { width?: number; height?: number; duration?: number };
}
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface PostPlatform {
id: string;
social_account_id: string | null;
enabled: boolean;
platform: string;
platform_name: string | null;
platform_username: string | null;
platform_avatar: string | null;
content_type: string | null;
status: string;
platform_url: string | null;
error_message: string | null;
published_at: string | null;
social_account: SocialAccount | null;
meta?: Record<string, any>;
}
interface Post {
id: string;
post_platforms: PostPlatform[];
}
interface TikTokCreatorInfo {
creator_nickname: string | null;
creator_username: string | null;
creator_avatar_url: string | null;
privacy_level_options: string[];
comment_disabled: boolean;
duet_disabled: boolean;
stitch_disabled: boolean;
max_video_post_duration_sec: number | null;
}
const props = defineProps<{
post: Post;
workspaceId: string;
content: string;
media: MediaItem[];
selectedPlatformIds: string[];
platformMeta: Record<string, Record<string, any>>;
platformContentTypes: Record<string, string>;
platformIssues: Record<string, string>;
platformConfigs: Record<string, any>;
labels: { id: string; name: string; color: string }[];
selectedLabelIds: string[];
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
isReadOnly: boolean;
authUserId: string;
initialHighlightCommentId: string | null;
}>();
const activeTab = defineModel<string>('activeTab', { required: true });
const emit = defineEmits<{
(e: 'toggle-platform', platformId: string): void;
(e: 'toggle-label', labelId: string): void;
(e: 'update:platformMeta', platformId: string, meta: Record<string, any>): void;
(e: 'update:platformContentType', platformId: string, contentType: string): void;
(e: 'add-media-from-assistant', payload: {
messageId: string;
messageContent: string;
media: { id: string; path: string; url: string; type: string; mime_type: string };
}): void;
}>();
const commentsTabRef = ref<InstanceType<typeof CommentsTab> | null>(null);
const previewablePlatforms = computed(() =>
props.post.post_platforms.filter((pp) => props.selectedPlatformIds.includes(pp.id)),
);
defineExpose({
addCommentFromBroadcast: (comment: any) => commentsTabRef.value?.addCommentFromBroadcast(comment),
registerMentionedUsers: (users: any) => commentsTabRef.value?.registerMentionedUsers(users),
});
</script>
<template>
<Tabs v-model="activeTab" class="h-full flex flex-col">
<TabsList class="mx-4 mt-4 w-auto shrink-0">
<TabsTrigger value="preview">{{ $t('posts.edit.tabs.preview') }}</TabsTrigger>
<TabsTrigger value="schedule">{{ $t('posts.edit.tabs.schedule') }}</TabsTrigger>
<TabsTrigger value="comments">{{ $t('posts.edit.tabs.comments') }}</TabsTrigger>
<TabsTrigger value="assistant">{{ $t('posts.edit.tabs.writing_assistant') }}</TabsTrigger>
</TabsList>
<TabsContent value="preview" class="flex-1 overflow-y-auto">
<PreviewTab
:platforms="previewablePlatforms"
:content="content"
:media="media"
:platform-content-types="platformContentTypes"
:platform-meta="platformMeta"
/>
</TabsContent>
<TabsContent value="schedule" class="flex-1 overflow-y-auto p-4">
<ScheduleTab
:post-platforms="post.post_platforms"
:selected-platform-ids="selectedPlatformIds"
:labels="labels"
:selected-label-ids="selectedLabelIds"
:is-read-only="isReadOnly"
:platform-configs="platformConfigs"
:platform-meta="platformMeta"
:platform-content-types="platformContentTypes"
:platform-issues="platformIssues"
:tiktok-creator-infos="tiktokCreatorInfos"
:media="media"
@toggle-platform="(id) => emit('toggle-platform', id)"
@toggle-label="(id) => emit('toggle-label', id)"
@update:platform-meta="(id, meta) => emit('update:platformMeta', id, meta)"
@update:platform-content-type="(id, contentType) => emit('update:platformContentType', id, contentType)"
/>
</TabsContent>
<TabsContent value="comments" class="flex-1 overflow-hidden">
<CommentsTab
ref="commentsTabRef"
:post-id="post.id"
:current-user-id="authUserId"
:highlight-comment-id="initialHighlightCommentId"
/>
</TabsContent>
<TabsContent value="assistant" class="flex-1 overflow-hidden">
<WritingAssistantTab
:post-id="post.id"
:workspace-id="workspaceId"
@add-media="(payload) => emit('add-media-from-assistant', payload)"
/>
</TabsContent>
</Tabs>
</template>

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import { IconDeviceMobile } from '@tabler/icons-vue';
import { computed, ref, watch } from 'vue';
import PhoneMockup from '@/components/PhoneMockup.vue';
import { PlatformPreview } from '@/components/posts/previews';
import { Avatar } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getContentTypeOptions, getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
interface MediaItem {
id: string;
@ -41,10 +42,6 @@ const props = defineProps<{
platformMeta?: Record<string, Record<string, any>>;
}>();
const emit = defineEmits<{
'update:platformContentType': [platformId: string, contentType: string];
}>();
const getPlatformAvatar = (pp: PostPlatform): string | null => pp.social_account?.avatar_url ?? pp.platform_avatar ?? null;
const getPlatformDisplayName = (pp: PostPlatform): string => pp.social_account?.display_name ?? pp.platform_name ?? pp.platform;
@ -64,17 +61,6 @@ const activeContentType = computed(() => {
if (!activePlatform.value) return null;
return props.platformContentTypes[activePlatform.value.id] ?? activePlatform.value.content_type;
});
const activeVariants = computed(() => {
if (!activePlatform.value) return [];
const options = getContentTypeOptions(activePlatform.value.platform);
return options.length > 1 ? options : [];
});
const pickVariant = (value: string) => {
if (!activePlatform.value) return;
emit('update:platformContentType', activePlatform.value.id, value);
};
</script>
<template>
@ -114,24 +100,7 @@ const pickVariant = (value: string) => {
</div>
</div>
<div v-if="activeVariants.length > 0" class="border-b px-4 py-3">
<div class="flex flex-wrap justify-center gap-2">
<button
v-for="variant in activeVariants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
:class="activeContentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
@click="pickVariant(variant.value)"
>
{{ $t(variant.labelKey) }}
</button>
</div>
</div>
<div class="flex flex-1 justify-center bg-muted/30 px-4 py-8">
<div class="flex flex-1 items-start justify-center bg-muted/30 px-4 pb-8 pt-6">
<PhoneMockup v-if="activePlatform">
<PlatformPreview
:platform="activePlatform.platform"
@ -142,6 +111,11 @@ const pickVariant = (value: string) => {
:meta="platformMeta?.[activePlatform.id] ?? {}"
/>
</PhoneMockup>
<div v-else class="flex flex-col items-center gap-2 text-center text-muted-foreground">
<IconDeviceMobile class="size-10 opacity-40" />
<p class="text-sm font-medium">{{ $t('posts.edit.preview_empty.title') }}</p>
<p class="text-xs">{{ $t('posts.edit.preview_empty.description') }}</p>
</div>
</div>
</div>
</template>

View file

@ -4,6 +4,8 @@ import { computed } from 'vue';
import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue';
import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue';
import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue';
import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
@ -111,6 +113,19 @@ const selectedFacebookPlatforms = computed(() =>
),
);
const selectedLinkedInPlatforms = computed(() =>
props.postPlatforms.filter(
(pp) => ['linkedin', 'linkedin-page'].includes(pp.platform)
&& props.selectedPlatformIds.includes(pp.id),
),
);
const selectedPinterestPlatforms = computed(() =>
props.postPlatforms.filter(
(pp) => pp.platform === 'pinterest' && props.selectedPlatformIds.includes(pp.id),
),
);
const getPublishConfig = (pp: PostPlatform): Record<string, any> | null =>
pp.social_account_id ? props.platformConfigs[pp.social_account_id]?.publishConfig ?? null : null;
@ -264,6 +279,31 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
/>
</div>
<div v-if="selectedLinkedInPlatforms.length > 0" class="space-y-4">
<LinkedInSettings
v-for="pp in selectedLinkedInPlatforms"
:key="pp.id"
:social-account="pp.social_account"
:platform="pp.platform"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
/>
</div>
<div v-if="selectedPinterestPlatforms.length > 0" class="space-y-4">
<PinterestSettings
v-for="pp in selectedPinterestPlatforms"
:key="pp.id"
:social-account="pp.social_account"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
/>
</div>
<div>
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ $t('posts.edit.labels') }}

View file

@ -185,7 +185,7 @@ watch(
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandTiktok class="h-4 w-4" />
<IconBrandTiktok class="size-5" />
<span>{{ $t('posts.form.tiktok.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
</span>

View file

@ -117,7 +117,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
</div>
<!-- Post Media - Aspect ratio matches user's chosen crop -->
<div class="relative w-full bg-black" :style="feedAspectStyle">
<div class="relative w-full shrink-0 bg-black" :style="feedAspectStyle">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"

View file

@ -4,38 +4,24 @@ import { useEcho } from '@laravel/echo-vue';
import {
IconCalendar,
IconCircleCheck,
IconHash,
IconLibraryPhoto,
IconLoader2,
IconMessage2,
IconMoodSmile,
IconSparkles,
IconTrash,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref, watch } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import CommentsTab from '@/components/posts/editor/CommentsTab.vue';
import PreviewTab from '@/components/posts/editor/PreviewTab.vue';
import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue';
import WritingAssistantTab from '@/components/posts/editor/WritingAssistantTab.vue';
import EmojiPicker from '@/components/posts/EmojiPicker.vue';
import HashtagsModal from '@/components/posts/HashtagsModal.vue';
import MediaPickerDialog from '@/components/posts/MediaPickerDialog.vue';
import PostEditorComposer from '@/components/posts/editor/PostEditorComposer.vue';
import PostEditorSidebar from '@/components/posts/editor/PostEditorSidebar.vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import { Button } from '@/components/ui/button';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { readFileMetadata } from '@/composables/useMedia';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { store as storeAsset } from '@/routes/app/assets';
import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts';
import { destroy as destroyPost, index as postsIndex, update as updatePost } from '@/routes/app/posts';
import type { BreadcrumbItem } from '@/types';
interface MediaItem {
id: string;
@ -114,6 +100,11 @@ const props = defineProps<{
const post = computed(() => props.post);
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(post.value.status));
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('posts.title'), href: postsIndex.url() },
{ title: trans('posts.edit.title') },
]);
const isPublishing = computed(() => post.value.status === 'publishing');
const isPublished = computed(() => ['published', 'partially_published'].includes(post.value.status));
@ -273,15 +264,8 @@ const initialTabFromQuery = (() => {
const initialHighlightCommentId = queryParams?.get('comment') ?? null;
const activeTab = ref(initialTabFromQuery);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const hashtagsModal = ref<InstanceType<typeof HashtagsModal> | null>(null);
const mediaPickerDialog = ref<InstanceType<typeof MediaPickerDialog> | null>(null);
const commentsTabRef = ref<InstanceType<typeof CommentsTab> | null>(null);
const emojiOpen = ref(false);
const editorSidebarRef = ref<InstanceType<typeof PostEditorSidebar> | null>(null);
const isDragging = ref(false);
const uploading = ref(false);
// Toggle platform
const togglePlatform = (platformId: string) => {
if (isReadOnly.value) return;
const index = selectedPlatformIds.value.indexOf(platformId);
@ -292,79 +276,6 @@ const togglePlatform = (platformId: string) => {
}
};
const previewablePlatforms = computed(() => {
const selected = post.value.post_platforms.filter((pp) => selectedPlatformIds.value.includes(pp.id));
return selected.length > 0 ? selected : post.value.post_platforms.slice(0, 1);
});
// Media upload
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const handleDrop = (event: DragEvent) => {
isDragging.value = false;
if (event.dataTransfer?.files) {
uploadFiles(Array.from(event.dataTransfer.files));
}
};
const uploadFiles = async (files: File[]) => {
uploading.value = true;
for (const file of files) {
const clientMeta = await readFileMetadata(file);
const formData = new FormData();
formData.append('media', file);
if (clientMeta.width) formData.append('meta[width]', String(clientMeta.width));
if (clientMeta.height) formData.append('meta[height]', String(clientMeta.height));
if (clientMeta.duration) formData.append('meta[duration]', String(clientMeta.duration));
try {
const response = await fetch(storeAsset.url(), {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
if (!response.ok) continue;
const data = await response.json();
media.value = [
...media.value,
{
id: data.id,
path: data.path,
url: data.url,
type: data.type,
mime_type: data.mime_type,
original_filename: data.original_filename,
size: data.size,
meta: data.meta,
},
];
} catch {
// ignore
}
}
uploading.value = false;
};
const removeMedia = (mediaId: string) => {
media.value = media.value.filter((m) => m.id !== mediaId);
};
const addMediaFromGallery = (picked: MediaItem[]) => {
const existingIds = new Set(media.value.map((m) => m.id));
const additions = picked.filter((m) => !existingIds.has(m.id));
if (additions.length === 0) return;
media.value = [...media.value, ...additions];
};
const addedTextFromMessageIds = ref<Set<string>>(new Set());
const addMediaFromAssistant = (payload: {
@ -426,7 +337,7 @@ const save = () => {
};
const debouncedSave = debounce(() => {
if (!isReadOnly.value && !uploading.value && !isSubmitting.value) {
if (!isReadOnly.value && !isSubmitting.value) {
save();
}
}, 1500);
@ -468,18 +379,6 @@ const toggleLabel = (labelId: string) => {
}
};
const appendHashtags = (hashtag: { id: string; name: string; hashtags: string }) => {
if (isReadOnly.value) return;
const separator = content.value.trim() ? '\n\n' : '';
content.value += separator + hashtag.hashtags;
};
const appendEmoji = (emoji: string) => {
if (isReadOnly.value) return;
content.value += emoji;
emojiOpen.value = false;
};
const focusAssistant = () => {
activeTab.value = 'assistant';
};
@ -501,88 +400,85 @@ useEcho(`post.${post.value.id}`, '.PostPlatformStatusUpdated', () => {
// Echo: listen for real-time comments
useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
if (e.mentioned_users) {
commentsTabRef.value?.registerMentionedUsers(e.mentioned_users);
editorSidebarRef.value?.registerMentionedUsers(e.mentioned_users);
}
commentsTabRef.value?.addCommentFromBroadcast(e.comment);
editorSidebarRef.value?.addCommentFromBroadcast(e.comment);
});
</script>
<template>
<Head :title="$t('posts.edit.title')" />
<AppLayout :full-width="true">
<div class="flex flex-col h-screen">
<!-- Slim status bar -->
<div class="flex items-center justify-between gap-4 border-b bg-background px-4 py-2">
<div class="flex min-w-0 items-center gap-2">
<span v-if="isSaving" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconLoader2 class="h-3.5 w-3.5 animate-spin" />
{{ $t('posts.edit.saving') }}
</span>
<span v-else-if="showSaved" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconCircleCheck class="h-3.5 w-3.5 text-green-500" />
{{ $t('posts.edit.saved') }}
</span>
<span v-else-if="isPublished" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconCircleCheck class="h-3.5 w-3.5 text-green-500" />
{{ $t('posts.edit.status.published') }}
</span>
<span v-else class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span class="h-2 w-2 rounded-full bg-muted-foreground/50" />
{{ $t('posts.edit.draft') }}
</span>
</div>
<AppLayout :full-width="true" :breadcrumbs="breadcrumbs">
<template #header-actions>
<span v-if="isSaving" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconLoader2 class="h-3.5 w-3.5 animate-spin" />
{{ $t('posts.edit.saving') }}
</span>
<span v-else-if="showSaved" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconCircleCheck class="h-3.5 w-3.5 text-green-500" />
{{ $t('posts.edit.saved') }}
</span>
<span v-else-if="isPublished" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconCircleCheck class="h-3.5 w-3.5 text-green-500" />
{{ $t('posts.edit.status.published') }}
</span>
<span v-else class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span class="h-2 w-2 rounded-full bg-muted-foreground/50" />
{{ $t('posts.edit.draft') }}
</span>
<div v-if="!isReadOnly" class="flex shrink-0 items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
:disabled="isSaving || isSubmitting"
@click="deletePost"
>
<IconTrash class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<template v-if="!isReadOnly">
<span class="h-4 w-px bg-border" />
<span class="h-4 w-px bg-border" />
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="secondary"
size="sm"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
>
<IconCalendar class="h-4 w-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
:disabled="isSaving || isSubmitting"
@click="deletePost"
>
<IconTrash class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="secondary"
size="sm"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="submit(hasPickedTime ? 'scheduled' : 'publishing')"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
<IconCalendar class="h-4 w-4" />
{{ pickTimeLabel }}
</Button>
</div>
</div>
</PickTimePopover>
<Button
type="button"
size="sm"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="submit(hasPickedTime ? 'scheduled' : 'publishing')"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</template>
</template>
<div class="flex flex-col flex-1 min-h-0">
<div class="relative flex-1 overflow-hidden">
<div
v-if="isPublishing"
@ -595,228 +491,41 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
</div>
</div>
<div class="h-full flex">
<!-- Composition column -->
<div class="w-full lg:w-2/3 lg:border-r overflow-y-auto">
<div class="max-w-2xl mx-auto py-8 px-6">
<div
class="rounded-lg border bg-card shadow-sm transition-shadow focus-within:shadow-md"
:class="isDragging ? 'border-primary ring-2 ring-primary/20' : ''"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<!-- Card header -->
<div class="flex items-start gap-3 border-b px-5 py-4">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<IconMessage2 class="h-5 w-5" />
</div>
<div class="min-w-0">
<h2 class="text-sm font-semibold text-foreground">{{ $t('posts.edit.compose_title') }}</h2>
<p class="text-xs text-muted-foreground">{{ $t('posts.edit.compose_subtitle') }}</p>
</div>
</div>
<!-- Textarea + counter -->
<div class="relative">
<Textarea
v-model="content"
:placeholder="$t('posts.edit.caption_placeholder')"
:disabled="isReadOnly"
class="min-h-[240px] resize-none rounded-none border-0 bg-transparent px-5 py-4 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<span class="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground/70">
{{ content.length }}
</span>
</div>
<!-- Inline toolbar -->
<div v-if="!isReadOnly" class="flex items-center gap-1 border-t px-3 py-2">
<Popover v-model:open="emojiOpen">
<PopoverAnchor as-child>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="emojiOpen = !emojiOpen"
>
<IconMoodSmile class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.emoji_picker.search') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</PopoverAnchor>
<PopoverContent class="w-auto p-0" align="start">
<EmojiPicker @select="appendEmoji" />
</PopoverContent>
</Popover>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="hashtagsModal?.open()"
>
<IconHash class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.hashtags') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="focusAssistant"
>
<IconSparkles class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.tabs.writing_assistant') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="mediaPickerDialog?.open()"
>
<IconLibraryPhoto class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.add_media') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<div class="flex-1" />
<span v-if="uploading" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconLoader2 class="h-3.5 w-3.5 animate-spin" />
</span>
</div>
<!-- Media grid -->
<div v-if="media.length > 0" class="border-t px-5 py-4">
<div class="grid grid-cols-4 gap-2">
<div
v-for="item in media"
:key="item.id"
class="group relative aspect-square overflow-hidden rounded-lg border bg-muted"
>
<video
v-if="item.type === 'video' || item.mime_type?.startsWith('video/')"
:src="item.url"
class="h-full w-full object-cover"
muted
/>
<img
v-else
:src="item.url"
:alt="item.original_filename"
class="h-full w-full object-cover"
loading="lazy"
/>
<button
v-if="!isReadOnly"
type="button"
class="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-black/80 group-hover:opacity-100"
@click="removeMedia(item.id)"
>
<IconTrash class="h-3 w-3" />
</button>
</div>
<button
v-if="!isReadOnly"
type="button"
class="flex aspect-square items-center justify-center rounded-lg border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary/50 hover:text-primary"
@click="mediaPickerDialog?.open()"
>
<IconLibraryPhoto class="h-6 w-6" />
</button>
</div>
</div>
<!-- Drag overlay hint -->
<div v-if="isDragging && !isReadOnly" class="border-t bg-primary/5 px-5 py-6 text-center text-sm text-primary">
{{ $t('posts.edit.drag_drop') }}
</div>
</div>
</div>
<PostEditorComposer
v-model:content="content"
v-model:media="media"
:is-read-only="isReadOnly"
:hashtags="hashtags"
@focus-assistant="focusAssistant"
/>
</div>
<!-- Right sidebar -->
<div class="hidden lg:block lg:w-1/3 overflow-hidden">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<TabsList class="mx-4 mt-4 w-auto shrink-0">
<TabsTrigger value="preview">{{ $t('posts.edit.tabs.preview') }}</TabsTrigger>
<TabsTrigger value="schedule">{{ $t('posts.edit.tabs.schedule') }}</TabsTrigger>
<TabsTrigger value="comments">{{ $t('posts.edit.tabs.comments') }}</TabsTrigger>
<TabsTrigger value="assistant">{{ $t('posts.edit.tabs.writing_assistant') }}</TabsTrigger>
</TabsList>
<TabsContent value="preview" class="flex-1 overflow-y-auto">
<PreviewTab
:platforms="previewablePlatforms"
:content="content"
:media="media"
:platform-content-types="platformContentTypes"
:platform-meta="platformMeta"
@update:platform-content-type="updatePlatformContentType"
/>
</TabsContent>
<TabsContent value="schedule" class="flex-1 overflow-y-auto p-4">
<ScheduleTab
:post-platforms="post.post_platforms"
:selected-platform-ids="selectedPlatformIds"
:labels="labels"
:selected-label-ids="selectedLabelIds"
:is-read-only="isReadOnly"
:platform-configs="platformConfigs"
:platform-meta="platformMeta"
:platform-content-types="platformContentTypes"
:platform-issues="platformIssues"
:tiktok-creator-infos="tiktokCreatorInfos"
:media="media"
@toggle-platform="togglePlatform"
@toggle-label="toggleLabel"
@update:platformMeta="updatePlatformMeta"
@update:platformContentType="updatePlatformContentType"
/>
</TabsContent>
<TabsContent value="comments" class="flex-1 overflow-hidden">
<CommentsTab
ref="commentsTabRef"
:post-id="post.id"
:current-user-id="authUserId"
:highlight-comment-id="initialHighlightCommentId"
/>
</TabsContent>
<TabsContent value="assistant" class="flex-1 overflow-hidden">
<WritingAssistantTab :post-id="post.id" :workspace-id="workspace.id" @add-media="addMediaFromAssistant" />
</TabsContent>
</Tabs>
<PostEditorSidebar
ref="editorSidebarRef"
v-model:active-tab="activeTab"
:post="post"
:workspace-id="workspace.id"
:content="content"
:media="media"
:selected-platform-ids="selectedPlatformIds"
:platform-meta="platformMeta"
:platform-content-types="platformContentTypes"
:platform-issues="platformIssues"
:platform-configs="platformConfigs"
:labels="labels"
:selected-label-ids="selectedLabelIds"
:tiktok-creator-infos="tiktokCreatorInfos"
:is-read-only="isReadOnly"
:auth-user-id="authUserId"
:initial-highlight-comment-id="initialHighlightCommentId"
@toggle-platform="togglePlatform"
@toggle-label="toggleLabel"
@update:platform-meta="updatePlatformMeta"
@update:platform-content-type="updatePlatformContentType"
@add-media-from-assistant="addMediaFromAssistant"
/>
</div>
</div>
</div>
@ -830,6 +539,4 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
:action="$t('posts.delete.confirm')"
:cancel="$t('posts.delete.cancel')"
/>
<HashtagsModal ref="hashtagsModal" :hashtags="hashtags" @select="appendHashtags" />
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
</template>