trypost/resources/js/pages/posts/Edit.vue

977 lines
No EOL
42 KiB
Vue

<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue';
import {
IconBrandBluesky,
IconBrandFacebook,
IconBrandInstagram,
IconBrandLinkedin,
IconBrandMastodon,
IconBrandPinterest,
IconBrandThreads,
IconBrandTiktok,
IconBrandX,
IconBrandYoutube,
} from '@tabler/icons-vue';
import {
IconClock,
IconAlertCircle,
IconTrash,
IconDots,
IconPlus,
IconCircleCheck,
IconExternalLink,
IconLoader2,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref, watch, type Component } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import DatePicker from '@/components/DatePicker.vue';
import PhoneMockup from '@/components/PhoneMockup.vue';
import PostForm from '@/components/posts/PostForm.vue';
import { PlatformPreview } from '@/components/posts/previews';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useMediaManager, type MediaItem } from '@/composables/useMediaManager';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as destroyPost, index as postsIndex, update as updatePost } from '@/routes/posts';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface PostPlatform {
id: string;
social_account_id: string;
enabled: boolean;
platform: string;
content: string;
content_type: string | null;
status: string;
platform_url: string | null;
error_message: string | null;
published_at: string | null;
social_account: SocialAccount;
media: MediaItem[];
meta?: Record<string, any>;
}
interface PinterestBoard {
id: string;
name: string;
}
interface ContentTypeOption {
value: string;
label: string;
description: string;
}
interface Post {
id: string;
status: string;
synced: boolean;
scheduled_at: string | null;
published_at: string | null;
post_platforms: PostPlatform[];
}
interface PlatformConfig {
maxContentLength: number;
maxImages: number;
allowedMediaTypes: string[];
supportsTextOnly: boolean;
}
interface Workspace {
id: string;
name: string;
timezone: string;
}
interface Props {
workspace: Workspace;
post: Post;
socialAccounts: SocialAccount[];
platformConfigs: Record<string, PlatformConfig>;
pinterestBoards: PinterestBoard[];
}
const props = defineProps<Props>();
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('posts.title'), href: postsIndex.url() },
{ title: trans('posts.edit.title'), href: '#' },
]);
// Reactive state for real-time updates
const post = ref(props.post);
// Listen for status updates via WebSocket
useEcho(
`posts.${props.post.id}`,
'PostPlatformStatusUpdated',
(e: { post_platform: { id: string; status: string; platform_url: string | null; error_message: string | null; published_at: string | null }; post: { id: string; status: string; published_at: string | null } }) => {
// Update post platform status
const platformIndex = post.value.post_platforms.findIndex(pp => pp.id === e.post_platform.id);
if (platformIndex !== -1) {
post.value.post_platforms[platformIndex].status = e.post_platform.status;
post.value.post_platforms[platformIndex].platform_url = e.post_platform.platform_url;
post.value.post_platforms[platformIndex].error_message = e.post_platform.error_message;
post.value.post_platforms[platformIndex].published_at = e.post_platform.published_at;
}
// Update post status
post.value.status = e.post.status;
post.value.published_at = e.post.published_at;
},
);
// Check if post is in read-only mode (published, failed, publishing, partially_published)
const isReadOnly = computed(() => ['published', 'failed', 'publishing', 'partially_published'].includes(post.value.status));
// State
const selectedPlatformIds = ref<string[]>(
post.value.post_platforms.filter(pp => pp.enabled).map(pp => pp.id)
);
const synced = ref(post.value.synced);
const enabledPlatforms = post.value.post_platforms.filter(pp => pp.enabled);
const globalContent = ref(
enabledPlatforms[0]?.content || post.value.post_platforms[0]?.content || ''
);
const platformContents = ref<Record<string, string>>(
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.content]))
);
const platformContentTypes = ref<Record<string, string>>(
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.content_type || getDefaultContentType(pp.platform)]))
);
const platformMeta = ref<Record<string, Record<string, any>>>(
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.meta || {}]))
);
// Media manager composable
const postPlatformsRef = computed(() => post.value.post_platforms);
const {
platformMedia,
isUploading,
upload: uploadMedia,
remove: removeMedia,
reorder: reorderMedia,
} = useMediaManager({
synced,
selectedPlatformIds,
platformContentTypes,
postPlatforms: postPlatformsRef,
});
// Check if any platform is currently uploading
const isAnyUploading = computed(() => Object.values(isUploading.value).some(v => v));
const isSubmitting = ref(false);
const isSaving = ref(false);
const showSaved = ref(false);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const showEnableSyncDialog = ref(false);
const showDisableSyncDialog = ref(false);
const showPlatformsDialog = ref(false);
// Active tab - first selected platform
const activeTabId = ref<string>(selectedPlatformIds.value[0] || post.value.post_platforms[0]?.id || '');
// Watch selected platforms and update active tab if needed
watch(selectedPlatformIds, (newIds) => {
if (!newIds.includes(activeTabId.value) && newIds.length > 0) {
activeTabId.value = newIds[0];
}
}, { deep: true });
// Convert UTC to workspace timezone for display
const getLocalSchedule = () => {
if (!post.value.scheduled_at) {
return '';
}
const local = dayjs.utc(post.value.scheduled_at).tz(props.workspace.timezone);
return local.format('YYYY-MM-DDTHH:mm:00');
};
const scheduledDateTime = ref(getLocalSchedule());
const timezoneAbbr = computed(() => {
return dayjs().tz(props.workspace.timezone).format('z');
});
// Helpers
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
'linkedin': '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
'x': '/images/accounts/x.png',
'tiktok': '/images/accounts/tiktok.png',
'youtube': '/images/accounts/youtube.png',
'facebook': '/images/accounts/facebook.png',
'instagram': '/images/accounts/instagram.png',
'threads': '/images/accounts/threads.png',
'pinterest': '/images/accounts/pinterest.png',
'bluesky': '/images/accounts/bluesky.png',
'mastodon': '/images/accounts/mastodon.png',
};
return logos[platform] || '/images/accounts/default.png';
};
const getPlatformLabel = (platform: string): string => {
return trans(`posts.platforms.${platform}`) || platform;
};
const getPlatformIcon = (platform: string): Component => {
const icons: Record<string, Component> = {
'linkedin': IconBrandLinkedin,
'linkedin-page': IconBrandLinkedin,
'x': IconBrandX,
'tiktok': IconBrandTiktok,
'youtube': IconBrandYoutube,
'facebook': IconBrandFacebook,
'instagram': IconBrandInstagram,
'threads': IconBrandThreads,
'pinterest': IconBrandPinterest,
'bluesky': IconBrandBluesky,
'mastodon': IconBrandMastodon,
};
return icons[platform];
};
const getStatusConfig = (status: string) => {
const configs: Record<string, { color: string; icon: any }> = {
'draft': { color: 'bg-neutral-100 text-neutral-800', icon: IconClock },
'scheduled': { color: 'bg-blue-100 text-blue-800', icon: IconClock },
'publishing': { color: 'bg-yellow-100 text-yellow-800', icon: IconLoader2 },
'published': { color: 'bg-green-100 text-green-800', icon: IconCircleCheck },
'partially_published': { color: 'bg-orange-100 text-orange-800', icon: IconAlertCircle },
'failed': { color: 'bg-red-100 text-red-800', icon: IconAlertCircle },
};
const config = configs[status] || configs['draft'];
return {
...config,
label: trans(`posts.status.${status}`),
};
};
const formatDateTime = (date: string | null): string => {
if (!date) return '-';
return dayjs.utc(date).tz(props.workspace.timezone).format('MMM D, YYYY [at] h:mm A');
};
// Content type options per platform
const contentTypeKeys: Record<string, string[]> = {
'instagram': ['instagram_feed', 'instagram_reel', 'instagram_story'],
'linkedin': ['linkedin_post', 'linkedin_carousel'],
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel'],
'facebook': ['facebook_post', 'facebook_reel', 'facebook_story'],
'tiktok': ['tiktok_video'],
'youtube': ['youtube_short'],
'x': ['x_post'],
'threads': ['threads_post'],
'pinterest': ['pinterest_pin', 'pinterest_video_pin', 'pinterest_carousel'],
'bluesky': ['bluesky_post'],
'mastodon': ['mastodon_post'],
};
function getDefaultContentType(platform: string): string {
const defaults: Record<string, string> = {
'instagram': 'instagram_feed',
'linkedin': 'linkedin_post',
'linkedin-page': 'linkedin_page_post',
'facebook': 'facebook_post',
'tiktok': 'tiktok_video',
'youtube': 'youtube_short',
'x': 'x_post',
'threads': 'threads_post',
'pinterest': 'pinterest_pin',
'bluesky': 'bluesky_post',
};
return defaults[platform] || '';
}
function getContentTypeOptions(platform: string): ContentTypeOption[] {
const keys = contentTypeKeys[platform] || [];
return keys.map(key => ({
value: key,
label: trans(`posts.content_types.${key}.label`),
description: trans(`posts.content_types.${key}.description`),
}));
}
function getPlatformData(platform: string): Record<string, any> {
if (platform === 'pinterest') {
return { boards: props.pinterestBoards };
}
return {};
}
const getConfig = (postPlatform: PostPlatform): PlatformConfig => {
return props.platformConfigs[postPlatform.social_account_id] || {
maxContentLength: 5000,
maxImages: 10,
allowedMediaTypes: ['image', 'video'],
supportsTextOnly: true,
};
};
// Get content for a platform
const getContent = (platformId: string): string => {
if (synced.value) {
return globalContent.value;
}
return platformContents.value[platformId] || '';
};
// === Autosave Logic ===
const getSubmitData = () => {
const platforms = post.value.post_platforms
.filter(pp => selectedPlatformIds.value.includes(pp.id))
.map(pp => ({
id: pp.id,
content: synced.value ? globalContent.value : platformContents.value[pp.id],
content_type: platformContentTypes.value[pp.id],
meta: platformMeta.value[pp.id] || {},
}));
return { platforms, scheduled_at: scheduledDateTime.value || null };
};
const save = () => {
if (isSubmitting.value || isReadOnly.value || isSaving.value) return;
const { platforms, scheduled_at } = getSubmitData();
isSaving.value = true;
showSaved.value = false;
router.put(updatePost.url(post.value.id), {
status: post.value.status,
synced: synced.value,
scheduled_at,
platforms,
}, {
preserveScroll: true,
onFinish: () => {
isSaving.value = false;
showSaved.value = true;
setTimeout(() => {
showSaved.value = false;
}, 2000);
},
});
};
const debouncedSave = debounce(() => {
if (!isReadOnly.value && !isAnyUploading.value && !isSubmitting.value) {
save();
}
}, 1500);
const triggerAutosave = () => {
if (!isReadOnly.value) {
showSaved.value = false;
debouncedSave();
}
};
// Watch other form data for autosave
watch([selectedPlatformIds, synced, scheduledDateTime], triggerAutosave, { deep: true });
// Cleanup on unmount
onUnmounted(() => {
debouncedSave.cancel();
});
// === Content Setters ===
// Set content for a platform
const setContent = (platformId: string, value: string) => {
if (synced.value) {
globalContent.value = value;
// Also update all platform contents when synced
for (const id of selectedPlatformIds.value) {
platformContents.value[id] = value;
}
} else {
platformContents.value[platformId] = value;
}
triggerAutosave();
};
// Active platform
const activePlatform = computed(() => {
return post.value.post_platforms.find(pp => pp.id === activeTabId.value);
});
// Current platform media (computed for proper reactivity)
const currentPlatformMedia = computed(() => {
if (!activePlatform.value) return [];
return platformMedia.value[activePlatform.value.id] || [];
});
// Current platform content type
const currentContentType = computed(() => {
if (!activePlatform.value) return '';
return platformContentTypes.value[activePlatform.value.id] || '';
});
// Set content type for a platform
const setContentType = (platformId: string, value: string) => {
platformContentTypes.value[platformId] = value;
triggerAutosave();
};
// Set meta for a platform
const setMeta = (platformId: string, value: Record<string, any>) => {
platformMeta.value[platformId] = value;
triggerAutosave();
};
// Selected platforms (for tabs)
const selectedPlatforms = computed(() => {
return post.value.post_platforms.filter(pp => selectedPlatformIds.value.includes(pp.id));
});
// First selected platform (for sync confirmation)
const firstSelectedPlatform = computed(() => {
return selectedPlatforms.value[0];
});
// Handle sync toggle with confirmation
const handleSyncClick = (event: Event) => {
event.preventDefault();
event.stopPropagation();
if (!synced.value) {
// User wants to enable sync - show confirmation dialog
showEnableSyncDialog.value = true;
} else {
// User wants to disable sync - show confirmation dialog
showDisableSyncDialog.value = true;
}
};
// Confirm sync enable
const confirmEnableSync = () => {
if (firstSelectedPlatform.value) {
// Copy content from first platform to global and all platforms
const firstContent = platformContents.value[firstSelectedPlatform.value.id] || '';
globalContent.value = firstContent;
for (const id of selectedPlatformIds.value) {
platformContents.value[id] = firstContent;
}
}
synced.value = true;
showEnableSyncDialog.value = false;
};
// Confirm sync disable
const confirmDisableSync = () => {
synced.value = false;
showDisableSyncDialog.value = false;
};
// Computed
const contentValidation = computed(() => {
const results: Record<string, { valid: boolean; message: string; charCount: number; maxLength: number }> = {};
for (const pp of post.value.post_platforms) {
if (!selectedPlatformIds.value.includes(pp.id)) continue;
const config = getConfig(pp);
const content = getContent(pp.id);
const charCount = content.length;
const hasContent = charCount > 0;
const withinLimit = charCount <= config.maxContentLength;
const media = platformMedia.value[pp.id] || [];
const hasMedia = media.length > 0;
const meta = platformMeta.value[pp.id] || {};
// Check media type compatibility
const imageCount = media.filter(m => m.type === 'image').length;
const videoCount = media.filter(m => m.type === 'video').length;
const hasUnsupportedImages = config.maxImages === 0 && imageCount > 0;
const hasTooManyImages = imageCount > config.maxImages && config.maxImages > 0;
const hasUnsupportedVideos = !config.allowedMediaTypes.includes('video') && videoCount > 0;
// Pinterest requires a board
if (pp.platform === 'pinterest' && !meta.board_id) {
results[pp.id] = { valid: false, message: trans('posts.edit.validation.select_board'), charCount, maxLength: config.maxContentLength };
} else if (hasUnsupportedImages) {
results[pp.id] = { valid: false, message: trans('posts.edit.validation.images_not_supported'), charCount, maxLength: config.maxContentLength };
} else if (hasUnsupportedVideos) {
results[pp.id] = { valid: false, message: trans('posts.edit.validation.videos_not_supported'), charCount, maxLength: config.maxContentLength };
} else if (hasTooManyImages) {
results[pp.id] = { valid: false, message: trans('posts.edit.validation.max_images', { count: config.maxImages }), charCount, maxLength: config.maxContentLength };
} else if (!config.supportsTextOnly && !hasMedia) {
results[pp.id] = { valid: false, message: trans('posts.edit.validation.requires_media'), charCount, maxLength: config.maxContentLength };
} else if (!hasContent && !hasMedia) {
results[pp.id] = { valid: false, message: trans('posts.edit.no_content'), charCount, maxLength: config.maxContentLength };
} else if (!withinLimit) {
results[pp.id] = { valid: false, message: trans('posts.edit.validation.exceeded', { count: charCount - config.maxContentLength }), charCount, maxLength: config.maxContentLength };
} else {
results[pp.id] = { valid: true, message: `${charCount}/${config.maxContentLength}`, charCount, maxLength: config.maxContentLength };
}
}
return results;
});
const mediaValidation = computed(() => {
const errors: string[] = [];
for (const pp of post.value.post_platforms) {
if (!selectedPlatformIds.value.includes(pp.id)) continue;
const config = getConfig(pp);
const media = platformMedia.value[pp.id] || [];
const imageCount = media.filter(m => m.type === 'image').length;
const videoCount = media.filter(m => m.type === 'video').length;
if (config.maxImages === 0 && imageCount > 0) {
errors.push(trans('posts.edit.validation.does_not_support_images', { platform: getPlatformLabel(pp.platform) }));
}
if (imageCount > config.maxImages && config.maxImages > 0) {
errors.push(trans('posts.edit.validation.supports_up_to_images', { platform: getPlatformLabel(pp.platform), count: config.maxImages }));
}
if (!config.allowedMediaTypes.includes('video') && videoCount > 0) {
errors.push(trans('posts.edit.validation.does_not_support_videos', { platform: getPlatformLabel(pp.platform) }));
}
}
return [...new Set(errors)];
});
const canSubmit = computed(() => {
if (selectedPlatformIds.value.length === 0) return false;
if (!scheduledDateTime.value) return false;
const selectedValidations = Object.entries(contentValidation.value)
.filter(([id]) => selectedPlatformIds.value.includes(id));
return selectedValidations.every(([, v]) => v.valid) && mediaValidation.value.length === 0;
});
// Methods
const togglePlatform = (platformId: string) => {
const index = selectedPlatformIds.value.indexOf(platformId);
if (index === -1) {
selectedPlatformIds.value.push(platformId);
// Switch to newly added platform
activeTabId.value = platformId;
} else {
selectedPlatformIds.value.splice(index, 1);
}
};
const submit = (status: string = 'scheduled') => {
if (isSubmitting.value || isReadOnly.value) return;
// Cancel any pending autosave
debouncedSave.cancel();
const { platforms, scheduled_at } = getSubmitData();
isSubmitting.value = true;
router.put(updatePost.url(post.value.id), {
status,
synced: synced.value,
scheduled_at,
platforms,
}, {
onFinish: () => {
isSubmitting.value = false;
},
});
};
const deletePost = () => {
if (isReadOnly.value) return;
deleteModal.value?.open({
url: destroyPost.url(post.value.id),
redirect: 'posts.index',
});
};
</script>
<template>
<Head :title="isReadOnly ? $t('posts.edit.view_title') : $t('posts.edit.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col h-screen">
<!-- Top Bar with Tabs and Actions -->
<div class="relative flex items-center justify-between border-b px-4 py-2 bg-background">
<!-- Platform Tabs -->
<div class="flex items-center gap-2">
<!-- Status Badge (when read-only) -->
<Badge v-if="isReadOnly" :class="getStatusConfig(post.status).color" class="mr-2">
<component :is="getStatusConfig(post.status).icon" class="mr-1 h-3 w-3"
:class="{ 'animate-spin': post.status === 'publishing' }" />
{{ getStatusConfig(post.status).label }}
</Badge>
<!-- Sync Toggle (centered, only in edit mode with multiple platforms) -->
<div v-if="!isReadOnly && selectedPlatformIds.length > 1" class="flex items-center gap-2">
<Switch id="sync-content" v-model="synced" @click.capture="handleSyncClick" />
<Label for="sync-content">
{{ $t('posts.edit.sync') }}
</Label>
</div>
<div v-if="selectedPlatforms.length > 0"
class="bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1">
<TooltipProvider>
<Tooltip v-for="pp in selectedPlatforms" :key="pp.id">
<TooltipTrigger asChild>
<button type="button" @click="activeTabId = pp.id" :class="[
'relative inline-flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium whitespace-nowrap transition-all',
activeTabId === pp.id
? 'bg-background text-foreground shadow-sm'
: 'hover:bg-background/50 hover:text-foreground'
]">
<component :is="getPlatformIcon(pp.platform)" class="h-5 w-5" />
<!-- Status indicator (read-only) or Validation indicator (edit mode) -->
<span v-if="isReadOnly"
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full border border-background"
:class="{
'bg-green-500': pp.status === 'published',
'bg-red-500': pp.status === 'failed',
'bg-yellow-500 animate-pulse': pp.status === 'publishing',
'bg-neutral-400': !['published', 'failed', 'publishing'].includes(pp.status)
}" />
<span v-else-if="contentValidation[pp.id]"
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full border border-background"
:class="contentValidation[pp.id].valid ? 'bg-green-500' : 'bg-red-500'" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{{ pp.social_account.display_name }}</p>
<p v-if="isReadOnly" class="text-xs text-muted-foreground">
{{ getStatusConfig(pp.status).label }}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- Platforms Menu Button (only in edit mode) -->
<TooltipProvider v-if="!isReadOnly">
<Tooltip>
<TooltipTrigger asChild>
<button type="button" @click="showPlatformsDialog = true"
class="p-2 rounded-lg hover:bg-muted/50 transition-colors">
<IconDots class="h-5 w-5 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{{ $t('posts.edit.manage_platforms') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- Schedule & Actions (only in edit mode) -->
<div v-if="!isReadOnly" class="flex items-center gap-3">
<!-- DateTime Picker -->
<div class="flex items-center gap-2">
<DatePicker name="scheduled_datetime" v-model="scheduledDateTime" :show-time="true" />
<span class="text-xs text-muted-foreground">
{{ timezoneAbbr }}
</span>
</div>
<span class="text-muted-foreground">|</span>
<Button type="button" variant="outline" size="icon-sm" @click="deletePost"
:disabled="isSaving || isSubmitting" class="text-muted-foreground hover:text-destructive">
<IconTrash class="h-4 w-4" />
</Button>
<Button type="button" variant="secondary" size="sm"
:disabled="!canSubmit || isSubmitting || isSaving" @click="submit('scheduled')">
{{ $t('posts.edit.schedule') }}
</Button>
<Button type="button" size="sm" :disabled="!canSubmit || isSubmitting || isSaving"
@click="submit('publishing')">
{{ $t('posts.edit.publish') }}
</Button>
</div>
<!-- Read-only info -->
<div v-else class="flex items-center gap-3 text-sm text-muted-foreground">
<span v-if="post.scheduled_at">{{ $t('posts.edit.scheduled_at') }} {{
formatDateTime(post.scheduled_at) }}</span>
<span v-if="post.published_at">{{ $t('posts.edit.published_at') }} {{
formatDateTime(post.published_at) }}</span>
</div>
</div>
<!-- Main Content Area - Split Layout -->
<div class="flex-1 overflow-hidden">
<div v-if="activePlatform && selectedPlatformIds.length > 0" class="h-full flex">
<!-- Left Side: Form -->
<div class="w-1/2 border-r overflow-y-auto relative ">
<!-- Autosave indicator -->
<div v-if="!isReadOnly && (isSaving || showSaved)" class="absolute top-4 left-6 z-10">
<span v-if="isSaving" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconLoader2 class="h-4 w-4 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-4 w-4 text-green-500" />
{{ $t('posts.edit.saved') }}
</span>
</div>
<div class="max-w-lg mx-auto py-8 px-6">
<!-- Publication Result (read-only mode) -->
<div v-if="isReadOnly" class="mb-6 space-y-3">
<!-- Platform Status -->
<div class="flex items-center justify-between p-4 rounded-lg border">
<div class="flex items-center gap-3">
<component :is="getPlatformIcon(activePlatform.platform)"
class="h-8 w-8 text-muted-foreground" />
<div>
<p class="font-medium">{{ activePlatform.social_account.display_name }}</p>
<p class="text-sm text-muted-foreground">{{
getPlatformLabel(activePlatform.platform) }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<Badge :class="getStatusConfig(activePlatform.status).color">
<component :is="getStatusConfig(activePlatform.status).icon"
class="mr-1 h-3 w-3"
:class="{ 'animate-spin': activePlatform.status === 'publishing' }" />
{{ getStatusConfig(activePlatform.status).label }}
</Badge>
<a v-if="activePlatform.platform_url" :href="activePlatform.platform_url"
target="_blank"
class="p-2 rounded-lg hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground">
<IconExternalLink class="h-4 w-4" />
</a>
</div>
</div>
<!-- Error Message -->
<Alert v-if="activePlatform.error_message" variant="destructive">
<IconAlertCircle class="h-4 w-4" />
<AlertDescription>
{{ activePlatform.error_message }}
</AlertDescription>
</Alert>
<!-- Published Date -->
<p v-if="activePlatform.published_at" class="text-sm text-muted-foreground text-center">
Published {{ formatDateTime(activePlatform.published_at) }}
</p>
<!-- Read-only content display -->
<div class="mt-6 space-y-4">
<!-- Media -->
<div v-if="currentPlatformMedia.length > 0">
<p class="text-sm font-medium mb-2">{{ $t('posts.edit.media') }}</p>
<div class="grid grid-cols-3 gap-2">
<div v-for="item in currentPlatformMedia" :key="item.id"
class="aspect-square rounded-lg overflow-hidden bg-muted">
<img v-if="item.type === 'image'" :src="item.url"
:alt="item.original_filename" class="w-full h-full object-cover" />
<video v-else :src="item.url"
class="w-full h-full object-cover bg-black" muted />
</div>
</div>
</div>
<!-- Content -->
<div>
<p class="text-sm font-medium mb-2">{{ $t('posts.edit.caption') }}</p>
<p class="text-sm text-muted-foreground whitespace-pre-wrap">
{{ getContent(activePlatform.id) || $t('posts.edit.no_caption') }}
</p>
</div>
</div>
</div>
<!-- Edit Form -->
<PostForm v-else :platform="activePlatform.platform"
:content="getContent(activePlatform.id)" :media="currentPlatformMedia"
:content-type="currentContentType"
:content-type-options="getContentTypeOptions(activePlatform.platform)"
:meta="platformMeta[activePlatform.id]"
:platform-data="getPlatformData(activePlatform.platform)"
:char-count="contentValidation[activePlatform.id]?.charCount || 0"
:max-length="contentValidation[activePlatform.id]?.maxLength || 5000"
:is-valid="contentValidation[activePlatform.id]?.valid ?? false"
:validation-message="contentValidation[activePlatform.id]?.message || ''"
:is-uploading="isUploading[activePlatform.id]" :disabled="isReadOnly"
@update:content="setContent(activePlatform.id, $event)"
@update:content-type="setContentType(activePlatform.id, $event)"
@update:meta="setMeta(activePlatform.id, $event)"
@upload="uploadMedia($event, activePlatform.id)"
@remove-media="removeMedia(activePlatform.id, $event)"
@reorder-media="reorderMedia(activePlatform.id, $event)" />
<!-- Media Validation Errors -->
<Alert v-if="!isReadOnly && mediaValidation.length > 0" variant="destructive" class="mt-4">
<IconAlertCircle class="h-4 w-4" />
<AlertDescription>
<ul class="list-disc list-inside">
<li v-for="error in mediaValidation" :key="error">{{ error }}</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
<!-- Right Side: Phone Preview -->
<div class="w-1/2 bg-muted/30 overflow-y-auto">
<div class="flex justify-center py-8 px-4">
<PhoneMockup>
<PlatformPreview :key="activePlatform.id" :platform="activePlatform.platform"
:social-account="activePlatform.social_account"
:content="getContent(activePlatform.id)" :media="currentPlatformMedia"
:content-type="currentContentType" />
</PhoneMockup>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center h-full text-center p-8">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center mb-4">
<IconPlus class="h-8 w-8 text-muted-foreground" />
</div>
<h3 class="text-lg font-semibold mb-2">{{ $t('posts.edit.empty_state.title') }}</h3>
<p class="text-muted-foreground mb-4">{{ $t('posts.edit.empty_state.description') }}</p>
<div class="flex flex-wrap justify-center gap-2">
<button v-for="pp in post.post_platforms" :key="pp.id" type="button"
@click="togglePlatform(pp.id)"
class="flex items-center gap-2 px-3 py-2 rounded-lg border hover:bg-muted transition-colors">
<component :is="getPlatformIcon(pp.platform)" class="h-5 w-5 text-muted-foreground" />
<span class="text-sm">{{ pp.social_account.display_name }}</span>
</button>
</div>
</div>
</div>
</div>
</AppLayout>
<ConfirmDeleteModal ref="deleteModal" :title="$t('posts.edit.delete_modal.title')"
:description="$t('posts.edit.delete_modal.description')" :action="$t('posts.edit.delete_modal.action')"
:cancel="$t('posts.edit.delete_modal.cancel')" />
<!-- Enable Sync Confirmation Dialog -->
<AlertDialog :open="showEnableSyncDialog" @update:open="showEnableSyncDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{{ $t('posts.edit.sync_enable.title') }}
</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('posts.edit.sync_enable.description') }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('posts.edit.sync_enable.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="confirmEnableSync">
{{ $t('posts.edit.sync_enable.action') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Disable Sync Confirmation Dialog -->
<AlertDialog :open="showDisableSyncDialog" @update:open="showDisableSyncDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{{ $t('posts.edit.sync_disable.title') }}
</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('posts.edit.sync_disable.description') }}
<br /><br />
{{ $t('posts.edit.sync_disable.customize_note') }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('posts.edit.sync_disable.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="confirmDisableSync">
{{ $t('posts.edit.sync_disable.action') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Platforms Selection Dialog -->
<Dialog :open="showPlatformsDialog" @update:open="showPlatformsDialog = $event">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{{ $t('posts.edit.platforms_dialog.title') }}</DialogTitle>
<DialogDescription>
{{ $t('posts.edit.platforms_dialog.description') }}
</DialogDescription>
</DialogHeader>
<div class="grid grid-cols-2 gap-3 py-4">
<div v-for="pp in post.post_platforms" :key="pp.id"
class="flex items-center justify-between p-3 rounded-lg border">
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<img v-if="pp.social_account.avatar_url" :src="pp.social_account.avatar_url"
:alt="pp.social_account.display_name" class="h-10 w-10 rounded-full object-cover" />
<div v-else class="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<span class="text-sm font-medium">{{ pp.social_account.display_name?.charAt(0) }}</span>
</div>
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform"
class="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full ring-2 ring-background" />
</div>
<div class="min-w-0">
<p class="font-medium text-sm truncate">{{ pp.social_account.display_name }}</p>
<p class="text-xs text-muted-foreground">{{ getPlatformLabel(pp.platform) }}</p>
</div>
</div>
<div @click="togglePlatform(pp.id)" class="cursor-pointer shrink-0">
<Switch :model-value="selectedPlatformIds.includes(pp.id)" />
</div>
</div>
</div>
</DialogContent>
</Dialog>
</template>