trypost/resources/js/pages/posts/Calendar.vue

465 lines
No EOL
20 KiB
Vue

<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { IconChevronLeft, IconChevronRight, IconPlus } from '@tabler/icons-vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import DatePicker from '@/components/DatePicker.vue';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import date from '@/date';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { calendar } from '@/routes/app';
import { store as storePost, edit as editPost } from '@/routes/app/posts';
interface PostPlatform {
id: string;
platform: string;
content: string;
status: string;
social_account: {
id: string;
platform: string;
display_name: string;
};
}
interface Post {
id: string;
status: string;
scheduled_at: string;
post_platforms: PostPlatform[];
}
interface Workspace {
id: string;
name: string;
}
interface Props {
workspace: Workspace;
posts: Record<string, Post[]>;
currentDay: string;
currentWeekStart: string;
currentMonth: string;
view: 'day' | 'week' | 'month';
}
const props = defineProps<Props>();
// Mobile detection
const isMobile = ref(false);
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024;
};
onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
});
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
// Effective view (force day view on mobile)
const effectiveView = computed(() => {
return isMobile.value ? 'day' : props.view;
});
// Generate weekday names based on dayjs locale (respects weekStart config)
const weekdayNames = computed(() => {
const names = [];
const start = dayjs().startOf('week');
for (let i = 0; i < 7; i++) {
names.push(start.add(i, 'day').format('dddd'));
}
return names;
});
// Day view computed
const currentDay = computed(() => dayjs(props.currentDay));
const dayHeaderTitle = computed(() => {
return currentDay.value.format('dddd, D [de] MMMM [de] YYYY');
});
const dayPosts = computed(() => {
const dateKey = currentDay.value.format('YYYY-MM-DD');
return props.posts[dateKey] || [];
});
// Date picker model for day navigation
const selectedDate = ref(props.currentDay);
// Week view computed
const weekStart = computed(() => dayjs(props.currentWeekStart));
const weekDays = computed(() => {
const days = [];
for (let i = 0; i < 7; i++) {
days.push(weekStart.value.add(i, 'day'));
}
return days;
});
const weekHeaderTitle = computed(() => {
const start = weekStart.value;
const end = weekStart.value.add(6, 'day');
if (start.month() === end.month()) {
return `${start.format('MMMM D')} - ${end.format('D, YYYY')}`;
}
return `${start.format('MMM D')} - ${end.format('MMM D, YYYY')}`;
});
// Month view computed
const monthDate = computed(() => dayjs(props.currentMonth));
const monthHeaderTitle = computed(() => {
return monthDate.value.format('MMMM YYYY');
});
const calendarDays = computed(() => {
const start = monthDate.value.startOf('month').startOf('week');
const end = monthDate.value.endOf('month').endOf('week');
const days = [];
let current = start;
while (current.isBefore(end) || current.isSame(end, 'day')) {
days.push(current);
current = current.add(1, 'day');
}
return days;
});
const calendarWeeks = computed(() => {
const weeks = [];
for (let i = 0; i < calendarDays.value.length; i += 7) {
weeks.push(calendarDays.value.slice(i, i + 7));
}
return weeks;
});
// Header title based on view
const headerTitle = computed(() => {
if (effectiveView.value === 'day') return dayHeaderTitle.value;
if (effectiveView.value === 'month') return monthHeaderTitle.value;
return weekHeaderTitle.value;
});
const getPostsForDay = (day: dayjs.Dayjs): Post[] => {
const dateKey = day.format('YYYY-MM-DD');
return props.posts[dateKey] || [];
};
const navigateDay = (direction: number) => {
const newDay = currentDay.value.add(direction, 'day');
router.get(calendar.url({ query: { view: 'day', day: newDay.format('YYYY-MM-DD') } }), {}, {
preserveState: true,
});
};
const navigateWeek = (direction: number) => {
const newStart = weekStart.value.add(direction * 7, 'day');
router.get(calendar.url({ query: { view: 'week', week: newStart.format('YYYY-MM-DD') } }), {}, {
preserveState: true,
});
};
const navigateMonth = (direction: number) => {
const newMonth = monthDate.value.add(direction, 'month');
router.get(calendar.url({ query: { view: 'month', month: newMonth.format('YYYY-MM-DD') } }), {}, {
preserveState: true,
});
};
const navigate = (direction: number) => {
if (effectiveView.value === 'day') {
navigateDay(direction);
} else if (effectiveView.value === 'month') {
navigateMonth(direction);
} else {
navigateWeek(direction);
}
};
const goToToday = () => {
router.get(calendar.url({ query: { view: effectiveView.value } }), {}, {
preserveState: true,
});
};
const goToDate = (dateStr: string) => {
if (!dateStr) return;
router.get(calendar.url({ query: { view: 'day', day: dateStr } }), {}, {
preserveState: true,
});
};
const switchView = (view: string | number) => {
router.get(calendar.url({ query: { view } }), {}, {
preserveState: true,
});
};
const isToday = (day: dayjs.Dayjs): boolean => {
return day.isSame(dayjs(), 'day');
};
const isCurrentMonth = (day: dayjs.Dayjs): boolean => {
return day.month() === monthDate.value.month();
};
const getStatusColor = (status: string): string => {
const colors: Record<string, string> = {
draft: 'bg-neutral-100 border-neutral-300 text-neutral-700 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-300',
scheduled: 'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-300',
publishing: 'bg-yellow-50 border-yellow-300 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300',
published: 'bg-green-50 border-green-300 text-green-700 dark:bg-green-950 dark:border-green-800 dark:text-green-300',
partially_published: 'bg-orange-50 border-orange-300 text-orange-700 dark:bg-orange-950 dark:border-orange-800 dark:text-orange-300',
failed: 'bg-red-50 border-red-300 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300',
};
return colors[status] || 'bg-neutral-100 border-neutral-300 text-neutral-700 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-300';
};
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',
'pinterest': '/images/accounts/pinterest.png',
'bluesky': '/images/accounts/bluesky.png',
'mastodon': '/images/accounts/mastodon.png',
};
return logos[platform];
};
const getPostUrl = (post: Post): string => {
return editPost.url(post.id);
};
const formatTime = (scheduledAt: string): string => {
return date.formatTime(scheduledAt) || '';
};
</script>
<template>
<Head :title="$t('calendar.title')" />
<AppLayout :fullWidth="true">
<template #header>
<Button variant="outline" size="icon" @click="navigate(-1)">
<IconChevronLeft class="h-4 w-4" />
</Button>
<Button variant="outline" @click="goToToday">
{{ $t('calendar.today') }}
</Button>
<Button variant="outline" size="icon" @click="navigate(1)">
<IconChevronRight class="h-4 w-4" />
</Button>
<DatePicker v-if="isMobile" v-model="selectedDate" @update:model-value="(v: any) => goToDate(v)" />
<span class="text-sm font-semibold">
{{ headerTitle }}
</span>
</template>
<template #header-actions>
<div class="flex items-center gap-2">
<Tabs v-if="!isMobile" :default-value="view" @update:model-value="switchView">
<TabsList class="h-10">
<TabsTrigger value="day">{{ $t('calendar.day') }}</TabsTrigger>
<TabsTrigger value="week">{{ $t('calendar.week') }}</TabsTrigger>
<TabsTrigger value="month">{{ $t('calendar.month') }}</TabsTrigger>
</TabsList>
</Tabs>
<Link :href="storePost.url()" method="post">
<Button>
{{ $t('calendar.new_post') }}
</Button>
</Link>
</div>
</template>
<div class="flex flex-col h-full">
<!-- Day View (mobile or when view=day) -->
<div v-if="effectiveView === 'day'" class="flex-1 overflow-y-auto">
<!-- Mobile Header Title -->
<div class="lg:hidden px-4 py-3 border-b bg-muted/30">
<h2 class="text-base font-semibold text-center">
{{ dayHeaderTitle }}
</h2>
</div>
<div class="p-4 space-y-3">
<!-- Posts List -->
<div v-if="dayPosts.length > 0" class="space-y-3">
<Link v-for="post in dayPosts" :key="post.id" :href="getPostUrl(post)" class="block">
<div class="p-4 rounded-lg border transition-all hover:ring-2 hover:ring-primary hover:ring-offset-1"
:class="getStatusColor(post.status)">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<!-- Time -->
<div class="text-sm font-semibold mb-2">
{{ formatTime(post.scheduled_at) }}
</div>
<!-- Platforms -->
<div class="flex -space-x-1 mb-2">
<img v-for="pp in post.post_platforms.slice(0, 5)" :key="pp.id"
:src="getPlatformLogo(pp.platform)" :alt="pp.platform"
class="h-6 w-6 rounded-full ring-2 ring-background" />
<span v-if="post.post_platforms.length > 5"
class="flex items-center justify-center h-6 w-6 rounded-full bg-muted text-xs font-medium ring-2 ring-background">
+{{ post.post_platforms.length - 5 }}
</span>
</div>
<!-- Content Preview -->
<p class="text-sm line-clamp-2 opacity-80">
{{ post.post_platforms[0]?.content || $t('calendar.no_content') }}
</p>
</div>
</div>
</div>
</Link>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12 text-muted-foreground">
<p>{{ $t('calendar.no_content') }}</p>
</div>
</div>
</div>
<!-- Week View -->
<div v-else-if="effectiveView === 'week'" class="flex-1 grid grid-cols-7 divide-x overflow-hidden">
<div v-for="day in weekDays" :key="day.format('YYYY-MM-DD')" class="flex flex-col min-h-0"
:class="{ 'bg-primary/5': isToday(day) }">
<!-- Day Header -->
<div class="flex flex-col items-center py-3 border-b bg-muted/30">
<span class="text-xs font-medium text-muted-foreground">
{{ day.format('dddd') }}
</span>
<span class="mt-1 text-sm font-semibold" :class="{
'text-primary': isToday(day),
'text-foreground': !isToday(day),
}">
{{ day.format('D/MMMM') }}
</span>
</div>
<!-- Day Content -->
<div class="flex-1 overflow-y-auto p-2 space-y-2">
<!-- Add Post Button -->
<Link :href="storePost.url({ query: { date: day.format('YYYY-MM-DD') } })" method="post"
class="flex items-center justify-center p-2 rounded border border-dashed border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors">
<IconPlus class="h-4 w-4" />
</Link>
<!-- Posts -->
<Link v-for="post in getPostsForDay(day)" :key="post.id" :href="getPostUrl(post)" class="block">
<div class="p-2 rounded border text-sm transition-all hover:ring-2 hover:ring-primary hover:ring-offset-1"
:class="getStatusColor(post.status)">
<!-- Time -->
<div class="text-xs font-medium mb-1">
{{ formatTime(post.scheduled_at) }}
</div>
<!-- Platforms -->
<div class="flex -space-x-1 mb-1.5">
<img v-for="pp in post.post_platforms.slice(0, 4)" :key="pp.id"
:src="getPlatformLogo(pp.platform)" :alt="pp.platform"
class="h-5 w-5 rounded-full ring-2 ring-background" />
<span v-if="post.post_platforms.length > 4"
class="flex items-center justify-center h-5 w-5 rounded-full bg-muted text-[10px] font-medium ring-2 ring-background">
+{{ post.post_platforms.length - 4 }}
</span>
</div>
<!-- Content Preview -->
<p class="text-xs line-clamp-2 opacity-80">
{{ post.post_platforms[0]?.content || $t('calendar.no_content') }}
</p>
</div>
</Link>
</div>
</div>
</div>
<!-- Month View -->
<div v-else class="flex-1 flex flex-col">
<!-- Weekday Headers -->
<div class="grid grid-cols-7 divide-x border-b bg-muted/30">
<div v-for="day in weekdayNames" :key="day"
class="py-3 text-center text-xs font-medium text-muted-foreground">
{{ day }}
</div>
</div>
<!-- Calendar Grid -->
<div class="flex-1 grid divide-y"
:style="{ gridTemplateRows: `repeat(${calendarWeeks.length}, minmax(0, 1fr))` }">
<div v-for="(week, weekIndex) in calendarWeeks" :key="weekIndex"
class="grid grid-cols-7 divide-x min-h-0">
<div v-for="day in week" :key="day.format('YYYY-MM-DD')"
class="flex flex-col p-2 min-h-0 overflow-hidden group" :class="{
'bg-primary/5': isToday(day),
'bg-muted/30': !isCurrentMonth(day),
}">
<!-- Day Header -->
<div class="flex items-center justify-between mb-2">
<span class="flex items-center justify-center w-7 h-7 text-sm font-medium rounded-full"
:class="{
'bg-primary text-primary-foreground': isToday(day),
'text-muted-foreground': !isCurrentMonth(day),
'text-foreground': isCurrentMonth(day) && !isToday(day),
}">
{{ day.format('D') }}
</span>
<Link :href="storePost.url({ query: { date: day.format('YYYY-MM-DD') } })" method="post"
class="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 rounded text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all">
<IconPlus class="h-4 w-4" />
</Link>
</div>
<!-- Posts -->
<div class="flex-1 overflow-y-auto space-y-1 min-h-0">
<Link v-for="post in getPostsForDay(day).slice(0, 3)" :key="post.id"
:href="getPostUrl(post)" class="block">
<div class="flex items-center justify-between gap-1.5 px-2 py-1 rounded border text-xs transition-colors hover:brightness-95"
:class="getStatusColor(post.status)">
<span class="font-medium shrink-0">{{ formatTime(post.scheduled_at) }}</span>
<div class="flex -space-x-1 shrink-0">
<img v-for="pp in post.post_platforms.slice(0, post.post_platforms.length > 4 ? 3 : 4)"
:key="pp.id" :src="getPlatformLogo(pp.platform)" :alt="pp.platform"
class="h-4 w-4 rounded-full ring-1 ring-background" />
<span v-if="post.post_platforms.length > 4"
class="flex items-center justify-center h-4 w-4 rounded-full bg-muted text-[9px] font-medium ring-1 ring-background">
+{{ post.post_platforms.length - 3 }}
</span>
</div>
</div>
</Link>
<div v-if="getPostsForDay(day).length > 3"
class="text-xs text-muted-foreground px-2 py-0.5">
{{ $t('calendar.more', { count: String(getPostsForDay(day).length - 3) }) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>