542 lines
20 KiB
Vue
542 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import { Head, router } from '@inertiajs/vue3';
|
|
import { useEcho } from '@laravel/echo-vue';
|
|
import {
|
|
IconCalendar,
|
|
IconCircleCheck,
|
|
IconLoader2,
|
|
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 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
|
|
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/app/posts';
|
|
import type { BreadcrumbItem } from '@/types';
|
|
|
|
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;
|
|
content: string;
|
|
media: MediaItem[];
|
|
status: string;
|
|
scheduled_at: string | null;
|
|
published_at: string | null;
|
|
post_platforms: PostPlatform[];
|
|
labels?: { id: string; name: string }[];
|
|
}
|
|
|
|
interface Workspace {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
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<{
|
|
workspace: Workspace;
|
|
post: Post;
|
|
socialAccounts: SocialAccount[];
|
|
platformConfigs: Record<string, any>;
|
|
pinterestBoards: any[];
|
|
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
|
|
labels: { id: string; name: string; color: string }[];
|
|
hashtags: { id: string; name: string; hashtags: string }[];
|
|
authUserId: string;
|
|
}>();
|
|
|
|
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));
|
|
|
|
// Content
|
|
const content = ref(post.value.content || '');
|
|
const media = ref<MediaItem[]>(post.value.media || []);
|
|
|
|
// Platforms
|
|
const selectedPlatformIds = ref<string[]>(
|
|
post.value.post_platforms.filter((pp) => pp.enabled).map((pp) => pp.id),
|
|
);
|
|
|
|
// Per-platform meta (TikTok settings, Pinterest board, etc.)
|
|
const platformMeta = ref<Record<string, Record<string, any>>>(
|
|
Object.fromEntries(post.value.post_platforms.map((pp) => [pp.id, { ...(pp.meta ?? {}) }])),
|
|
);
|
|
|
|
const updatePlatformMeta = (platformId: string, meta: Record<string, any>) => {
|
|
platformMeta.value = { ...platformMeta.value, [platformId]: meta };
|
|
};
|
|
|
|
// Per-platform content_type (Instagram Feed/Reel/Story, Facebook Post/Reel/Story, etc.)
|
|
const platformContentTypes = ref<Record<string, string>>(
|
|
Object.fromEntries(post.value.post_platforms.map((pp) => [pp.id, pp.content_type ?? ''])),
|
|
);
|
|
|
|
const updatePlatformContentType = (platformId: string, contentType: string) => {
|
|
platformContentTypes.value = { ...platformContentTypes.value, [platformId]: contentType };
|
|
};
|
|
|
|
const getMediaIncompatibilityReason = (contentType: string, mediaItems: MediaItem[]): string | null => {
|
|
const rules = getMediaRulesForContentType(contentType);
|
|
const videos = mediaItems.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
|
|
const images = mediaItems.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
|
|
const gifs = mediaItems.filter((m) => m.mime_type === 'image/gif');
|
|
const total = mediaItems.length;
|
|
|
|
// Order matters: type-mismatch errors (image vs video vs gif) are more
|
|
// fundamental than count errors. Telling the user "YouTube doesn't accept
|
|
// images" is more actionable than "too many files" when they're mixing types.
|
|
if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media');
|
|
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
|
|
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
|
|
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
|
|
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: rules.maxFiles });
|
|
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: rules.minFiles });
|
|
|
|
for (const m of mediaItems) {
|
|
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
|
|
const size = m.size ?? 0;
|
|
const duration = m.meta?.duration ?? 0;
|
|
const width = m.meta?.width ?? 0;
|
|
const height = m.meta?.height ?? 0;
|
|
|
|
if (isVideo) {
|
|
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return trans('posts.edit.compliance.video_too_large');
|
|
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) {
|
|
return trans('posts.edit.compliance.video_too_long', { seconds: rules.maxVideoDurationSec });
|
|
}
|
|
} else if (rules.maxImageBytes && size > 0 && size > rules.maxImageBytes) {
|
|
return trans('posts.edit.compliance.image_too_large');
|
|
}
|
|
|
|
if (width > 0 && height > 0 && (rules.aspectRatioMin || rules.aspectRatioMax)) {
|
|
const ratio = width / height;
|
|
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return trans('posts.edit.compliance.aspect_ratio_invalid');
|
|
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return trans('posts.edit.compliance.aspect_ratio_invalid');
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const platformIssues = computed<Record<string, string>>(() => {
|
|
const issues: Record<string, string> = {};
|
|
|
|
for (const pp of post.value.post_platforms) {
|
|
const contentType = platformContentTypes.value[pp.id];
|
|
if (!contentType) {
|
|
issues[pp.id] = trans('posts.edit.compliance.no_content_type');
|
|
continue;
|
|
}
|
|
|
|
const reason = getMediaIncompatibilityReason(contentType, media.value);
|
|
if (reason) issues[pp.id] = reason;
|
|
}
|
|
|
|
return issues;
|
|
});
|
|
|
|
const mediaCompliancePerPlatformValid = computed(
|
|
() => selectedPlatformIds.value.every((id) => !platformIssues.value[id]),
|
|
);
|
|
|
|
// TikTok compliance per docs:
|
|
// - privacy_level must be explicitly selected
|
|
// - if disclosure toggle is ON, at least one sub-toggle must be selected
|
|
const tiktokComplianceValid = computed(() => {
|
|
const tiktokPlatforms = post.value.post_platforms.filter(
|
|
(pp) => pp.platform === 'tiktok' && selectedPlatformIds.value.includes(pp.id),
|
|
);
|
|
return tiktokPlatforms.every((pp) => {
|
|
const meta = platformMeta.value[pp.id] ?? {};
|
|
if (!meta.privacy_level) return false;
|
|
if (meta.disclose && !meta.brand_organic_toggle && !meta.brand_content_toggle) return false;
|
|
return true;
|
|
});
|
|
});
|
|
|
|
const canSchedule = computed(
|
|
() => mediaCompliancePerPlatformValid.value && tiktokComplianceValid.value,
|
|
);
|
|
|
|
const postActionTooltip = computed(() => {
|
|
if (canSchedule.value) return '';
|
|
|
|
const reasons = post.value.post_platforms
|
|
.filter((pp) => selectedPlatformIds.value.includes(pp.id) && platformIssues.value[pp.id])
|
|
.map((pp) => `${pp.platform_name ?? pp.platform}: ${platformIssues.value[pp.id]}`);
|
|
|
|
if (reasons.length === 0) return trans('posts.edit.compliance_incomplete');
|
|
|
|
return reasons.join('\n');
|
|
});
|
|
|
|
// Schedule
|
|
const getLocalSchedule = () => {
|
|
if (!post.value.scheduled_at) return '';
|
|
return dayjs.utc(post.value.scheduled_at).local().format('YYYY-MM-DDTHH:mm:00');
|
|
};
|
|
const scheduledDateTime = ref(getLocalSchedule());
|
|
const hasPickedTime = ref(post.value.status === 'scheduled' && !! post.value.scheduled_at);
|
|
|
|
const pickTimeLabel = computed(() => {
|
|
if (! hasPickedTime.value || ! scheduledDateTime.value) {
|
|
return trans('posts.edit.pick_time');
|
|
}
|
|
return dayjs(scheduledDateTime.value).format('MMM D, HH:mm');
|
|
});
|
|
|
|
// Labels
|
|
const selectedLabelIds = ref<string[]>(post.value.labels?.map((l) => l.id) || []);
|
|
|
|
// UI state
|
|
const isSubmitting = ref(false);
|
|
const isSaving = ref(false);
|
|
const showSaved = ref(false);
|
|
|
|
const isPostActionDisabled = computed(
|
|
() => isSubmitting.value || selectedPlatformIds.value.length === 0 || !canSchedule.value,
|
|
);
|
|
const queryParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
|
const initialTabFromQuery = (() => {
|
|
const tab = queryParams?.get('tab');
|
|
return ['preview', 'schedule', 'comments', 'assistant'].includes(tab ?? '') ? (tab as string) : 'schedule';
|
|
})();
|
|
const initialHighlightCommentId = queryParams?.get('comment') ?? null;
|
|
const activeTab = ref(initialTabFromQuery);
|
|
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
|
const editorSidebarRef = ref<InstanceType<typeof PostEditorSidebar> | null>(null);
|
|
|
|
const togglePlatform = (platformId: string) => {
|
|
if (isReadOnly.value) return;
|
|
const index = selectedPlatformIds.value.indexOf(platformId);
|
|
if (index === -1) {
|
|
selectedPlatformIds.value.push(platformId);
|
|
} else {
|
|
selectedPlatformIds.value.splice(index, 1);
|
|
}
|
|
};
|
|
|
|
const addedTextFromMessageIds = ref<Set<string>>(new Set());
|
|
|
|
const addMediaFromAssistant = (payload: {
|
|
messageId: string;
|
|
messageContent: string;
|
|
media: { id: string; path: string; url: string; type: string; mime_type: string };
|
|
}) => {
|
|
media.value = [...media.value, payload.media];
|
|
|
|
const text = payload.messageContent.trim();
|
|
if (text === '' || addedTextFromMessageIds.value.has(payload.messageId)) {
|
|
return;
|
|
}
|
|
|
|
content.value = content.value.trim() === '' ? text : `${content.value}\n\n${text}`;
|
|
addedTextFromMessageIds.value.add(payload.messageId);
|
|
};
|
|
|
|
// Save logic
|
|
const getSubmitData = () => {
|
|
const platforms = post.value.post_platforms
|
|
.filter((pp) => selectedPlatformIds.value.includes(pp.id))
|
|
.map((pp) => ({
|
|
id: pp.id,
|
|
content_type: platformContentTypes.value[pp.id] ?? pp.content_type,
|
|
meta: platformMeta.value[pp.id] ?? pp.meta ?? {},
|
|
}));
|
|
|
|
return {
|
|
content: content.value,
|
|
media: media.value,
|
|
platforms,
|
|
scheduled_at: scheduledDateTime.value
|
|
? dayjs(scheduledDateTime.value).utc().format()
|
|
: null,
|
|
label_ids: selectedLabelIds.value,
|
|
};
|
|
};
|
|
|
|
const save = () => {
|
|
if (isSubmitting.value || isReadOnly.value || isSaving.value) return;
|
|
|
|
const data = getSubmitData();
|
|
|
|
isSaving.value = true;
|
|
showSaved.value = false;
|
|
|
|
router.put(updatePost.url(post.value.id), {
|
|
status: post.value.status,
|
|
...data,
|
|
}, {
|
|
preserveScroll: true,
|
|
onFinish: () => {
|
|
isSaving.value = false;
|
|
showSaved.value = true;
|
|
setTimeout(() => { showSaved.value = false; }, 2000);
|
|
},
|
|
});
|
|
};
|
|
|
|
const debouncedSave = debounce(() => {
|
|
if (!isReadOnly.value && !isSubmitting.value) {
|
|
save();
|
|
}
|
|
}, 1500);
|
|
|
|
const triggerAutosave = () => {
|
|
if (!isReadOnly.value) {
|
|
showSaved.value = false;
|
|
debouncedSave();
|
|
}
|
|
};
|
|
|
|
watch([content, media, selectedPlatformIds, scheduledDateTime, selectedLabelIds, platformMeta, platformContentTypes], triggerAutosave, { deep: true });
|
|
|
|
onUnmounted(() => {
|
|
debouncedSave.cancel();
|
|
});
|
|
|
|
const submit = (status: string = 'scheduled') => {
|
|
if (isSubmitting.value || isReadOnly.value) return;
|
|
debouncedSave.cancel();
|
|
|
|
const data = getSubmitData();
|
|
isSubmitting.value = true;
|
|
|
|
router.put(updatePost.url(post.value.id), {
|
|
status,
|
|
...data,
|
|
}, {
|
|
onFinish: () => { isSubmitting.value = false; },
|
|
});
|
|
};
|
|
|
|
const toggleLabel = (labelId: string) => {
|
|
const index = selectedLabelIds.value.indexOf(labelId);
|
|
if (index === -1) {
|
|
selectedLabelIds.value.push(labelId);
|
|
} else {
|
|
selectedLabelIds.value.splice(index, 1);
|
|
}
|
|
};
|
|
|
|
const focusAssistant = () => {
|
|
activeTab.value = 'assistant';
|
|
};
|
|
|
|
const deletePost = () => {
|
|
if (isReadOnly.value) return;
|
|
deleteModal.value?.open({ url: destroyPost.url(post.value.id) });
|
|
};
|
|
|
|
// Echo: listen for real-time platform status updates.
|
|
// Event fires when any post_platform completes publishing (success or fail).
|
|
// Full reload of the post prop so the new status + post_platforms propagate and
|
|
// the overlay dismisses.
|
|
useEcho(`post.${post.value.id}`, '.PostPlatformStatusUpdated', () => {
|
|
router.reload({ only: ['post'] });
|
|
});
|
|
|
|
|
|
// Echo: listen for real-time comments
|
|
useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
|
if (e.mentioned_users) {
|
|
editorSidebarRef.value?.registerMentionedUsers(e.mentioned_users);
|
|
}
|
|
editorSidebarRef.value?.addCommentFromBroadcast(e.comment);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Head :title="$t('posts.edit.title')" />
|
|
|
|
<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>
|
|
|
|
<template v-if="!isReadOnly">
|
|
<span class="h-4 w-px bg-border" />
|
|
|
|
<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"
|
|
>
|
|
<IconCalendar class="h-4 w-4" />
|
|
{{ pickTimeLabel }}
|
|
</Button>
|
|
</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"
|
|
class="absolute inset-0 z-40 flex flex-col items-center justify-center gap-3 bg-background/80 backdrop-blur-sm"
|
|
>
|
|
<IconLoader2 class="h-8 w-8 animate-spin text-primary" />
|
|
<div class="text-center">
|
|
<p class="text-sm font-medium">{{ $t('posts.edit.publishing_overlay_title') }}</p>
|
|
<p class="text-xs text-muted-foreground">{{ $t('posts.edit.publishing_overlay_subtitle') }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="h-full flex">
|
|
<div class="w-full lg:w-2/3 lg:border-r overflow-y-auto">
|
|
<PostEditorComposer
|
|
v-model:content="content"
|
|
v-model:media="media"
|
|
:is-read-only="isReadOnly"
|
|
:hashtags="hashtags"
|
|
@focus-assistant="focusAssistant"
|
|
/>
|
|
</div>
|
|
|
|
<div class="hidden lg:block lg:w-1/3 overflow-hidden">
|
|
<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>
|
|
</div>
|
|
</AppLayout>
|
|
|
|
<ConfirmDeleteModal
|
|
ref="deleteModal"
|
|
:title="$t('posts.delete.title')"
|
|
:description="$t('posts.delete.description')"
|
|
:action="$t('posts.delete.confirm')"
|
|
:cancel="$t('posts.delete.cancel')"
|
|
/>
|
|
</template>
|