deafmissoula-website/client/src/components/Calendar.tsx

238 lines
No EOL
9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;