110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
import { useMemo } from 'react';
|
|
import {
|
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
|
eachDayOfInterval, isSameMonth, isToday, isSameDay, format,
|
|
} from 'date-fns';
|
|
import { motion } from 'framer-motion';
|
|
import { clsx } from 'clsx';
|
|
import type { CalendarEvent, Member } from '@/lib/api';
|
|
|
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
|
|
interface Props {
|
|
month: Date;
|
|
events: CalendarEvent[];
|
|
members: Member[];
|
|
onDayClick: (date: Date) => void;
|
|
onEventClick: (event: CalendarEvent) => void;
|
|
}
|
|
|
|
function eventColor(event: CalendarEvent, members: Member[]): string {
|
|
if (event.color) return event.color;
|
|
if (event.member_id) {
|
|
return members.find((m) => m.id === event.member_id)?.color ?? '#6366f1';
|
|
}
|
|
return '#6366f1';
|
|
}
|
|
|
|
export function CalendarGrid({ month, events, members, onDayClick, onEventClick }: Props) {
|
|
const days = useMemo(() => {
|
|
const start = startOfWeek(startOfMonth(month));
|
|
const end = endOfWeek(endOfMonth(month));
|
|
return eachDayOfInterval({ start, end });
|
|
}, [month]);
|
|
|
|
function dayEvents(day: Date) {
|
|
return events.filter((e) => {
|
|
const start = new Date(e.start_at);
|
|
const end = new Date(e.end_at);
|
|
// All-day or events that touch this day
|
|
return isSameDay(start, day) || (start <= day && end >= day);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Weekday headers */}
|
|
<div className="grid grid-cols-7 border-b border-theme">
|
|
{WEEKDAYS.map((d) => (
|
|
<div key={d} className="py-2 text-center text-xs font-semibold text-muted uppercase tracking-wide">
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Day grid */}
|
|
<div className="grid grid-cols-7 flex-1" style={{ gridTemplateRows: `repeat(${days.length / 7}, 1fr)` }}>
|
|
{days.map((day, i) => {
|
|
const de = dayEvents(day);
|
|
const inMonth = isSameMonth(day, month);
|
|
const today = isToday(day);
|
|
|
|
return (
|
|
<motion.div
|
|
key={i}
|
|
whileHover={{ backgroundColor: 'var(--color-surface-raised)' }}
|
|
onClick={() => onDayClick(day)}
|
|
className={clsx(
|
|
'relative min-h-[80px] border-b border-r border-theme p-1.5 cursor-pointer',
|
|
'transition-colors duration-100',
|
|
!inMonth && 'opacity-40',
|
|
)}
|
|
>
|
|
{/* Date number */}
|
|
<div className="flex justify-end mb-1">
|
|
<span
|
|
className={clsx(
|
|
'h-6 w-6 flex items-center justify-center rounded-full text-xs font-semibold',
|
|
today
|
|
? 'bg-accent text-white'
|
|
: 'text-primary hover:bg-surface-raised'
|
|
)}
|
|
>
|
|
{format(day, 'd')}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Event chips */}
|
|
<div className="flex flex-col gap-0.5">
|
|
{de.slice(0, 3).map((ev) => (
|
|
<button
|
|
key={ev.id}
|
|
onClick={(e) => { e.stopPropagation(); onEventClick(ev); }}
|
|
className="w-full text-left px-1.5 py-0.5 rounded text-xs font-medium text-white truncate leading-5"
|
|
style={{ backgroundColor: eventColor(ev, members) }}
|
|
>
|
|
{ev.all_day ? '' : `${format(new Date(ev.start_at), 'h:mm')} `}{ev.title}
|
|
</button>
|
|
))}
|
|
{de.length > 3 && (
|
|
<span className="text-xs text-muted px-1">+{de.length - 3} more</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|