337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useCalendar } from "./CalendarContext";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Calendar as CalendarIcon,
|
|
Clock,
|
|
MapPin,
|
|
} from "lucide-react";
|
|
import {
|
|
format,
|
|
startOfMonth,
|
|
endOfMonth,
|
|
startOfWeek,
|
|
endOfWeek,
|
|
addDays,
|
|
isSameMonth,
|
|
isSameDay,
|
|
addMonths,
|
|
subMonths,
|
|
parseISO,
|
|
isToday,
|
|
} from "date-fns";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface CalendarGridProps {
|
|
onEventClick?: (event: CalendarEvent) => void;
|
|
}
|
|
|
|
interface CalendarEvent {
|
|
id: string;
|
|
summary: string;
|
|
description?: string;
|
|
start: {
|
|
dateTime?: string;
|
|
date?: string;
|
|
};
|
|
end: {
|
|
dateTime?: string;
|
|
date?: string;
|
|
};
|
|
location?: string;
|
|
calendarId: string;
|
|
calendarName: string;
|
|
}
|
|
|
|
function getEventDate(event: { start: { dateTime?: string; date?: string } }): Date {
|
|
if (event.start.dateTime) {
|
|
return parseISO(event.start.dateTime);
|
|
}
|
|
if (event.start.date) {
|
|
return parseISO(event.start.date);
|
|
}
|
|
return new Date();
|
|
}
|
|
|
|
function isAllDayEvent(event: { start: { dateTime?: string; date?: string } }): boolean {
|
|
return !!event.start.date && !event.start.dateTime;
|
|
}
|
|
|
|
function getCalendarColor(calendarName: string): string {
|
|
const colors: Record<string, string> = {
|
|
personal: "bg-pink-500 text-white",
|
|
work: "bg-blue-500 text-white",
|
|
health: "bg-green-500 text-white",
|
|
social: "bg-purple-500 text-white",
|
|
family: "bg-yellow-500 text-black",
|
|
task: "bg-orange-500 text-white",
|
|
primary: "bg-blue-600 text-white",
|
|
};
|
|
|
|
const lower = calendarName.toLowerCase();
|
|
for (const [key, color] of Object.entries(colors)) {
|
|
if (lower.includes(key)) return color;
|
|
}
|
|
|
|
return "bg-muted text-muted-foreground";
|
|
}
|
|
|
|
export function CalendarGrid({ onEventClick }: CalendarGridProps) {
|
|
const { events } = useCalendar();
|
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
|
|
|
// Get calendar days
|
|
const calendarDays = useMemo(() => {
|
|
const monthStart = startOfMonth(currentMonth);
|
|
const monthEnd = endOfMonth(monthStart);
|
|
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
|
|
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
|
|
|
|
const days: Date[] = [];
|
|
let day = calendarStart;
|
|
|
|
while (day <= calendarEnd) {
|
|
days.push(day);
|
|
day = addDays(day, 1);
|
|
}
|
|
|
|
return days;
|
|
}, [currentMonth]);
|
|
|
|
// Get events for a specific day
|
|
const getEventsForDay = (date: Date): CalendarEvent[] => {
|
|
return events.filter((event) => {
|
|
const eventDate = getEventDate(event);
|
|
return isSameDay(eventDate, date);
|
|
}).sort((a, b) => {
|
|
// All-day events first, then by time
|
|
const aAllDay = isAllDayEvent(a);
|
|
const bAllDay = isAllDayEvent(b);
|
|
if (aAllDay && !bAllDay) return -1;
|
|
if (!aAllDay && bAllDay) return 1;
|
|
|
|
const aDate = getEventDate(a);
|
|
const bDate = getEventDate(b);
|
|
return aDate.getTime() - bDate.getTime();
|
|
});
|
|
};
|
|
|
|
const handlePrevMonth = () => setCurrentMonth(subMonths(currentMonth, 1));
|
|
const handleNextMonth = () => setCurrentMonth(addMonths(currentMonth, 1));
|
|
const handleToday = () => {
|
|
const today = new Date();
|
|
setCurrentMonth(today);
|
|
setSelectedDate(today);
|
|
};
|
|
|
|
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
const selectedDateEvents = selectedDate ? getEventsForDay(selectedDate) : [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Calendar Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<h2 className="text-lg font-semibold">
|
|
{format(currentMonth, "MMMM yyyy")}
|
|
</h2>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handlePrevMonth}
|
|
className="h-8 w-8"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleNextMonth}
|
|
className="h-8 w-8"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={handleToday}>
|
|
Today
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
{/* Calendar Grid */}
|
|
<Card className="lg:col-span-2">
|
|
<CardContent className="p-4">
|
|
{/* Week Day Headers */}
|
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
{weekDays.map((day) => (
|
|
<div
|
|
key={day}
|
|
className="text-center text-sm font-medium text-muted-foreground py-2"
|
|
>
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Calendar Days */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{calendarDays.map((date, index) => {
|
|
const dayEvents = getEventsForDay(date);
|
|
const isCurrentMonth = isSameMonth(date, currentMonth);
|
|
const isTodayDate = isToday(date);
|
|
const isSelected = selectedDate && isSameDay(date, selectedDate);
|
|
|
|
return (
|
|
<button
|
|
key={index}
|
|
onClick={() => setSelectedDate(date)}
|
|
className={cn(
|
|
"min-h-[80px] sm:min-h-[100px] p-1 sm:p-2 text-left rounded-lg border transition-all",
|
|
!isCurrentMonth && "bg-muted/30 text-muted-foreground",
|
|
isCurrentMonth && "bg-card hover:bg-accent",
|
|
isTodayDate && "border-primary/50 bg-primary/5",
|
|
isSelected && "ring-2 ring-primary border-primary"
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span
|
|
className={cn(
|
|
"text-sm font-medium",
|
|
isTodayDate && "text-primary",
|
|
!isCurrentMonth && "text-muted-foreground"
|
|
)}
|
|
>
|
|
{format(date, "d")}
|
|
</span>
|
|
{dayEvents.length > 0 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{dayEvents.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Event Indicators */}
|
|
<div className="space-y-1">
|
|
{dayEvents.slice(0, 3).map((event, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"text-[10px] px-1.5 py-0.5 rounded truncate",
|
|
getCalendarColor(event.calendarName)
|
|
)}
|
|
title={event.summary}
|
|
>
|
|
{isAllDayEvent(event) ? (
|
|
event.summary
|
|
) : (
|
|
<span className="flex items-center gap-1">
|
|
<span>{format(getEventDate(event), "h:mm a")}</span>
|
|
<span className="truncate">{event.summary}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
{dayEvents.length > 3 && (
|
|
<div className="text-[10px] text-muted-foreground px-1.5">
|
|
+{dayEvents.length - 3} more
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Selected Day Details */}
|
|
<Card className="h-fit">
|
|
<CardContent className="p-4">
|
|
{selectedDate ? (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="font-semibold text-lg">
|
|
{isToday(selectedDate)
|
|
? "Today"
|
|
: format(selectedDate, "EEEE, MMMM d")}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{selectedDateEvents.length} event
|
|
{selectedDateEvents.length !== 1 ? "s" : ""}
|
|
</p>
|
|
</div>
|
|
|
|
{selectedDateEvents.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
<CalendarIcon className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
|
<p className="text-sm">No events scheduled</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{selectedDateEvents.map((event) => (
|
|
<div
|
|
key={event.id}
|
|
onClick={() => onEventClick?.(event)}
|
|
className="p-3 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<Badge
|
|
className={cn(
|
|
"text-[10px] shrink-0",
|
|
getCalendarColor(event.calendarName)
|
|
)}
|
|
>
|
|
{event.calendarName}
|
|
</Badge>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm truncate">
|
|
{event.summary}
|
|
</p>
|
|
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
|
{isAllDayEvent(event) ? (
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
All day
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{format(getEventDate(event), "h:mm a")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{event.location && (
|
|
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
|
<MapPin className="w-3 h-3" />
|
|
<span className="truncate">{event.location}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<CalendarIcon className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
|
<p className="text-sm">Select a date to view events</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|