feat: implement post editor sidebar with tabbed navigation and add support for LinkedIn and Pinterest platform settings
This commit is contained in:
parent
17ca1f7b0e
commit
02f45e31e7
18 changed files with 918 additions and 438 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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">· @{{ socialAccount.username }}</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ socialAccount.username }}</span>
|
||||
</span>
|
||||
|
|
|
|||
117
resources/js/components/posts/editor/LinkedInSettings.vue
Normal file
117
resources/js/components/posts/editor/LinkedInSettings.vue
Normal 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">· @{{ 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>
|
||||
108
resources/js/components/posts/editor/PinterestSettings.vue
Normal file
108
resources/js/components/posts/editor/PinterestSettings.vue
Normal 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">· @{{ 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>
|
||||
300
resources/js/components/posts/editor/PostEditorComposer.vue
Normal file
300
resources/js/components/posts/editor/PostEditorComposer.vue
Normal 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>
|
||||
162
resources/js/components/posts/editor/PostEditorSidebar.vue
Normal file
162
resources/js/components/posts/editor/PostEditorSidebar.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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">· @{{ socialAccount.username }}</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue