238 lines
No EOL
9 KiB
TypeScript
238 lines
No EOL
9 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import EventCard from './EventCard';
|
||
import { parseISO, startOfDay, isSameDay, isAfter, format, startOfMonth } from 'date-fns';
|
||
import { events, Event } from '../eventData';
|
||
|
||
export const formatTime = (dateString: string, time: string): string => {
|
||
const date = parseISO(dateString);
|
||
const [hours, minutes] = time.split(':');
|
||
const eventDate = new Date(date.setHours(parseInt(hours, 10), parseInt(minutes, 10)));
|
||
return format(eventDate, 'h:mm a');
|
||
};
|
||
|
||
export const formatDateRange = (startDate: string, startTime: string, endDate: string, endTime: string): string => {
|
||
const start = parseISO(`${startDate}T${startTime}`);
|
||
const end = parseISO(`${endDate}T${endTime}`);
|
||
|
||
if (isSameDay(start, end)) {
|
||
return `${format(start, 'MMMM d, yyyy')}\n${formatTime(startDate, startTime)} - ${formatTime(endDate, endTime)}`;
|
||
} else {
|
||
return `${format(start, 'MMMM d, yyyy')}\n${formatTime(startDate, startTime)} - ${format(end, 'MMMM d, yyyy')}\n${formatTime(endDate, endTime)}`;
|
||
}
|
||
};
|
||
|
||
const EventPopup: React.FC<{ event: Event; position: { top: number; left: number }; onClose: () => void }> = ({ event, position, onClose }) => (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -20 }}
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||
onClick={onClose}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`Event Details: ${event.title}`}
|
||
>
|
||
<motion.div
|
||
className="bg-white p-4 rounded-lg shadow-lg max-w-xs w-full relative"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
onClick={onClose}
|
||
className="absolute top-2 right-2 p-2 text-gray-600 hover:text-gray-900"
|
||
aria-label="Close event details"
|
||
>
|
||
×
|
||
</button>
|
||
<h3 className="font-bold text-lg">{event.title}</h3>
|
||
<p className="text-sm whitespace-pre-line">{formatDateRange(event.date, event.startTime, event.date, event.endTime)}</p>
|
||
<p className="text-sm mt-2">{event.description}</p>
|
||
<p className="text-sm mt-2">Address: {event.address}</p>
|
||
<p className="text-sm mt-2">Contact: <a href={`mailto:${event.email}`} className="text-blue-500 hover:underline">{event.pointOfContact}</a></p>
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
|
||
const Calendar: React.FC = () => {
|
||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||
const now = new Date();
|
||
return startOfMonth(now);
|
||
});
|
||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||
const calendarRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||
if (selectedEvent && calendarRef.current && !calendarRef.current.contains(event.target as Node)) {
|
||
setSelectedEvent(null);
|
||
}
|
||
};
|
||
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
document.addEventListener('touchstart', handleClickOutside);
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
document.removeEventListener('touchstart', handleClickOutside);
|
||
};
|
||
}, [selectedEvent]);
|
||
|
||
useEffect(() => {
|
||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape' && selectedEvent) {
|
||
setSelectedEvent(null);
|
||
}
|
||
};
|
||
|
||
document.addEventListener('keydown', handleEscapeKey);
|
||
return () => {
|
||
document.removeEventListener('keydown', handleEscapeKey);
|
||
};
|
||
}, [selectedEvent]);
|
||
|
||
const isEventUpcoming = (eventDate: string) => {
|
||
const today = startOfDay(new Date());
|
||
const parsedEventDate = startOfDay(parseISO(eventDate));
|
||
return isSameDay(parsedEventDate, today) || isAfter(parsedEventDate, today);
|
||
};
|
||
|
||
const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate();
|
||
const firstDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1).getDay();
|
||
|
||
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||
const emptyDays = Array.from({ length: firstDayOfMonth }, (_, i) => i);
|
||
|
||
const monthNames = ["January", "February", "March", "April", "May", "June",
|
||
"July", "August", "September", "October", "November", "December"
|
||
];
|
||
|
||
const nextMonth = () => {
|
||
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
|
||
};
|
||
|
||
const prevMonth = () => {
|
||
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
|
||
};
|
||
|
||
const handleEventClick = (event: Event) => {
|
||
setSelectedEvent(event);
|
||
};
|
||
|
||
const upcomingEvents = events
|
||
.filter(event => isEventUpcoming(event.date))
|
||
.sort((a, b) => parseISO(a.date).getTime() - parseISO(b.date).getTime());
|
||
|
||
return (
|
||
<div className="max-w-4xl mx-auto p-4 sm:p-6 relative" ref={calendarRef}>
|
||
<div className="flex justify-between items-center mb-6">
|
||
<motion.button
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
className="text-2xl text-[#8C1D40]"
|
||
onClick={prevMonth}
|
||
aria-label={`Previous month: ${monthNames[(currentMonth.getMonth() - 1 + 12) % 12]}`}
|
||
>
|
||
←
|
||
</motion.button>
|
||
<h2 className="text-xl sm:text-2xl font-bold text-[#8C1D40]" role="heading" aria-level={2}>
|
||
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||
</h2>
|
||
<motion.button
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
className="text-2xl text-[#8C1D40]"
|
||
onClick={nextMonth}
|
||
aria-label={`Next month: ${monthNames[(currentMonth.getMonth() + 1) % 12]}`}
|
||
>
|
||
→
|
||
</motion.button>
|
||
</div>
|
||
<div
|
||
className="grid grid-cols-7 gap-1 bg-gray-200 rounded-lg overflow-hidden"
|
||
role="grid"
|
||
aria-label="Calendar"
|
||
>
|
||
{['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map(day => (
|
||
<div
|
||
key={day}
|
||
className="bg-[#8C1D40] text-white font-bold p-2 text-center text-xs sm:text-sm"
|
||
role="columnheader"
|
||
aria-label={day}
|
||
>
|
||
{day.charAt(0)}
|
||
</div>
|
||
))}
|
||
{emptyDays.map(day => (
|
||
<div
|
||
key={`empty-${day}`}
|
||
className="bg-white p-2 h-16 sm:h-24"
|
||
role="gridcell"
|
||
aria-hidden="true"
|
||
></div>
|
||
))}
|
||
{days.map(day => {
|
||
const currentDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day);
|
||
const dateString = format(currentDate, 'yyyy-MM-dd');
|
||
const dayEvents = events.filter(event => event.date === dateString);
|
||
|
||
return (
|
||
<motion.div
|
||
key={day}
|
||
className="bg-white p-1 sm:p-2 h-16 sm:h-24 cursor-pointer flex flex-col items-start justify-start overflow-hidden"
|
||
whileHover={{ backgroundColor: '#FFCCCB' }}
|
||
role="gridcell"
|
||
aria-label={`${format(currentDate, 'MMMM d, yyyy')}${dayEvents.length ? `. ${dayEvents.length} events scheduled` : '. No events'}`}
|
||
>
|
||
<span className="font-semibold text-xs sm:text-sm">{day}</span>
|
||
{dayEvents.map((event, index) => (
|
||
<button
|
||
key={index}
|
||
className="text-xs bg-[#8C1D40] text-white p-1 mt-1 rounded w-full truncate"
|
||
onClick={() => handleEventClick(event)}
|
||
aria-label={`${event.title} on ${format(parseISO(event.date), 'MMMM d')} from ${formatTime(event.date, event.startTime)} to ${formatTime(event.date, event.endTime)}`}
|
||
>
|
||
<div className="truncate">{event.title}</div>
|
||
<div className="hidden sm:block">
|
||
{formatTime(event.date, event.startTime)} - {formatTime(event.date, event.endTime)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
<AnimatePresence>
|
||
{selectedEvent && (
|
||
<EventPopup
|
||
event={selectedEvent}
|
||
position={{ top: 0, left: 0 }}
|
||
onClose={() => setSelectedEvent(null)}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<div className="mt-12">
|
||
<h2 className="text-xl sm:text-2xl font-bold text-[#8C1D40] mb-6" role="heading" aria-level={2}>
|
||
Upcoming Events
|
||
</h2>
|
||
<div
|
||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 justify-items-center"
|
||
role="list"
|
||
aria-label="List of upcoming events"
|
||
>
|
||
{upcomingEvents.map((event, index) => (
|
||
<div key={index} role="listitem">
|
||
<EventCard
|
||
event={{
|
||
...event,
|
||
formattedDateRange: formatDateRange(event.date, event.startTime, event.date, event.endTime)
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Calendar; |