feat: improvements on ui
This commit is contained in:
parent
82316df470
commit
925540b93e
20 changed files with 306 additions and 513 deletions
|
|
@ -189,28 +189,6 @@ public function store(StorePostRequest $request): RedirectResponse
|
|||
return redirect()->route($route);
|
||||
}
|
||||
|
||||
public function show(Request $request, Post $post): Response|RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$post->load(['postPlatforms.socialAccount', 'postPlatforms.media', 'user']);
|
||||
|
||||
return Inertia::render('posts/Show', [
|
||||
'workspace' => $workspace,
|
||||
'post' => $post,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, Post $post): Response|RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
|
@ -225,13 +203,6 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
|
|||
abort(404);
|
||||
}
|
||||
|
||||
if ($post->status === PostStatus::Published) {
|
||||
session()->flash('flash.banner', 'Published posts cannot be edited.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
|
||||
$post->load(['postPlatforms.socialAccount', 'postPlatforms.media']);
|
||||
$socialAccounts = $workspace->socialAccounts;
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
|
|
@ -82,7 +83,11 @@ const emit = defineEmits<{
|
|||
|
||||
<!-- Post Content -->
|
||||
<div class="mt-1">
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-[15px] text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
|
|
@ -125,6 +130,7 @@ const emit = defineEmits<{
|
|||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
|
@ -165,8 +171,8 @@ const emit = defineEmits<{
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count -->
|
||||
<div class="border-t border-gray-200 dark:border-[#2e3f50] px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#1a2634]">
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-[#2e3f50] px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#1a2634]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
|
|
@ -75,7 +76,11 @@ const emit = defineEmits<{
|
|||
|
||||
<!-- Content -->
|
||||
<div class="mt-2">
|
||||
<div v-if="props.readonly" class="w-full min-h-[100px] text-[15px] text-gray-900 dark:text-[#e4e6eb] whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[100px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-[#e4e6eb] resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
|
|
@ -120,6 +125,7 @@ const emit = defineEmits<{
|
|||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
|
@ -174,8 +180,8 @@ const emit = defineEmits<{
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count -->
|
||||
<div class="px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#3a3b3c]">
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#3a3b3c]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
|
@ -72,7 +73,7 @@ const emit = defineEmits<{
|
|||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Content Type Selector -->
|
||||
<div v-if="hasMultipleContentTypes" class="flex items-center justify-center gap-2">
|
||||
<div v-if="hasMultipleContentTypes && !props.readonly" class="flex items-center justify-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Type:</span>
|
||||
<Select
|
||||
:model-value="contentType"
|
||||
|
|
@ -114,6 +115,7 @@ const emit = defineEmits<{
|
|||
/>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-3 right-3 bg-black/70 text-white rounded-full p-2 z-20"
|
||||
|
|
@ -121,7 +123,7 @@ const emit = defineEmits<{
|
|||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<div v-else-if="!props.readonly" class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<IconVideo class="h-16 w-16 mb-2" />
|
||||
<p class="text-sm">Add a {{ isReel ? 'video' : 'photo or video' }}</p>
|
||||
<label class="mt-2 cursor-pointer text-blue-500 font-medium text-sm hover:text-blue-600">
|
||||
|
|
@ -135,6 +137,10 @@ const emit = defineEmits<{
|
|||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<IconVideo class="h-16 w-16 mb-2" />
|
||||
<p class="text-sm">No media</p>
|
||||
</div>
|
||||
|
||||
<!-- Overlay UI -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
|
|
@ -218,13 +224,17 @@ const emit = defineEmits<{
|
|||
<!-- Caption input for Reels -->
|
||||
<div v-if="isReel" class="mt-4 p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">Caption</label>
|
||||
<div v-if="props.readonly" class="w-full min-h-[80px] bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[80px] bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-2 focus:ring-pink-500 placeholder:text-gray-500"
|
||||
placeholder="Write a caption..."
|
||||
/>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div v-if="!props.readonly" class="flex items-center justify-between mt-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
|
|
@ -247,7 +257,7 @@ const emit = defineEmits<{
|
|||
<!-- Story note -->
|
||||
<div v-if="isStory" class="mt-4 p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Stories disappear after 24 hours</p>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div v-if="!props.readonly" class="flex items-center justify-between mt-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
|
|
@ -317,6 +327,7 @@ const emit = defineEmits<{
|
|||
/>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-3 right-3 bg-black/70 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
|
@ -324,7 +335,7 @@ const emit = defineEmits<{
|
|||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<div v-else-if="!props.readonly" class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<IconPhoto class="h-16 w-16 mb-2" />
|
||||
<p class="text-sm">Add a photo or video</p>
|
||||
<label class="mt-2 cursor-pointer text-blue-500 font-medium text-sm hover:text-blue-600">
|
||||
|
|
@ -338,6 +349,10 @@ const emit = defineEmits<{
|
|||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<IconPhoto class="h-16 w-16 mb-2" />
|
||||
<p class="text-sm">No media</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
|
|
@ -369,7 +384,11 @@ const emit = defineEmits<{
|
|||
<span class="font-semibold text-gray-900 dark:text-white mr-1">
|
||||
{{ socialAccount.username || socialAccount.display_name }}
|
||||
</span>
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
|
|
@ -383,8 +402,8 @@ const emit = defineEmits<{
|
|||
<p class="text-[10px] text-gray-500 uppercase tracking-wide">Just now</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer with char count -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
|
||||
<!-- Footer with char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
|
|
|
|||
|
|
@ -35,9 +35,10 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
|
|
@ -80,7 +81,11 @@ const emit = defineEmits<{
|
|||
|
||||
<!-- Content -->
|
||||
<div class="mt-3">
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[120px] bg-transparent border-0 p-0 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
|
|
@ -122,6 +127,7 @@ const emit = defineEmits<{
|
|||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
|
@ -178,8 +184,8 @@ const emit = defineEmits<{
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-800/50">
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
|
|
|
|||
|
|
@ -34,9 +34,10 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
|
|
@ -82,7 +83,11 @@ const emit = defineEmits<{
|
|||
|
||||
<!-- Post Content -->
|
||||
<div class="mt-2">
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-[15px] text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
|
|
@ -125,6 +130,7 @@ const emit = defineEmits<{
|
|||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
|
@ -171,8 +177,8 @@ const emit = defineEmits<{
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count -->
|
||||
<div class="border-t border-gray-200 dark:border-[#313543] px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#1f2128]">
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-[#313543] px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#1f2128]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
|
@ -82,7 +83,7 @@ const updateBoardId = (boardId: string) => {
|
|||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Settings Bar -->
|
||||
<div v-if="hasMultipleContentTypes || boards.length > 0" class="flex items-center justify-center gap-4 flex-wrap">
|
||||
<div v-if="(hasMultipleContentTypes || boards.length > 0) && !props.readonly" class="flex items-center justify-center gap-4 flex-wrap">
|
||||
<!-- Content Type Selector -->
|
||||
<div v-if="hasMultipleContentTypes" class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Type:</span>
|
||||
|
|
@ -157,6 +158,7 @@ const updateBoardId = (boardId: string) => {
|
|||
/>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 opacity-0 hover:opacity-100 transition-opacity"
|
||||
|
|
@ -184,6 +186,7 @@ const updateBoardId = (boardId: string) => {
|
|||
class="w-full aspect-[2/3] object-cover"
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 opacity-0 hover:opacity-100 transition-opacity"
|
||||
|
|
@ -215,7 +218,7 @@ const updateBoardId = (boardId: string) => {
|
|||
</div>
|
||||
|
||||
<!-- Empty State - No Media -->
|
||||
<div v-else class="aspect-[2/3] bg-gray-100 dark:bg-gray-800 flex flex-col items-center justify-center">
|
||||
<div v-else-if="!props.readonly" class="aspect-[2/3] bg-gray-100 dark:bg-gray-800 flex flex-col items-center justify-center">
|
||||
<label class="cursor-pointer flex flex-col items-center gap-3 p-6 text-center">
|
||||
<div class="p-4 bg-gray-200 dark:bg-gray-700 rounded-full">
|
||||
<IconPhoto class="h-8 w-8 text-gray-500 dark:text-gray-400" />
|
||||
|
|
@ -238,12 +241,22 @@ const updateBoardId = (boardId: string) => {
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="aspect-[2/3] bg-gray-100 dark:bg-gray-800 flex flex-col items-center justify-center">
|
||||
<div class="p-4 bg-gray-200 dark:bg-gray-700 rounded-full">
|
||||
<IconPhoto class="h-8 w-8 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-3">No media</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin Content -->
|
||||
<div class="p-4">
|
||||
<!-- Description -->
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
|
|
@ -272,8 +285,8 @@ const updateBoardId = (boardId: string) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-800/50">
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
|
@ -123,6 +124,7 @@ const handleRemoveMedia = (mediaId: string) => {
|
|||
:is-valid="isValid"
|
||||
:validation-message="validationMessage"
|
||||
:is-uploading="isUploading"
|
||||
:readonly="readonly"
|
||||
@update:content="handleContentUpdate"
|
||||
@update:content-type="handleContentTypeUpdate"
|
||||
@update:meta="handleMetaUpdate"
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
|
|
@ -81,7 +82,11 @@ const emit = defineEmits<{
|
|||
|
||||
<!-- Thread Content -->
|
||||
<div class="mt-0.5">
|
||||
<div v-if="props.readonly" class="w-full min-h-[60px] text-[15px] text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
|
|
@ -122,6 +127,7 @@ const emit = defineEmits<{
|
|||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
|
@ -164,8 +170,8 @@ const emit = defineEmits<{
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
|
|
|
|||
|
|
@ -35,9 +35,10 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
|
|
@ -53,6 +54,7 @@ const emit = defineEmits<{
|
|||
<div v-if="media.length > 0 && media[0].type === 'video'" class="w-full h-full">
|
||||
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
|
||||
|
|
@ -63,6 +65,7 @@ const emit = defineEmits<{
|
|||
<div v-else-if="media.length > 0 && media[0].type === 'image'" class="w-full h-full">
|
||||
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
|
||||
|
|
@ -70,7 +73,7 @@ const emit = defineEmits<{
|
|||
<IconX class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-gray-900 text-gray-400">
|
||||
<div v-else-if="!props.readonly" class="w-full h-full flex flex-col items-center justify-center bg-gray-900 text-gray-400">
|
||||
<IconVideo class="h-20 w-20 mb-4" />
|
||||
<p class="text-sm mb-2">Add a video</p>
|
||||
<label class="cursor-pointer bg-[#fe2c55] text-white px-6 py-2 rounded-full font-semibold text-sm hover:bg-[#e91e4f] transition-colors">
|
||||
|
|
@ -84,6 +87,10 @@ const emit = defineEmits<{
|
|||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-gray-900 text-gray-400">
|
||||
<IconVideo class="h-20 w-20 mb-4" />
|
||||
<p class="text-sm">No media</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlay at bottom -->
|
||||
|
|
@ -154,7 +161,11 @@ const emit = defineEmits<{
|
|||
<div class="absolute left-3 right-16 bottom-4 text-white">
|
||||
<p class="font-semibold text-sm">@{{ socialAccount.username || socialAccount.display_name }}</p>
|
||||
<div class="mt-1">
|
||||
<div v-if="props.readonly" class="w-full min-h-[40px] max-h-[80px] text-sm text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[40px] max-h-[80px] bg-transparent border-0 p-0 text-sm text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
|
|
@ -167,8 +178,8 @@ const emit = defineEmits<{
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with char count -->
|
||||
<div class="absolute top-0 left-0 right-0 p-3 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent">
|
||||
<!-- Footer with char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="absolute top-0 left-0 right-0 p-3 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-500/30 text-green-300' : 'bg-red-500/30 text-red-300'"
|
||||
|
|
|
|||
|
|
@ -35,9 +35,10 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
|
|
@ -83,7 +84,11 @@ const emit = defineEmits<{
|
|||
|
||||
<!-- Tweet Content -->
|
||||
<div class="mt-1">
|
||||
<div v-if="props.readonly" class="w-full min-h-[80px] text-[15px] text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[80px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
|
||||
|
|
@ -125,6 +130,7 @@ const emit = defineEmits<{
|
|||
playsinline
|
||||
/>
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', item.id)"
|
||||
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
|
@ -180,8 +186,8 @@ const emit = defineEmits<{
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
|
||||
<!-- Footer with upload and char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
|
|
|
|||
|
|
@ -34,9 +34,10 @@ interface Props {
|
|||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
isUploading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [value: string];
|
||||
|
|
@ -52,6 +53,7 @@ const emit = defineEmits<{
|
|||
<div v-if="media.length > 0 && media[0].type === 'video'" class="w-full h-full">
|
||||
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
|
||||
|
|
@ -62,6 +64,7 @@ const emit = defineEmits<{
|
|||
<div v-else-if="media.length > 0 && media[0].type === 'image'" class="w-full h-full">
|
||||
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
|
||||
<button
|
||||
v-if="!props.readonly"
|
||||
type="button"
|
||||
@click="emit('remove-media', media[0].id)"
|
||||
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
|
||||
|
|
@ -69,7 +72,7 @@ const emit = defineEmits<{
|
|||
<IconX class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-[#0f0f0f] text-gray-400">
|
||||
<div v-else-if="!props.readonly" class="w-full h-full flex flex-col items-center justify-center bg-[#0f0f0f] text-gray-400">
|
||||
<IconVideo class="h-20 w-20 mb-4" />
|
||||
<p class="text-sm mb-2">Add a Short video</p>
|
||||
<label class="cursor-pointer bg-[#ff0000] text-white px-6 py-2 rounded-full font-semibold text-sm hover:bg-[#cc0000] transition-colors">
|
||||
|
|
@ -83,6 +86,10 @@ const emit = defineEmits<{
|
|||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-[#0f0f0f] text-gray-400">
|
||||
<IconVideo class="h-20 w-20 mb-4" />
|
||||
<p class="text-sm">No media</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlay at bottom -->
|
||||
|
|
@ -161,7 +168,11 @@ const emit = defineEmits<{
|
|||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="props.readonly" class="w-full min-h-[40px] max-h-[80px] text-sm text-white whitespace-pre-wrap">
|
||||
{{ content || 'No content' }}
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:value="content"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
class="w-full min-h-[40px] max-h-[80px] bg-transparent border-0 p-0 text-sm text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
|
||||
|
|
@ -177,8 +188,8 @@ const emit = defineEmits<{
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with char count -->
|
||||
<div class="absolute top-4 right-3 flex items-center gap-2">
|
||||
<!-- Footer with char count (only in edit mode) -->
|
||||
<div v-if="!props.readonly" class="absolute top-4 right-3 flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="isValid ? 'bg-green-500/30 text-green-300' : 'bg-red-500/30 text-red-300'"
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { store } from '@/routes/password/confirm';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Confirm your password"
|
||||
description="This is a secure area of the application. Please confirm your password before continuing."
|
||||
>
|
||||
<Head title="Confirm password" />
|
||||
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
reset-on-success
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="processing"
|
||||
data-test="confirm-password-button"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Confirm Password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
|
@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input';
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { update } from '@/routes/password';
|
||||
import { store } from '@/routes/password';
|
||||
|
||||
const props = defineProps<{
|
||||
token: string;
|
||||
|
|
@ -26,7 +26,7 @@ const inputEmail = ref(props.email);
|
|||
<Head title="Reset password" />
|
||||
|
||||
<Form
|
||||
v-bind="update.form()"
|
||||
v-bind="store.form()"
|
||||
:transform="(data) => ({ ...data, token, email })"
|
||||
:reset-on-success="['password', 'password_confirmation']"
|
||||
v-slot="{ errors, processing }"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||
import dayjs from '@/dayjs';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { calendar } from '@/routes';
|
||||
import { create as createPost, edit as editPost, show as showPost } from '@/routes/posts';
|
||||
import { create as createPost, edit as editPost } from '@/routes/posts';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
|
||||
interface PostPlatform {
|
||||
|
|
@ -179,9 +179,7 @@ const getPlatformLogo = (platform: string): string => {
|
|||
};
|
||||
|
||||
const getPostUrl = (post: Post): string => {
|
||||
return post.status === 'draft' || post.status === 'scheduled'
|
||||
? editPost.url(post.id)
|
||||
: showPost.url(post.id);
|
||||
return editPost.url(post.id);
|
||||
};
|
||||
|
||||
const formatTime = (scheduledAt: string): string => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { useEcho } from '@laravel/echo-vue';
|
||||
import axios from 'axios';
|
||||
import { Clock, AlertCircle, Trash2, Send, Link2, MoreHorizontal, Plus } from 'lucide-vue-next';
|
||||
import { Clock, AlertCircle, Trash2, Send, Link2, MoreHorizontal, Plus, CheckCircle, ExternalLink, Loader2 } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
import { store as storeMedia, storeChunked as storeMediaChunked, destroy as destroyMedia, duplicate as duplicateMedia } from '@/actions/App/Http/Controllers/MediaController';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import DatePicker from '@/components/DatePicker.vue';
|
||||
|
|
@ -62,6 +65,9 @@ interface PostPlatform {
|
|||
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>;
|
||||
|
|
@ -83,6 +89,7 @@ interface Post {
|
|||
status: string;
|
||||
synced: boolean;
|
||||
scheduled_at: string | null;
|
||||
published_at: string | null;
|
||||
post_platforms: PostPlatform[];
|
||||
}
|
||||
|
||||
|
|
@ -109,31 +116,57 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItemType[] = [
|
||||
// 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));
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
|
||||
{ title: 'Calendar', href: calendar.url() },
|
||||
{ title: 'Edit Post', href: '#' },
|
||||
];
|
||||
{ title: isReadOnly.value ? 'View Post' : 'Edit Post', href: '#' },
|
||||
]);
|
||||
|
||||
// State
|
||||
const selectedPlatformIds = ref<string[]>(
|
||||
props.post.post_platforms.filter(pp => pp.enabled).map(pp => pp.id)
|
||||
post.value.post_platforms.filter(pp => pp.enabled).map(pp => pp.id)
|
||||
);
|
||||
const synced = ref(props.post.synced);
|
||||
const enabledPlatforms = props.post.post_platforms.filter(pp => pp.enabled);
|
||||
const synced = ref(post.value.synced);
|
||||
const enabledPlatforms = post.value.post_platforms.filter(pp => pp.enabled);
|
||||
const globalContent = ref(
|
||||
enabledPlatforms[0]?.content || props.post.post_platforms[0]?.content || ''
|
||||
enabledPlatforms[0]?.content || post.value.post_platforms[0]?.content || ''
|
||||
);
|
||||
const platformContents = ref<Record<string, string>>(
|
||||
Object.fromEntries(props.post.post_platforms.map(pp => [pp.id, pp.content]))
|
||||
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.content]))
|
||||
);
|
||||
const platformMedia = ref<Record<string, MediaItem[]>>(
|
||||
Object.fromEntries(props.post.post_platforms.map(pp => [pp.id, pp.media || []]))
|
||||
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.media || []]))
|
||||
);
|
||||
const platformContentTypes = ref<Record<string, string>>(
|
||||
Object.fromEntries(props.post.post_platforms.map(pp => [pp.id, pp.content_type || getDefaultContentType(pp.platform)]))
|
||||
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(props.post.post_platforms.map(pp => [pp.id, pp.meta || {}]))
|
||||
Object.fromEntries(post.value.post_platforms.map(pp => [pp.id, pp.meta || {}]))
|
||||
);
|
||||
const isUploading = ref<Record<string, boolean>>({});
|
||||
const isSubmitting = ref(false);
|
||||
|
|
@ -143,7 +176,7 @@ const showDisableSyncDialog = ref(false);
|
|||
const showPlatformsDialog = ref(false);
|
||||
|
||||
// Active tab - first selected platform
|
||||
const activeTabId = ref<string>(selectedPlatformIds.value[0] || props.post.post_platforms[0]?.id || '');
|
||||
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) => {
|
||||
|
|
@ -154,10 +187,10 @@ watch(selectedPlatformIds, (newIds) => {
|
|||
|
||||
// Convert UTC to workspace timezone for display
|
||||
const getLocalSchedule = () => {
|
||||
if (!props.post.scheduled_at) {
|
||||
if (!post.value.scheduled_at) {
|
||||
return { date: '', time: '09:00' };
|
||||
}
|
||||
const local = dayjs.utc(props.post.scheduled_at).tz(props.workspace.timezone);
|
||||
const local = dayjs.utc(post.value.scheduled_at).tz(props.workspace.timezone);
|
||||
return {
|
||||
date: local.format('YYYY-MM-DD'),
|
||||
time: local.format('HH:mm'),
|
||||
|
|
@ -207,6 +240,23 @@ const getPlatformLabel = (platform: string): string => {
|
|||
return labels[platform] || platform;
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
const configs: Record<string, { label: string; color: string; icon: any }> = {
|
||||
'draft': { label: 'Draft', color: 'bg-gray-100 text-gray-800', icon: Clock },
|
||||
'scheduled': { label: 'Scheduled', color: 'bg-blue-100 text-blue-800', icon: Clock },
|
||||
'publishing': { label: 'Publishing', color: 'bg-yellow-100 text-yellow-800', icon: Loader2 },
|
||||
'published': { label: 'Published', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
'partially_published': { label: 'Partially Published', color: 'bg-orange-100 text-orange-800', icon: AlertCircle },
|
||||
'failed': { label: 'Failed', color: 'bg-red-100 text-red-800', icon: AlertCircle },
|
||||
};
|
||||
return configs[status] || configs['draft'];
|
||||
};
|
||||
|
||||
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 contentTypeOptions: Record<string, ContentTypeOption[]> = {
|
||||
'instagram': [
|
||||
|
|
@ -308,7 +358,7 @@ const setContent = (platformId: string, value: string) => {
|
|||
|
||||
// Active platform
|
||||
const activePlatform = computed(() => {
|
||||
return props.post.post_platforms.find(pp => pp.id === activeTabId.value);
|
||||
return post.value.post_platforms.find(pp => pp.id === activeTabId.value);
|
||||
});
|
||||
|
||||
// Current platform media (computed for proper reactivity)
|
||||
|
|
@ -330,7 +380,7 @@ const setContentType = (platformId: string, value: string) => {
|
|||
|
||||
// Selected platforms (for tabs)
|
||||
const selectedPlatforms = computed(() => {
|
||||
return props.post.post_platforms.filter(pp => selectedPlatformIds.value.includes(pp.id));
|
||||
return post.value.post_platforms.filter(pp => selectedPlatformIds.value.includes(pp.id));
|
||||
});
|
||||
|
||||
// Other selected platforms (for sync label)
|
||||
|
|
@ -381,7 +431,7 @@ const confirmDisableSync = () => {
|
|||
const contentValidation = computed(() => {
|
||||
const results: Record<string, { valid: boolean; message: string; charCount: number; maxLength: number }> = {};
|
||||
|
||||
for (const pp of props.post.post_platforms) {
|
||||
for (const pp of post.value.post_platforms) {
|
||||
if (!selectedPlatformIds.value.includes(pp.id)) continue;
|
||||
|
||||
const config = getConfig(pp);
|
||||
|
|
@ -414,7 +464,7 @@ const contentValidation = computed(() => {
|
|||
const mediaValidation = computed(() => {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const pp of props.post.post_platforms) {
|
||||
for (const pp of post.value.post_platforms) {
|
||||
if (!selectedPlatformIds.value.includes(pp.id)) continue;
|
||||
|
||||
const config = getConfig(pp);
|
||||
|
|
@ -464,7 +514,7 @@ const togglePlatform = (platformId: string) => {
|
|||
const singleMediaPlatforms = ['tiktok', 'youtube', 'instagram'];
|
||||
|
||||
const isSingleMediaPlatform = (platformId: string): boolean => {
|
||||
const platform = props.post.post_platforms.find(pp => pp.id === platformId);
|
||||
const platform = post.value.post_platforms.find(pp => pp.id === platformId);
|
||||
return platform ? singleMediaPlatforms.includes(platform.platform) : false;
|
||||
};
|
||||
|
||||
|
|
@ -594,7 +644,7 @@ const removeMedia = async (postPlatformId: string, mediaId: string) => {
|
|||
};
|
||||
|
||||
const getSubmitData = () => {
|
||||
const platforms = props.post.post_platforms
|
||||
const platforms = post.value.post_platforms
|
||||
.filter(pp => selectedPlatformIds.value.includes(pp.id))
|
||||
.map(pp => ({
|
||||
id: pp.id,
|
||||
|
|
@ -612,14 +662,14 @@ const getSubmitData = () => {
|
|||
};
|
||||
|
||||
const save = () => {
|
||||
if (isSubmitting.value) return;
|
||||
if (isSubmitting.value || isReadOnly.value) return;
|
||||
|
||||
const { platforms, scheduled_at } = getSubmitData();
|
||||
|
||||
isSubmitting.value = true;
|
||||
|
||||
router.put(updatePost.url(props.post.id), {
|
||||
status: props.post.status,
|
||||
router.put(updatePost.url(post.value.id), {
|
||||
status: post.value.status,
|
||||
synced: synced.value,
|
||||
scheduled_at,
|
||||
platforms,
|
||||
|
|
@ -632,13 +682,13 @@ const save = () => {
|
|||
};
|
||||
|
||||
const submit = (status: string = 'scheduled') => {
|
||||
if (isSubmitting.value) return;
|
||||
if (isSubmitting.value || isReadOnly.value) return;
|
||||
|
||||
const { platforms, scheduled_at } = getSubmitData();
|
||||
|
||||
isSubmitting.value = true;
|
||||
|
||||
router.put(updatePost.url(props.post.id), {
|
||||
router.put(updatePost.url(post.value.id), {
|
||||
status,
|
||||
synced: synced.value,
|
||||
scheduled_at,
|
||||
|
|
@ -651,14 +701,15 @@ const submit = (status: string = 'scheduled') => {
|
|||
};
|
||||
|
||||
const deletePost = () => {
|
||||
if (isReadOnly.value) return;
|
||||
deleteModal.value?.open({
|
||||
url: destroyPost.url(props.post.id),
|
||||
url: destroyPost.url(post.value.id),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Edit Post" />
|
||||
<Head :title="isReadOnly ? 'View Post' : 'Edit Post'" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex flex-col h-[calc(100vh-4rem)]">
|
||||
|
|
@ -666,6 +717,12 @@ const deletePost = () => {
|
|||
<div class="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>
|
||||
|
||||
<Tabs v-model="activeTabId" v-if="selectedPlatforms.length > 0">
|
||||
<TabsList class="h-auto p-1 gap-1">
|
||||
<TooltipProvider v-for="pp in selectedPlatforms" :key="pp.id">
|
||||
|
|
@ -677,9 +734,19 @@ const deletePost = () => {
|
|||
:alt="getPlatformLabel(pp.platform)"
|
||||
class="h-6 w-6 rounded"
|
||||
/>
|
||||
<!-- Validation indicator -->
|
||||
<!-- Status indicator (read-only) or Validation indicator (edit mode) -->
|
||||
<span
|
||||
v-if="contentValidation[pp.id]"
|
||||
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-gray-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'"
|
||||
/>
|
||||
|
|
@ -687,14 +754,15 @@ const deletePost = () => {
|
|||
</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>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<!-- Platforms Menu Button -->
|
||||
<TooltipProvider>
|
||||
<!-- Platforms Menu Button (only in edit mode) -->
|
||||
<TooltipProvider v-if="!isReadOnly">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -711,19 +779,21 @@ const deletePost = () => {
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<span class="mx-2 text-muted-foreground">|</span>
|
||||
<template v-if="!isReadOnly">
|
||||
<span class="mx-2 text-muted-foreground">|</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="deletePost"
|
||||
class="p-2 rounded-lg hover:bg-muted/50 transition-colors text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="deletePost"
|
||||
class="p-2 rounded-lg hover:bg-muted/50 transition-colors text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-5 w-5" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Schedule & Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Schedule & Actions (only in edit mode) -->
|
||||
<div v-if="!isReadOnly" class="flex items-center gap-3">
|
||||
<!-- Date/Time Picker -->
|
||||
<div class="flex items-center gap-2">
|
||||
<DatePicker
|
||||
|
|
@ -774,13 +844,19 @@ const deletePost = () => {
|
|||
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">Scheduled: {{ formatDateTime(post.scheduled_at) }}</span>
|
||||
<span v-if="post.published_at">Published: {{ formatDateTime(post.published_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div v-if="activePlatform && selectedPlatformIds.length > 0" class="max-w-2xl mx-auto py-8 px-4">
|
||||
<!-- Sync Toggle -->
|
||||
<div v-if="selectedPlatformIds.length > 1" class="flex items-center justify-center gap-2 mb-6">
|
||||
<!-- Sync Toggle (only in edit mode) -->
|
||||
<div v-if="!isReadOnly && selectedPlatformIds.length > 1" class="flex items-center justify-center gap-2 mb-6">
|
||||
<div @click.capture="handleSyncClick">
|
||||
<Switch id="sync-content" v-model="synced" />
|
||||
</div>
|
||||
|
|
@ -801,6 +877,51 @@ const deletePost = () => {
|
|||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<img
|
||||
:src="getPlatformLogo(activePlatform.platform)"
|
||||
:alt="getPlatformLabel(activePlatform.platform)"
|
||||
class="h-8 w-8 rounded"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<Alert v-if="activePlatform.error_message" variant="destructive">
|
||||
<AlertCircle 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>
|
||||
</div>
|
||||
|
||||
<!-- Platform Preview -->
|
||||
<PlatformPreview
|
||||
:key="activePlatform.id"
|
||||
|
|
@ -817,6 +938,7 @@ const deletePost = () => {
|
|||
:is-valid="contentValidation[activePlatform.id]?.valid ?? false"
|
||||
:validation-message="contentValidation[activePlatform.id]?.message || ''"
|
||||
:is-uploading="isUploading[activePlatform.id]"
|
||||
:readonly="isReadOnly"
|
||||
@update:content="setContent(activePlatform.id, $event)"
|
||||
@update:content-type="setContentType(activePlatform.id, $event)"
|
||||
@update:meta="platformMeta[activePlatform.id] = $event"
|
||||
|
|
@ -824,8 +946,8 @@ const deletePost = () => {
|
|||
@remove-media="removeMedia(activePlatform.id, $event)"
|
||||
/>
|
||||
|
||||
<!-- Media Validation Errors -->
|
||||
<Alert v-if="mediaValidation.length > 0" variant="destructive" class="mt-4">
|
||||
<!-- Media Validation Errors (only in edit mode) -->
|
||||
<Alert v-if="!isReadOnly && mediaValidation.length > 0" variant="destructive" class="mt-4">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<ul class="list-disc list-inside">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import dayjs from '@/dayjs';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { calendar } from '@/routes';
|
||||
import { index as postsIndex, create as createPost, show as showPost, edit as editPost, destroy as destroyPost } from '@/actions/App/Http/Controllers/PostController';
|
||||
import { index as postsIndex, create as createPost, edit as editPost, destroy as destroyPost } from '@/actions/App/Http/Controllers/PostController';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
|
||||
interface SocialAccount {
|
||||
|
|
@ -232,7 +232,7 @@ const handleDelete = (post: Post) => {
|
|||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Link :href="showPost.url(post.id)">
|
||||
<Link :href="editPost.url(post.id)">
|
||||
<Button variant="ghost" size="icon">
|
||||
<IconEye class="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,312 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { useEcho } from '@laravel/echo-vue';
|
||||
import { Clock, CheckCircle, AlertCircle, Loader2, FileText, ExternalLink, Pencil } from 'lucide-vue-next';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import dayjs from '@/dayjs';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { calendar } from '@/routes';
|
||||
import { edit as editPost } from '@/routes/posts';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
display_name: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface PostPlatform {
|
||||
id: string;
|
||||
social_account_id: string;
|
||||
enabled: boolean;
|
||||
platform: string;
|
||||
content: string;
|
||||
status: string;
|
||||
platform_url: string | null;
|
||||
error_message: string | null;
|
||||
published_at: string | null;
|
||||
social_account: SocialAccount;
|
||||
media: MediaItem[];
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
status: string;
|
||||
scheduled_at: string | null;
|
||||
published_at: string | null;
|
||||
post_platforms: PostPlatform[];
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
post: Post;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// 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;
|
||||
},
|
||||
);
|
||||
|
||||
const breadcrumbs: BreadcrumbItemType[] = [
|
||||
{ title: 'Calendar', href: calendar.url() },
|
||||
{ title: 'Post', href: '#' },
|
||||
];
|
||||
|
||||
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',
|
||||
};
|
||||
return logos[platform] || '/images/accounts/default.png';
|
||||
};
|
||||
|
||||
const getPlatformLabel = (platform: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
'linkedin': 'LinkedIn',
|
||||
'linkedin-page': 'LinkedIn Page',
|
||||
'x': 'X',
|
||||
'tiktok': 'TikTok',
|
||||
'youtube': 'YouTube Shorts',
|
||||
'facebook': 'Facebook Page',
|
||||
'instagram': 'Instagram',
|
||||
'threads': 'Threads',
|
||||
};
|
||||
return labels[platform] || platform;
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
const configs: Record<string, { label: string; color: string; icon: any }> = {
|
||||
'draft': { label: 'Draft', color: 'bg-gray-100 text-gray-800', icon: FileText },
|
||||
'scheduled': { label: 'Scheduled', color: 'bg-blue-100 text-blue-800', icon: Clock },
|
||||
'publishing': { label: 'Publishing', color: 'bg-yellow-100 text-yellow-800', icon: Loader2 },
|
||||
'published': { label: 'Published', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
'partially_published': { label: 'Partially Published', color: 'bg-orange-100 text-orange-800', icon: AlertCircle },
|
||||
'failed': { label: 'Failed', color: 'bg-red-100 text-red-800', icon: AlertCircle },
|
||||
};
|
||||
return configs[status] || configs['draft'];
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
const canEdit = computed(() => ['draft', 'scheduled'].includes(post.value.status));
|
||||
const enabledPlatforms = computed(() => post.value.post_platforms.filter(pp => pp.enabled));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Post Details" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Post Details</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Created by {{ post.user.name }}
|
||||
</p>
|
||||
</div>
|
||||
<Link v-if="canEdit" :href="editPost.url(post.id)">
|
||||
<Button>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Edit Post
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Platform Posts -->
|
||||
<Card v-for="pp in enabledPlatforms" :key="pp.id">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<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-1 -right-1 h-5 w-5 rounded-full ring-2 ring-background"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle class="text-base">{{ pp.social_account.display_name }}</CardTitle>
|
||||
<CardDescription>{{ getPlatformLabel(pp.platform) }}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :class="getStatusConfig(pp.status).color">
|
||||
<component :is="getStatusConfig(pp.status).icon" class="mr-1 h-3 w-3" />
|
||||
{{ getStatusConfig(pp.status).label }}
|
||||
</Badge>
|
||||
<a
|
||||
v-if="pp.platform_url"
|
||||
:href="pp.platform_url"
|
||||
target="_blank"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Content -->
|
||||
<div class="whitespace-pre-wrap text-sm">
|
||||
{{ pp.content || 'No content' }}
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div v-if="pp.media.length > 0" class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="media in pp.media"
|
||||
:key="media.id"
|
||||
class="w-24 h-24 rounded-lg overflow-hidden border bg-muted"
|
||||
>
|
||||
<img
|
||||
v-if="media.type === 'image'"
|
||||
:src="media.url"
|
||||
:alt="media.original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-xs text-muted-foreground">
|
||||
{{ media.type }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="pp.error_message" class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
|
||||
<p class="font-medium">Error:</p>
|
||||
<p class="mt-1">{{ pp.error_message }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Published Info -->
|
||||
<div v-if="pp.published_at" class="text-xs text-muted-foreground">
|
||||
Published {{ formatDateTime(pp.published_at) }}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="enabledPlatforms.length === 0">
|
||||
<CardContent class="py-8 text-center text-muted-foreground">
|
||||
No platforms selected for this post.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :class="getStatusConfig(post.status).color" class="text-sm">
|
||||
<component :is="getStatusConfig(post.status).icon" class="mr-1 h-4 w-4" />
|
||||
{{ getStatusConfig(post.status).label }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Scheduled</span>
|
||||
<span>{{ formatDateTime(post.scheduled_at) }}</span>
|
||||
</div>
|
||||
<div v-if="post.published_at" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Published</span>
|
||||
<span>{{ formatDateTime(post.published_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Platforms</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="pp in enabledPlatforms"
|
||||
:key="pp.id"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<img :src="getPlatformLogo(pp.platform)" class="h-4 w-4" />
|
||||
<span>{{ pp.social_account.display_name }}</span>
|
||||
</div>
|
||||
<Badge variant="outline" :class="getStatusConfig(pp.status).color" class="text-xs">
|
||||
{{ getStatusConfig(pp.status).label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -112,7 +112,6 @@
|
|||
Route::get('posts/create', [PostController::class, 'create'])->name('posts.create');
|
||||
Route::get('posts/{status?}', [PostController::class, 'index'])->name('posts.index')->where('status', 'draft|scheduled|published');
|
||||
Route::post('posts', [PostController::class, 'store'])->name('posts.store');
|
||||
Route::get('posts/{post}', [PostController::class, 'show'])->name('posts.show');
|
||||
Route::get('posts/{post}/edit', [PostController::class, 'edit'])->name('posts.edit');
|
||||
Route::put('posts/{post}', [PostController::class, 'update'])->name('posts.update');
|
||||
Route::delete('posts/{post}', [PostController::class, 'destroy'])->name('posts.destroy');
|
||||
|
|
|
|||
|
|
@ -152,46 +152,23 @@
|
|||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('edit post redirects to show for published posts', function () {
|
||||
test('edit post shows published posts in read-only mode', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => PostStatus::Published,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('posts.edit', $post));
|
||||
|
||||
$response->assertRedirect(route('posts.show', $post));
|
||||
});
|
||||
|
||||
// Show tests
|
||||
test('show post requires authentication', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('posts.show', $post));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('show post displays post details', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
PostPlatform::factory()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('posts.show', $post));
|
||||
$response = $this->actingAs($this->user)->get(route('posts.edit', $post));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('posts/Show')
|
||||
->component('posts/Edit')
|
||||
->has('post')
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue