feat: improvements on ui

This commit is contained in:
Paulo Castellano 2026-01-20 19:42:50 -03:00
parent 82316df470
commit 925540b93e
20 changed files with 306 additions and 513 deletions

View file

@ -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;

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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'"

View file

@ -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"

View file

@ -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'"

View file

@ -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>

View file

@ -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 }"

View file

@ -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 => {

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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');

View file

@ -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')
);
});