feat: improvements on ui and i18n

This commit is contained in:
Paulo Castellano 2026-01-21 21:33:31 -03:00
parent 24b363a091
commit 2834fdd81a
14 changed files with 494 additions and 149 deletions

View file

@ -47,7 +47,7 @@ public function store(Request $request): RedirectResponse
$isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/');
$user = DB::transaction(function () use ($request, $isInviteRegistration) {
$defaultLanguage = Language::where('code', 'en-US')->first();
$defaultLanguage = Language::where('code', 'en')->first();
$user = User::create([
'name' => $request->name,

View file

@ -303,7 +303,7 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse
session()->flash('flash.banner', 'Post is being published!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('posts.show', $post);
return redirect()->route('posts.edit', $post);
}
// Redirect to show page for schedule action
@ -311,7 +311,7 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse
session()->flash('flash.banner', 'Post scheduled successfully!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('posts.show', $post);
return redirect()->route('posts.edit', $post);
}
// Stay on edit page for save action
@ -340,6 +340,6 @@ public function destroy(Request $request, Post $post): RedirectResponse
session()->flash('flash.banner', 'Post deleted successfully!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('calendar');
return back();
}
}

View file

@ -59,6 +59,9 @@ public function updateLanguage(Request $request): RedirectResponse
'language_id' => $request->language_id,
]);
// Refresh the user model to clear cached language relationship
$request->user()->refresh();
session()->flash('flash.banner', 'Language updated successfully!');
session()->flash('flash.bannerStyle', 'success');

View file

@ -6,7 +6,7 @@
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Language>
*/
*/
class LanguageFactory extends Factory
{
/**
@ -18,7 +18,7 @@ public function definition(): array
{
return [
'name' => 'English',
'code' => 'en-US',
'code' => 'en',
];
}
}

View file

@ -12,6 +12,11 @@
'delete_confirm' => 'Are you sure you want to delete this post?',
'by' => 'by',
'actions' => [
'view' => 'View post',
'delete' => 'Delete post',
],
'status' => [
'draft' => 'Draft',
'scheduled' => 'Scheduled',
@ -26,4 +31,159 @@
'scheduled' => 'Posts scheduled for publishing',
'published' => 'Posts already published',
],
'edit' => [
'title' => 'Edit Post',
'view_title' => 'View Post',
'manage_platforms' => 'Manage platforms',
'sync' => 'Sync',
'schedule' => 'Schedule',
'publish' => 'Publish',
'saving' => 'Saving...',
'saved' => 'Saved',
'scheduled_at' => 'Scheduled:',
'published_at' => 'Published:',
'media' => 'Media',
'caption' => 'Caption',
'no_caption' => 'No caption',
'no_content' => 'No content',
'empty_state' => [
'title' => 'No platforms selected',
'description' => 'Select at least one platform to create your post',
],
'delete_modal' => [
'title' => 'Delete Post',
'description' => 'Are you sure you want to delete this post? This action cannot be undone.',
'action' => 'Delete',
'cancel' => 'Cancel',
],
'sync_enable' => [
'title' => 'Discard text and sync with :platform?',
'description' => 'If you enable syncing, you\'ll lose all edits made specifically to other platforms.',
'confirm_question' => 'Are you sure you want to sync with the :platform version?',
'cancel' => 'Cancel',
'action' => 'Sync with :platform',
],
'sync_disable' => [
'title' => 'Disable sync?',
'description' => 'Each platform will keep its current content, but future edits will only apply to the platform you\'re editing.',
'customize_note' => 'You\'ll be able to customize the content for each platform individually.',
'cancel' => 'Cancel',
'action' => 'Disable sync',
],
'platforms_dialog' => [
'title' => 'Select Platforms',
'description' => 'Choose which platforms to publish this post to.',
],
'validation' => [
'select_board' => 'Select a board',
'images_not_supported' => 'Images not supported',
'videos_not_supported' => 'Videos not supported',
'max_images' => 'Max :count images',
'requires_media' => 'Requires media',
'exceeded' => ':count exceeded',
'does_not_support_images' => ':platform does not support images',
'supports_up_to_images' => ':platform supports up to :count images',
'does_not_support_videos' => ':platform does not support videos',
],
],
'content_types' => [
'instagram_feed' => [
'label' => 'Feed Post',
'description' => 'Appears in your feed and profile',
],
'instagram_reel' => [
'label' => 'Reel',
'description' => 'Short video up to 90 seconds',
],
'instagram_story' => [
'label' => 'Story',
'description' => 'Disappears after 24 hours',
],
'linkedin_post' => [
'label' => 'Post',
'description' => 'Standard post with text and media',
],
'linkedin_carousel' => [
'label' => 'Carousel',
'description' => 'Swipeable images',
],
'linkedin_page_post' => [
'label' => 'Post',
'description' => 'Standard post with text and media',
],
'linkedin_page_carousel' => [
'label' => 'Carousel',
'description' => 'Swipeable images',
],
'facebook_post' => [
'label' => 'Post',
'description' => 'Standard post on your page',
],
'facebook_reel' => [
'label' => 'Reel',
'description' => 'Short video up to 90 seconds',
],
'facebook_story' => [
'label' => 'Story',
'description' => 'Disappears after 24 hours',
],
'tiktok_video' => [
'label' => 'Video',
'description' => 'Short-form video content',
],
'youtube_short' => [
'label' => 'Short',
'description' => 'Vertical video up to 60 seconds',
],
'x_post' => [
'label' => 'Post',
'description' => 'Tweet with text and media',
],
'threads_post' => [
'label' => 'Post',
'description' => 'Text post with optional media',
],
'pinterest_pin' => [
'label' => 'Pin',
'description' => 'Image pin with link',
],
'pinterest_video_pin' => [
'label' => 'Video Pin',
'description' => 'Video content',
],
'pinterest_carousel' => [
'label' => 'Carousel',
'description' => '2-5 images',
],
'bluesky_post' => [
'label' => 'Post',
'description' => 'Text post with optional images',
],
'mastodon_post' => [
'label' => 'Post',
'description' => 'Text post with optional media',
],
],
'platforms' => [
'linkedin' => 'LinkedIn',
'linkedin-page' => 'LinkedIn Page',
'x' => 'X',
'tiktok' => 'TikTok',
'youtube' => 'YouTube Shorts',
'facebook' => 'Facebook Page',
'instagram' => 'Instagram',
'threads' => 'Threads',
'pinterest' => 'Pinterest',
'bluesky' => 'Bluesky',
'mastodon' => 'Mastodon',
],
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,11 @@
'delete_confirm' => 'Tem certeza que deseja excluir este post?',
'by' => 'por',
'actions' => [
'view' => 'Ver post',
'delete' => 'Excluir post',
],
'status' => [
'draft' => 'Rascunho',
'scheduled' => 'Agendado',
@ -26,4 +31,159 @@
'scheduled' => 'Posts agendados para publicação',
'published' => 'Posts já publicados',
],
'edit' => [
'title' => 'Editar Post',
'view_title' => 'Visualizar Post',
'manage_platforms' => 'Gerenciar plataformas',
'sync' => 'Sincronizar',
'schedule' => 'Agendar',
'publish' => 'Publicar',
'saving' => 'Salvando...',
'saved' => 'Salvo',
'scheduled_at' => 'Agendado:',
'published_at' => 'Publicado:',
'media' => 'Mídia',
'caption' => 'Legenda',
'no_caption' => 'Sem legenda',
'no_content' => 'Sem conteúdo',
'empty_state' => [
'title' => 'Nenhuma plataforma selecionada',
'description' => 'Selecione pelo menos uma plataforma para criar seu post',
],
'delete_modal' => [
'title' => 'Excluir Post',
'description' => 'Tem certeza que deseja excluir este post? Esta ação não pode ser desfeita.',
'action' => 'Excluir',
'cancel' => 'Cancelar',
],
'sync_enable' => [
'title' => 'Descartar texto e sincronizar com :platform?',
'description' => 'Se você ativar a sincronização, perderá todas as edições feitas especificamente para outras plataformas.',
'confirm_question' => 'Tem certeza que deseja sincronizar com a versão do :platform?',
'cancel' => 'Cancelar',
'action' => 'Sincronizar com :platform',
],
'sync_disable' => [
'title' => 'Desativar sincronização?',
'description' => 'Cada plataforma manterá seu conteúdo atual, mas edições futuras serão aplicadas apenas à plataforma que você estiver editando.',
'customize_note' => 'Você poderá personalizar o conteúdo para cada plataforma individualmente.',
'cancel' => 'Cancelar',
'action' => 'Desativar sincronização',
],
'platforms_dialog' => [
'title' => 'Selecionar Plataformas',
'description' => 'Escolha em quais plataformas publicar este post.',
],
'validation' => [
'select_board' => 'Selecione uma pasta',
'images_not_supported' => 'Imagens não suportadas',
'videos_not_supported' => 'Vídeos não suportados',
'max_images' => 'Máx :count imagens',
'requires_media' => 'Requer mídia',
'exceeded' => ':count excedido',
'does_not_support_images' => ':platform não suporta imagens',
'supports_up_to_images' => ':platform suporta até :count imagens',
'does_not_support_videos' => ':platform não suporta vídeos',
],
],
'content_types' => [
'instagram_feed' => [
'label' => 'Post do Feed',
'description' => 'Aparece no seu feed e perfil',
],
'instagram_reel' => [
'label' => 'Reels',
'description' => 'Vídeo curto de até 90 segundos',
],
'instagram_story' => [
'label' => 'Story',
'description' => 'Desaparece após 24 horas',
],
'linkedin_post' => [
'label' => 'Post',
'description' => 'Post padrão com texto e mídia',
],
'linkedin_carousel' => [
'label' => 'Carrossel',
'description' => 'Imagens deslizáveis',
],
'linkedin_page_post' => [
'label' => 'Post',
'description' => 'Post padrão com texto e mídia',
],
'linkedin_page_carousel' => [
'label' => 'Carrossel',
'description' => 'Imagens deslizáveis',
],
'facebook_post' => [
'label' => 'Post',
'description' => 'Post padrão na sua página',
],
'facebook_reel' => [
'label' => 'Reels',
'description' => 'Vídeo curto de até 90 segundos',
],
'facebook_story' => [
'label' => 'Story',
'description' => 'Desaparece após 24 horas',
],
'tiktok_video' => [
'label' => 'Vídeo',
'description' => 'Conteúdo de vídeo curto',
],
'youtube_short' => [
'label' => 'Short',
'description' => 'Vídeo vertical de até 60 segundos',
],
'x_post' => [
'label' => 'Post',
'description' => 'Tweet com texto e mídia',
],
'threads_post' => [
'label' => 'Post',
'description' => 'Post de texto com mídia opcional',
],
'pinterest_pin' => [
'label' => 'Pin',
'description' => 'Pin de imagem com link',
],
'pinterest_video_pin' => [
'label' => 'Pin de Vídeo',
'description' => 'Conteúdo em vídeo',
],
'pinterest_carousel' => [
'label' => 'Carrossel',
'description' => '2-5 imagens',
],
'bluesky_post' => [
'label' => 'Post',
'description' => 'Post de texto com imagens opcionais',
],
'mastodon_post' => [
'label' => 'Post',
'description' => 'Post de texto com mídia opcional',
],
],
'platforms' => [
'linkedin' => 'LinkedIn',
'linkedin-page' => 'Página do LinkedIn',
'x' => 'X',
'tiktok' => 'TikTok',
'youtube' => 'YouTube Shorts',
'facebook' => 'Página do Facebook',
'instagram' => 'Instagram',
'threads' => 'Threads',
'pinterest' => 'Pinterest',
'bluesky' => 'Bluesky',
'mastodon' => 'Mastodon',
],
];

View file

@ -24,12 +24,15 @@ createInertiaApp({
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
),
setup({ el, App, props, plugin }) {
// Get locale from shared Inertia props
const locale = (props.initialPage.props as { locale?: string })?.locale || 'en';
// Set dayjs locale based on user's language
const userLanguage = (props.initialPage.props as { auth?: { user?: { language?: { code?: string } } } })?.auth?.user?.language?.code;
dayjs.locale(userLanguage?.toLowerCase() || 'en');
dayjs.locale(locale.toLowerCase());
createApp({ render: () => h(App, props) })
.use(i18nVue, {
lang: locale,
resolve: async (lang: string) => {
const langs = import.meta.glob('../../lang/*.json');
return await langs[`../../lang/php_${lang}.json`]();

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { IconBrandDiscord, IconCalendar, IconCheck, IconSelector, IconFileText, IconHash, IconLogout, IconPlus, IconSettings, IconAffiliate, IconPencil, IconFileCheck, IconTag, IconUser, IconClock, IconMessageCircle, IconBell, IconBook, IconSun, IconMoon, IconDeviceDesktop, IconLanguage } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { loadLanguageAsync, trans } from 'laravel-vue-i18n';
import { Button } from '@/components/ui/button';
import { create as createPost } from '@/actions/App/Http/Controllers/PostController';
import { updateLanguage } from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { computed } from 'vue';
import dayjs from '@/dayjs';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
@ -169,8 +170,22 @@ function switchWorkspace(workspace: Workspace) {
}
function switchLanguage(languageId: string) {
const language = languages.value.find(l => l.id === languageId);
const languageCode = language?.code || 'en';
const previousLanguageCode = currentLanguage.value?.code || 'en';
// Set locales immediately before the request
loadLanguageAsync(languageCode);
dayjs.locale(languageCode.toLowerCase());
router.patch(updateLanguage.url(), { language_id: languageId }, {
preserveScroll: true,
preserveState: false,
onError: () => {
// Revert to previous locale if request fails
loadLanguageAsync(previousLanguageCode);
dayjs.locale(previousLanguageCode.toLowerCase());
},
});
}
@ -238,7 +253,8 @@ function handleLogout() {
<DropdownMenuSubTrigger>
<img v-if="currentWorkspace?.logo.url" :src="currentWorkspace.logo.url"
:alt="currentWorkspace.name" class="mr-2 size-4 rounded-full object-cover" />
<span>{{ currentWorkspace ? $t('sidebar.workspace', { name: currentWorkspace.name }) : $t('sidebar.workspace_select') }}</span>
<span>{{ currentWorkspace ? $t('sidebar.workspace', { name: currentWorkspace.name })
: $t('sidebar.workspace_select') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
@ -297,7 +313,8 @@ function handleLogout() {
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<IconLanguage class="mr-2 size-4" />
<span>{{ currentLanguage ? $t('sidebar.language', { name: currentLanguage.name }) : $t('sidebar.language_select') }}</span>
<span>{{ currentLanguage ? $t('sidebar.language', { name: currentLanguage.name }) :
$t('sidebar.language_select') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>

View file

@ -181,7 +181,7 @@ const displayText = computed(() => {
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" :align="align">
<Calendar v-model="internalDate" :placeholder="internalDate" layout="month-and-year" locale="en-US"
<Calendar v-model="internalDate" :placeholder="internalDate" layout="month-and-year" locale="en"
calendar-label="Date picker" initial-focus />
<!-- Time Picker -->
<div v-if="showTime" class="border-t p-3">

View file

@ -18,13 +18,13 @@ import {
IconAlertCircle,
IconTrash,
IconSend,
IconLink,
IconDots,
IconPlus,
IconCircleCheck,
IconExternalLink,
IconLoader2,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref, watch, type Component } from 'vue';
import debounce from '@/debounce';
@ -132,10 +132,10 @@ interface Props {
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Posts', href: postsIndex.url() },
{ title: 'Edit Post', href: '#' },
];
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('posts.title'), href: postsIndex.url() },
{ title: trans('posts.edit.title'), href: '#' },
]);
// Reactive state for real-time updates
const post = ref(props.post);
@ -252,20 +252,7 @@ const getPlatformLogo = (platform: string): string => {
};
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',
'pinterest': 'Pinterest',
'bluesky': 'Bluesky',
'mastodon': 'Mastodon',
};
return labels[platform] || platform;
return trans(`posts.platforms.${platform}`) || platform;
};
const getPlatformIcon = (platform: string): Component => {
@ -286,15 +273,19 @@ const getPlatformIcon = (platform: string): Component => {
};
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: IconClock },
'scheduled': { label: 'Scheduled', color: 'bg-blue-100 text-blue-800', icon: IconClock },
'publishing': { label: 'Publishing', color: 'bg-yellow-100 text-yellow-800', icon: IconLoader2 },
'published': { label: 'Published', color: 'bg-green-100 text-green-800', icon: IconCircleCheck },
'partially_published': { label: 'Partially Published', color: 'bg-orange-100 text-orange-800', icon: IconAlertCircle },
'failed': { label: 'Failed', color: 'bg-red-100 text-red-800', icon: IconAlertCircle },
const configs: Record<string, { color: string; icon: any }> = {
'draft': { color: 'bg-gray-100 text-gray-800', icon: IconClock },
'scheduled': { color: 'bg-blue-100 text-blue-800', icon: IconClock },
'publishing': { color: 'bg-yellow-100 text-yellow-800', icon: IconLoader2 },
'published': { color: 'bg-green-100 text-green-800', icon: IconCircleCheck },
'partially_published': { color: 'bg-orange-100 text-orange-800', icon: IconAlertCircle },
'failed': { color: 'bg-red-100 text-red-800', icon: IconAlertCircle },
};
const config = configs[status] || configs['draft'];
return {
...config,
label: trans(`posts.status.${status}`),
};
return configs[status] || configs['draft'];
};
const formatDateTime = (date: string | null): string => {
@ -303,45 +294,18 @@ const formatDateTime = (date: string | null): string => {
};
// Content type options per platform
const contentTypeOptions: Record<string, ContentTypeOption[]> = {
'instagram': [
{ value: 'instagram_feed', label: 'Feed Post', description: 'Appears in your feed and profile' },
{ value: 'instagram_reel', label: 'Reel', description: 'Short video up to 90 seconds' },
{ value: 'instagram_story', label: 'Story', description: 'Disappears after 24 hours' },
],
'linkedin': [
{ value: 'linkedin_post', label: 'Post', description: 'Standard post with text and media' },
{ value: 'linkedin_carousel', label: 'Carousel', description: 'Swipeable images' },
],
'linkedin-page': [
{ value: 'linkedin_page_post', label: 'Post', description: 'Standard post with text and media' },
{ value: 'linkedin_page_carousel', label: 'Carousel', description: 'Swipeable images' },
],
'facebook': [
{ value: 'facebook_post', label: 'Post', description: 'Standard post on your page' },
{ value: 'facebook_reel', label: 'Reel', description: 'Short video up to 90 seconds' },
{ value: 'facebook_story', label: 'Story', description: 'Disappears after 24 hours' },
],
'tiktok': [
{ value: 'tiktok_video', label: 'Video', description: 'Short-form video content' },
],
'youtube': [
{ value: 'youtube_short', label: 'Short', description: 'Vertical video up to 60 seconds' },
],
'x': [
{ value: 'x_post', label: 'Post', description: 'Tweet with text and media' },
],
'threads': [
{ value: 'threads_post', label: 'Post', description: 'Text post with optional media' },
],
'pinterest': [
{ value: 'pinterest_pin', label: 'Pin', description: 'Image pin with link' },
{ value: 'pinterest_video_pin', label: 'Video Pin', description: 'Video content' },
{ value: 'pinterest_carousel', label: 'Carousel', description: '2-5 images' },
],
'bluesky': [
{ value: 'bluesky_post', label: 'Post', description: 'Text post with optional images' },
],
const contentTypeKeys: Record<string, string[]> = {
'instagram': ['instagram_feed', 'instagram_reel', 'instagram_story'],
'linkedin': ['linkedin_post', 'linkedin_carousel'],
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel'],
'facebook': ['facebook_post', 'facebook_reel', 'facebook_story'],
'tiktok': ['tiktok_video'],
'youtube': ['youtube_short'],
'x': ['x_post'],
'threads': ['threads_post'],
'pinterest': ['pinterest_pin', 'pinterest_video_pin', 'pinterest_carousel'],
'bluesky': ['bluesky_post'],
'mastodon': ['mastodon_post'],
};
function getDefaultContentType(platform: string): string {
@ -361,7 +325,12 @@ function getDefaultContentType(platform: string): string {
}
function getContentTypeOptions(platform: string): ContentTypeOption[] {
return contentTypeOptions[platform] || [];
const keys = contentTypeKeys[platform] || [];
return keys.map(key => ({
value: key,
label: trans(`posts.content_types.${key}.label`),
description: trans(`posts.content_types.${key}.description`),
}));
}
function getPlatformData(platform: string): Record<string, any> {
@ -568,19 +537,19 @@ const contentValidation = computed(() => {
// Pinterest requires a board
if (pp.platform === 'pinterest' && !meta.board_id) {
results[pp.id] = { valid: false, message: 'Select a board', charCount, maxLength: config.maxContentLength };
results[pp.id] = { valid: false, message: trans('posts.edit.validation.select_board'), charCount, maxLength: config.maxContentLength };
} else if (hasUnsupportedImages) {
results[pp.id] = { valid: false, message: 'Images not supported', charCount, maxLength: config.maxContentLength };
results[pp.id] = { valid: false, message: trans('posts.edit.validation.images_not_supported'), charCount, maxLength: config.maxContentLength };
} else if (hasUnsupportedVideos) {
results[pp.id] = { valid: false, message: 'Videos not supported', charCount, maxLength: config.maxContentLength };
results[pp.id] = { valid: false, message: trans('posts.edit.validation.videos_not_supported'), charCount, maxLength: config.maxContentLength };
} else if (hasTooManyImages) {
results[pp.id] = { valid: false, message: `Max ${config.maxImages} images`, charCount, maxLength: config.maxContentLength };
results[pp.id] = { valid: false, message: trans('posts.edit.validation.max_images', { count: config.maxImages }), charCount, maxLength: config.maxContentLength };
} else if (!config.supportsTextOnly && !hasMedia) {
results[pp.id] = { valid: false, message: 'Requires media', charCount, maxLength: config.maxContentLength };
results[pp.id] = { valid: false, message: trans('posts.edit.validation.requires_media'), charCount, maxLength: config.maxContentLength };
} else if (!hasContent && !hasMedia) {
results[pp.id] = { valid: false, message: 'No content', charCount, maxLength: config.maxContentLength };
results[pp.id] = { valid: false, message: trans('posts.edit.no_content'), charCount, maxLength: config.maxContentLength };
} else if (!withinLimit) {
results[pp.id] = { valid: false, message: `${charCount - config.maxContentLength} exceeded`, charCount, maxLength: config.maxContentLength };
results[pp.id] = { valid: false, message: trans('posts.edit.validation.exceeded', { count: charCount - config.maxContentLength }), charCount, maxLength: config.maxContentLength };
} else {
results[pp.id] = { valid: true, message: `${charCount}/${config.maxContentLength}`, charCount, maxLength: config.maxContentLength };
}
@ -601,15 +570,15 @@ const mediaValidation = computed(() => {
const videoCount = media.filter(m => m.type === 'video').length;
if (config.maxImages === 0 && imageCount > 0) {
errors.push(`${getPlatformLabel(pp.platform)} does not support images`);
errors.push(trans('posts.edit.validation.does_not_support_images', { platform: getPlatformLabel(pp.platform) }));
}
if (imageCount > config.maxImages && config.maxImages > 0) {
errors.push(`${getPlatformLabel(pp.platform)} supports up to ${config.maxImages} images`);
errors.push(trans('posts.edit.validation.supports_up_to_images', { platform: getPlatformLabel(pp.platform), count: config.maxImages }));
}
if (!config.allowedMediaTypes.includes('video') && videoCount > 0) {
errors.push(`${getPlatformLabel(pp.platform)} does not support videos`);
errors.push(trans('posts.edit.validation.does_not_support_videos', { platform: getPlatformLabel(pp.platform) }));
}
}
@ -670,7 +639,7 @@ const deletePost = () => {
<template>
<Head :title="isReadOnly ? 'View Post' : 'Edit Post'" />
<Head :title="isReadOnly ? $t('posts.edit.view_title') : $t('posts.edit.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col h-screen">
@ -732,7 +701,7 @@ const deletePost = () => {
</button>
</TooltipTrigger>
<TooltipContent>
<p>Manage platforms</p>
<p>{{ $t('posts.edit.manage_platforms') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -745,13 +714,12 @@ const deletePost = () => {
<Switch id="sync-content" v-model="synced" />
</div>
<Label for="sync-content" class="text-sm cursor-pointer flex items-center gap-1.5">
<IconLink class="h-4 w-4" />
<span>Sync</span>
<span>{{ $t('posts.edit.sync') }}</span>
<span class="flex items-center gap-1">
<component v-for="op in otherSelectedPlatforms.slice(0, 3)" :key="op.id"
<component v-for="op in otherSelectedPlatforms.slice(0, 1)" :key="op.id"
:is="getPlatformIcon(op.platform)" class="h-4 w-4 text-neutral-600" />
<span v-if="otherSelectedPlatforms.length > 3" class="text-muted-foreground">
+{{ otherSelectedPlatforms.length - 3 }}
<span v-if="otherSelectedPlatforms.length > 1" class="text-muted-foreground">
+{{ otherSelectedPlatforms.length - 1 }}
</span>
</span>
</Label>
@ -774,23 +742,23 @@ const deletePost = () => {
<IconTrash class="h-4 w-4" />
</Button>
<Button type="button" variant="secondary" size="icon-sm" class="xl:w-auto xl:px-3"
<Button type="button" variant="secondary" size="sm"
:disabled="!canSubmit || isSubmitting || isSaving" @click="submit('scheduled')">
<IconClock class="h-4 w-4" />
<span class="hidden xl:inline">Schedule</span>
{{ $t('posts.edit.schedule') }}
</Button>
<Button type="button" size="icon-sm" class="xl:w-auto xl:px-3"
<Button type="button" size="sm"
:disabled="!canSubmit || isSubmitting || isSaving" @click="submit('publishing')">
<IconSend class="h-4 w-4" />
<span class="hidden xl:inline">Publish</span>
{{ $t('posts.edit.publish') }}
</Button>
</div>
<!-- Read-only info -->
<div v-else class="flex items-center gap-3 text-sm text-muted-foreground">
<span v-if="post.scheduled_at">Scheduled: {{ formatDateTime(post.scheduled_at) }}</span>
<span v-if="post.published_at">Published: {{ formatDateTime(post.published_at) }}</span>
<span v-if="post.scheduled_at">{{ $t('posts.edit.scheduled_at') }} {{
formatDateTime(post.scheduled_at) }}</span>
<span v-if="post.published_at">{{ $t('posts.edit.published_at') }} {{
formatDateTime(post.published_at) }}</span>
</div>
</div>
@ -803,11 +771,11 @@ const deletePost = () => {
<div v-if="!isReadOnly && (isSaving || showSaved)" class="absolute top-4 left-6 z-10">
<span v-if="isSaving" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconLoader2 class="h-3 w-3 animate-spin" />
Saving...
{{ $t('posts.edit.saving') }}
</span>
<span v-else-if="showSaved" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconCircleCheck class="h-3 w-3 text-green-500" />
Saved
{{ $t('posts.edit.saved') }}
</span>
</div>
<div class="max-w-lg mx-auto py-8 px-6">
@ -856,7 +824,7 @@ const deletePost = () => {
<div class="mt-6 space-y-4">
<!-- Media -->
<div v-if="currentPlatformMedia.length > 0">
<p class="text-sm font-medium mb-2">Media</p>
<p class="text-sm font-medium mb-2">{{ $t('posts.edit.media') }}</p>
<div class="grid grid-cols-3 gap-2">
<div v-for="item in currentPlatformMedia" :key="item.id"
class="aspect-square rounded-lg overflow-hidden bg-muted">
@ -869,9 +837,9 @@ const deletePost = () => {
</div>
<!-- Content -->
<div>
<p class="text-sm font-medium mb-2">Caption</p>
<p class="text-sm font-medium mb-2">{{ $t('posts.edit.caption') }}</p>
<p class="text-sm text-muted-foreground whitespace-pre-wrap">
{{ getContent(activePlatform.id) || 'No caption' }}
{{ getContent(activePlatform.id) || $t('posts.edit.no_caption') }}
</p>
</div>
</div>
@ -926,8 +894,8 @@ const deletePost = () => {
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center mb-4">
<IconPlus class="h-8 w-8 text-muted-foreground" />
</div>
<h3 class="text-lg font-semibold mb-2">No platforms selected</h3>
<p class="text-muted-foreground mb-4">Select at least one platform to create your post</p>
<h3 class="text-lg font-semibold mb-2">{{ $t('posts.edit.empty_state.title') }}</h3>
<p class="text-muted-foreground mb-4">{{ $t('posts.edit.empty_state.description') }}</p>
<div class="flex flex-wrap justify-center gap-2">
<button v-for="pp in post.post_platforms" :key="pp.id" type="button"
@click="togglePlatform(pp.id)"
@ -941,29 +909,36 @@ const deletePost = () => {
</div>
</AppLayout>
<ConfirmDeleteModal ref="deleteModal" title="Delete Post"
description="Are you sure you want to delete this post? This action cannot be undone." action="Delete"
cancel="Cancel" />
<ConfirmDeleteModal ref="deleteModal" :title="$t('posts.edit.delete_modal.title')"
:description="$t('posts.edit.delete_modal.description')" :action="$t('posts.edit.delete_modal.action')"
:cancel="$t('posts.edit.delete_modal.cancel')" />
<!-- Enable Sync Confirmation Dialog -->
<AlertDialog :open="showEnableSyncDialog" @update:open="showEnableSyncDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Discard text and sync with {{ getPlatformLabel(firstSelectedPlatform?.platform || '') }}?
{{ $t('posts.edit.sync_enable.title', {
platform: getPlatformLabel(firstSelectedPlatform?.platform
|| '')
}) }}
</AlertDialogTitle>
<AlertDialogDescription>
If you enable syncing, you'll lose all edits made specifically to other platforms.
{{ $t('posts.edit.sync_enable.description') }}
<br /><br />
Are you sure you want to sync with the {{ getPlatformLabel(firstSelectedPlatform?.platform || '') }}
version?
{{ $t('posts.edit.sync_enable.confirm_question', {
platform:
getPlatformLabel(firstSelectedPlatform?.platform || '')
}) }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{{ $t('posts.edit.sync_enable.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="confirmEnableSync">
<IconLink class="mr-2 h-4 w-4" />
Sync with {{ getPlatformLabel(firstSelectedPlatform?.platform || '') }}
{{ $t('posts.edit.sync_enable.action', {
platform: getPlatformLabel(firstSelectedPlatform?.platform
|| '')
}) }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -974,19 +949,18 @@ const deletePost = () => {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Disable sync?
{{ $t('posts.edit.sync_disable.title') }}
</AlertDialogTitle>
<AlertDialogDescription>
Each platform will keep its current content, but future edits will only apply to the platform you're
editing.
{{ $t('posts.edit.sync_disable.description') }}
<br /><br />
You'll be able to customize the content for each platform individually.
{{ $t('posts.edit.sync_disable.customize_note') }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{{ $t('posts.edit.sync_disable.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="confirmDisableSync">
Disable sync
{{ $t('posts.edit.sync_disable.action') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -996,9 +970,9 @@ const deletePost = () => {
<Dialog :open="showPlatformsDialog" @update:open="showPlatformsDialog = $event">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Select Platforms</DialogTitle>
<DialogTitle>{{ $t('posts.edit.platforms_dialog.title') }}</DialogTitle>
<DialogDescription>
Choose which platforms to publish this post to.
{{ $t('posts.edit.platforms_dialog.description') }}
</DialogDescription>
</DialogHeader>
<div class="grid grid-cols-2 gap-3 py-4">

View file

@ -1,13 +1,15 @@
<script setup lang="ts">
import { Head, Link, router, InfiniteScroll } from '@inertiajs/vue3';
import { IconClock, IconCircleCheck, IconAlertCircle, IconLoader2, IconFileText, IconPlus, IconEye, IconPencil, IconTrash } from '@tabler/icons-vue';
import { Head, Link, InfiniteScroll } from '@inertiajs/vue3';
import { IconClock, IconCircleCheck, IconAlertCircle, IconLoader2, IconFileText, IconPlus, IconEye, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { computed, ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as postsIndex, create as createPost, edit as editPost, destroy as destroyPost } from '@/actions/App/Http/Controllers/PostController';
@ -140,10 +142,12 @@ const canEdit = (post: Post): boolean => {
return ['draft', 'scheduled'].includes(post.status);
};
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const handleDelete = (post: Post) => {
if (confirm(trans('posts.delete_confirm'))) {
router.delete(destroyPost.url(post.id));
}
deleteModal.value?.open({
url: destroyPost.url(post.id),
});
};
</script>
@ -218,17 +222,33 @@ const handleDelete = (post: Post) => {
</span>
</div>
<div class="flex items-center gap-1">
<Link :href="editPost.url(post.id)">
<Button variant="ghost" size="icon" class="h-8 w-8">
<IconEye class="h-4 w-4" />
</Button>
</Link>
<Button v-if="canEdit(post)" variant="ghost" size="icon" class="h-8 w-8"
@click="handleDelete(post)">
<IconTrash class="h-4 w-4 text-red-500" />
</Button>
</div>
<TooltipProvider>
<div class="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Link :href="editPost.url(post.id)">
<Button variant="ghost" size="icon" class="h-8 w-8">
<IconEye class="h-4 w-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
{{ $t('posts.actions.view') }}
</TooltipContent>
</Tooltip>
<Tooltip v-if="canEdit(post)">
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" class="h-8 w-8"
@click="handleDelete(post)">
<IconTrash class="h-4 w-4 text-red-500" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ $t('posts.actions.delete') }}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
</CardContent>
</Card>
@ -265,4 +285,12 @@ const handleDelete = (post: Post) => {
</div>
</div>
</AppLayout>
<ConfirmDeleteModal
ref="deleteModal"
:title="$t('posts.edit.delete_modal.title')"
:description="$t('posts.edit.delete_modal.description')"
:action="$t('posts.edit.delete_modal.action')"
:cancel="$t('posts.edit.delete_modal.cancel')"
/>
</template>

View file

@ -8,7 +8,7 @@
use App\Models\WorkspaceInvite;
beforeEach(function () {
Language::factory()->create(['code' => 'en-US']);
Language::factory()->create(['code' => 'en']);
$this->owner = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
});