Spaces:
Running
Running
| 'use client' | |
| import React, { useState, useEffect, useMemo, useRef } from 'react' | |
| import { | |
| CaretLeft, | |
| CaretRight, | |
| Plus, | |
| MagnifyingGlass, | |
| CalendarBlank, | |
| List, | |
| Check | |
| } from '@phosphor-icons/react' | |
| import { motion, AnimatePresence } from 'framer-motion' | |
| import { useKV } from '../hooks/useKV' | |
| import Window from './Window' | |
| import { recurringHolidays, getVariableHolidays, Holiday } from '../data/holidays' | |
| interface CalendarProps { | |
| onClose: () => void | |
| onMinimize?: () => void | |
| onMaximize?: () => void | |
| onFocus?: () => void | |
| zIndex?: number | |
| } | |
| interface CalendarEvent extends Holiday { | |
| isCustom?: boolean | |
| } | |
| type ViewMode = 'day' | 'week' | 'month' | 'year' | |
| export function Calendar({ onClose, onMinimize, onMaximize, onFocus, zIndex }: CalendarProps) { | |
| const [windowSize, setWindowSize] = useState({ width: 1100, height: 750 }) | |
| const [currentDate, setCurrentDate] = useState(new Date()) | |
| const [view, setView] = useState<ViewMode>('month') | |
| const [customEvents, setCustomEvents] = useKV<CalendarEvent[]>('custom-calendar-events', []) | |
| const [searchQuery, setSearchQuery] = useState('') | |
| const [isSearchOpen, setIsSearchOpen] = useState(false) | |
| const scrollContainerRef = useRef<HTMLDivElement>(null) | |
| const [sidebarOpen, setSidebarOpen] = useState(true) | |
| // Handle window resize | |
| useEffect(() => { | |
| const handleResize = () => { | |
| const isMobile = window.innerWidth < 768 | |
| setWindowSize({ | |
| width: Math.min(1100, window.innerWidth - (isMobile ? 20 : 40)), | |
| height: Math.min(750, window.innerHeight - (isMobile ? 20 : 40)) | |
| }) | |
| // Auto-close sidebar on mobile | |
| if (isMobile && sidebarOpen) { | |
| setSidebarOpen(false) | |
| } | |
| } | |
| handleResize() // Set initial size | |
| window.addEventListener('resize', handleResize) | |
| return () => window.removeEventListener('resize', handleResize) | |
| }, [sidebarOpen]) | |
| // Scroll to 8 AM on mount for day/week views | |
| useEffect(() => { | |
| if ((view === 'day' || view === 'week') && scrollContainerRef.current) { | |
| scrollContainerRef.current.scrollTop = 8 * 60 | |
| } | |
| }, [view]) | |
| // Generate all events | |
| const allEvents = useMemo(() => { | |
| const year = currentDate.getFullYear() | |
| const years = [year - 1, year, year + 1] | |
| let events: CalendarEvent[] = [...customEvents] | |
| years.forEach(y => { | |
| const variable = getVariableHolidays(y) | |
| const recurring = recurringHolidays.map(h => ({ | |
| ...h, | |
| date: `${y}-${h.date}` | |
| })) | |
| events = [...events, ...variable, ...recurring] | |
| }) | |
| return events | |
| }, [currentDate.getFullYear(), customEvents]) | |
| const monthNames = [ | |
| 'January', 'February', 'March', 'April', 'May', 'June', | |
| 'July', 'August', 'September', 'October', 'November', 'December' | |
| ] | |
| const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] | |
| const hours = Array.from({ length: 24 }, (_, i) => i) | |
| const getDaysInMonth = (year: number, month: number) => { | |
| const firstDay = new Date(year, month, 1) | |
| const lastDay = new Date(year, month + 1, 0) | |
| const daysInMonth = lastDay.getDate() | |
| const startingDayOfWeek = firstDay.getDay() | |
| const prevMonthDays = [] | |
| const prevMonthLastDay = new Date(year, month, 0).getDate() | |
| for (let i = startingDayOfWeek - 1; i >= 0; i--) { | |
| prevMonthDays.push({ | |
| day: prevMonthLastDay - i, | |
| month: month - 1, | |
| year: year, | |
| isCurrentMonth: false | |
| }) | |
| } | |
| const currentMonthDays = [] | |
| for (let i = 1; i <= daysInMonth; i++) { | |
| currentMonthDays.push({ | |
| day: i, | |
| month: month, | |
| year: year, | |
| isCurrentMonth: true | |
| }) | |
| } | |
| const nextMonthDays = [] | |
| const totalDaysSoFar = prevMonthDays.length + currentMonthDays.length | |
| const remainingCells = 42 - totalDaysSoFar | |
| for (let i = 1; i <= remainingCells; i++) { | |
| nextMonthDays.push({ | |
| day: i, | |
| month: month + 1, | |
| year: year, | |
| isCurrentMonth: false | |
| }) | |
| } | |
| return [...prevMonthDays, ...currentMonthDays, ...nextMonthDays] | |
| } | |
| const calendarDays = useMemo(() => { | |
| return getDaysInMonth(currentDate.getFullYear(), currentDate.getMonth()) | |
| }, [currentDate]) | |
| const getEventsForDate = (dateStr: string) => { | |
| return allEvents.filter(e => e.date === dateStr) | |
| } | |
| const isToday = (day: number, month: number, year: number) => { | |
| const today = new Date() | |
| return day === today.getDate() && month === today.getMonth() && year === today.getFullYear() | |
| } | |
| const handlePrev = () => { | |
| const newDate = new Date(currentDate) | |
| if (view === 'month') newDate.setMonth(newDate.getMonth() - 1) | |
| else if (view === 'year') newDate.setFullYear(newDate.getFullYear() - 1) | |
| else if (view === 'week') newDate.setDate(newDate.getDate() - 7) | |
| else if (view === 'day') newDate.setDate(newDate.getDate() - 1) | |
| setCurrentDate(newDate) | |
| } | |
| const handleNext = () => { | |
| const newDate = new Date(currentDate) | |
| if (view === 'month') newDate.setMonth(newDate.getMonth() + 1) | |
| else if (view === 'year') newDate.setFullYear(newDate.getFullYear() + 1) | |
| else if (view === 'week') newDate.setDate(newDate.getDate() + 7) | |
| else if (view === 'day') newDate.setDate(newDate.getDate() + 1) | |
| setCurrentDate(newDate) | |
| } | |
| const handleToday = () => setCurrentDate(new Date()) | |
| const getWeekDays = () => { | |
| const startOfWeek = new Date(currentDate) | |
| const day = startOfWeek.getDay() | |
| const diff = startOfWeek.getDate() - day | |
| startOfWeek.setDate(diff) | |
| const days = [] | |
| for (let i = 0; i < 7; i++) { | |
| const d = new Date(startOfWeek) | |
| d.setDate(startOfWeek.getDate() + i) | |
| days.push(d) | |
| } | |
| return days | |
| } | |
| const renderHeaderTitle = () => { | |
| if (view === 'year') return <span className="font-semibold text-xl">{currentDate.getFullYear()}</span> | |
| if (view === 'day') { | |
| return ( | |
| <div className="flex flex-col leading-tight"> | |
| <span className="font-semibold text-xl">{currentDate.getDate()} {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}</span> | |
| </div> | |
| ) | |
| } | |
| return <span className="font-semibold text-xl">{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}</span> | |
| } | |
| return ( | |
| <Window | |
| id="calendar" | |
| title="Calendar" | |
| isOpen={true} | |
| onClose={onClose} | |
| onMinimize={onMinimize} | |
| onMaximize={onMaximize} | |
| onFocus={onFocus} | |
| zIndex={zIndex} | |
| width={windowSize.width} | |
| height={windowSize.height} | |
| x={50} | |
| y={50} | |
| className="calendar-window !bg-[#1e1e1e]/80 !backdrop-blur-2xl border border-white/10 shadow-2xl !rounded-xl overflow-hidden" | |
| contentClassName="!bg-transparent" | |
| headerClassName="!bg-transparent border-b border-white/5" | |
| > | |
| <div className="flex h-full text-white overflow-hidden"> | |
| {/* Sidebar */} | |
| <AnimatePresence initial={false}> | |
| {sidebarOpen && ( | |
| <motion.div | |
| initial={{ width: 0, opacity: 0 }} | |
| animate={{ width: 240, opacity: 1 }} | |
| exit={{ width: 0, opacity: 0 }} | |
| className="border-r border-white/10 bg-black/20 backdrop-blur-md flex flex-col" | |
| > | |
| <div className="p-4"> | |
| <div className="grid grid-cols-7 gap-y-2 text-center mb-4"> | |
| {weekDays.map(d => ( | |
| <div key={d} className="text-[10px] text-gray-500 font-medium">{d.charAt(0)}</div> | |
| ))} | |
| {calendarDays.map((d, i) => { | |
| if (!d.isCurrentMonth) return <div key={i} className="text-gray-600 text-xs">{d.day}</div> | |
| const isCurrentDay = isToday(d.day, d.month, d.year) | |
| const isSelected = d.day === currentDate.getDate() && d.month === currentDate.getMonth() | |
| return ( | |
| <div | |
| key={i} | |
| onClick={() => setCurrentDate(new Date(d.year, d.month, d.day))} | |
| className={` | |
| text-xs w-6 h-6 flex items-center justify-center rounded-full mx-auto cursor-pointer transition-colors | |
| ${isCurrentDay ? 'bg-red-500 text-white font-bold' : isSelected ? 'bg-white/20 text-white' : 'text-gray-300 hover:bg-white/10'} | |
| `} | |
| > | |
| {d.day} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| <div className="mt-8 space-y-4"> | |
| <div className="flex items-center justify-between text-xs text-gray-400 font-medium px-2"> | |
| <span>CALENDARS</span> | |
| </div> | |
| <div className="space-y-1"> | |
| {['Home', 'Work', 'Holidays', 'Birthdays'].map((cal, i) => ( | |
| <div key={cal} className="flex items-center gap-3 px-2 py-1.5 rounded-md hover:bg-white/5 cursor-pointer group"> | |
| <div className={`w-3 h-3 rounded-full border-2 ${i === 0 ? 'border-blue-500 bg-blue-500' : | |
| i === 1 ? 'border-purple-500 bg-purple-500' : | |
| i === 2 ? 'border-green-500 bg-green-500' : | |
| 'border-red-500 bg-red-500' | |
| }`} /> | |
| <span className="text-sm text-gray-300">{cal}</span> | |
| <Check size={12} className="ml-auto opacity-0 group-hover:opacity-100 text-gray-500" /> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* Main Content */} | |
| <div className="flex-1 flex flex-col bg-transparent"> | |
| {/* Toolbar */} | |
| <div className="h-12 sm:h-14 flex items-center justify-between px-2 sm:px-6 border-b border-white/10 bg-[#252525]/30 backdrop-blur-sm"> | |
| <div className="flex items-center gap-2 sm:gap-4"> | |
| <button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-1.5 sm:p-2 hover:bg-white/10 rounded-md transition-colors text-gray-400 hover:text-white"> | |
| <List size={18} className="sm:w-5 sm:h-5" /> | |
| </button> | |
| <div className="text-base sm:text-2xl font-light tracking-tight hidden sm:block">{renderHeaderTitle()}</div> | |
| <div className="text-sm font-medium tracking-tight sm:hidden"> | |
| {view === 'month' && `${monthNames[currentDate.getMonth()].substr(0, 3)} ${currentDate.getFullYear()}`} | |
| {view === 'year' && currentDate.getFullYear()} | |
| {view === 'day' && `${currentDate.getDate()} ${monthNames[currentDate.getMonth()].substr(0, 3)}`} | |
| {view === 'week' && 'Week View'} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1 sm:gap-4"> | |
| <div className="flex items-center bg-black/20 rounded-lg p-0.5 sm:p-1 border border-white/5"> | |
| <button onClick={handlePrev} className="p-1 sm:p-1.5 hover:bg-white/10 rounded-md text-gray-400 hover:text-white transition-colors"> | |
| <CaretLeft size={14} className="sm:w-4 sm:h-4" /> | |
| </button> | |
| <button onClick={handleToday} className="hidden sm:block px-3 py-1 text-sm font-medium text-gray-300 hover:text-white transition-colors"> | |
| Today | |
| </button> | |
| <button onClick={handleNext} className="p-1 sm:p-1.5 hover:bg-white/10 rounded-md text-gray-400 hover:text-white transition-colors"> | |
| <CaretRight size={14} className="sm:w-4 sm:h-4" /> | |
| </button> | |
| </div> | |
| <div className="hidden sm:flex items-center bg-black/20 rounded-lg p-1 border border-white/5"> | |
| {(['day', 'week', 'month', 'year'] as ViewMode[]).map((v) => ( | |
| <button | |
| key={v} | |
| onClick={() => setView(v)} | |
| className={`px-3 py-1 text-xs font-medium rounded-md capitalize transition-all ${view === v | |
| ? 'bg-[#444] text-white shadow-sm' | |
| : 'text-gray-400 hover:text-gray-200' | |
| }`} | |
| > | |
| {v} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Mobile view selector */} | |
| <select | |
| value={view} | |
| onChange={(e) => setView(e.target.value as ViewMode)} | |
| className="sm:hidden bg-black/20 border border-white/5 rounded-lg px-2 py-1 text-xs text-gray-300 focus:outline-none focus:ring-1 focus:ring-white/20" | |
| > | |
| <option value="day">Day</option> | |
| <option value="week">Week</option> | |
| <option value="month">Month</option> | |
| <option value="year">Year</option> | |
| </select> | |
| <button className="hidden sm:block p-2 hover:bg-white/10 rounded-full transition-colors text-gray-400 hover:text-white"> | |
| <Plus size={20} /> | |
| </button> | |
| <button className="hidden sm:block p-2 hover:bg-white/10 rounded-full transition-colors text-gray-400 hover:text-white"> | |
| <MagnifyingGlass size={20} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* View Content */} | |
| <div className="flex-1 overflow-hidden relative"> | |
| <AnimatePresence mode="wait"> | |
| {view === 'month' && ( | |
| <motion.div | |
| key="month" | |
| initial={{ opacity: 0, scale: 0.98 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 1.02 }} | |
| transition={{ duration: 0.2 }} | |
| className="h-full flex flex-col overflow-auto [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2" | |
| > | |
| <div className="flex-1 flex flex-col"> | |
| <div className="grid grid-cols-7 border-b border-white/5 bg-black/10 sticky top-0 z-10 backdrop-blur-sm"> | |
| {weekDays.map(day => ( | |
| <div key={day} className="py-1 sm:py-2 text-center sm:text-right pr-1 sm:pr-4 text-[10px] sm:text-xs font-medium text-gray-500 uppercase tracking-wider"> | |
| <span className="hidden sm:inline">{day}</span> | |
| <span className="sm:hidden">{day.charAt(0)}</span> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="flex-1 grid grid-cols-7 grid-rows-6"> | |
| {calendarDays.map((dateObj, index) => { | |
| const dateStr = `${dateObj.year}-${String(dateObj.month + 1).padStart(2, '0')}-${String(dateObj.day).padStart(2, '0')}` | |
| const dayEvents = getEventsForDate(dateStr) | |
| const isCurrentDay = isToday(dateObj.day, dateObj.month, dateObj.year) | |
| return ( | |
| <div | |
| key={index} | |
| className={` | |
| border-b border-r border-white/5 p-0.5 sm:p-1 relative group transition-colors min-h-[50px] sm:min-h-[80px] | |
| ${!dateObj.isCurrentMonth ? 'bg-black/20 text-gray-600' : 'hover:bg-white/5'} | |
| `} | |
| onClick={() => { | |
| setCurrentDate(new Date(dateObj.year, dateObj.month, dateObj.day)) | |
| setView('day') | |
| }} | |
| > | |
| <div className="flex justify-center sm:justify-end mb-0.5 sm:mb-1"> | |
| <span | |
| className={` | |
| text-xs sm:text-sm font-medium w-5 h-5 sm:w-7 sm:h-7 flex items-center justify-center rounded-full | |
| ${isCurrentDay | |
| ? 'bg-red-500 text-white shadow-lg shadow-red-900/50' | |
| : dateObj.isCurrentMonth ? 'text-gray-300' : 'text-gray-600'} | |
| `} | |
| > | |
| {dateObj.day} | |
| </span> | |
| </div> | |
| <div className="hidden sm:block space-y-0.5 overflow-y-auto max-h-[calc(100%-1.5rem)] [&::-webkit-scrollbar]:hidden px-0.5"> | |
| {dayEvents.slice(0, 3).map((event, i) => ( | |
| <div | |
| key={i} | |
| className={` | |
| text-[9px] px-1 py-0.5 rounded-sm truncate cursor-pointer hover:opacity-80 | |
| ${event.color || 'bg-blue-500'} text-white font-medium shadow-sm backdrop-blur-sm bg-opacity-80 | |
| `} | |
| title={event.title} | |
| > | |
| {event.title} | |
| </div> | |
| ))} | |
| {dayEvents.length > 3 && ( | |
| <div className="text-[8px] text-gray-400 px-1"> | |
| +{dayEvents.length - 3} more | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| {view === 'year' && ( | |
| <motion.div | |
| key="year" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="h-full overflow-y-auto p-4 md:p-8 [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2" | |
| > | |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-12"> | |
| {monthNames.map((month, monthIndex) => { | |
| const days = getDaysInMonth(currentDate.getFullYear(), monthIndex) | |
| return ( | |
| <div key={month} className="flex flex-col gap-4"> | |
| <h3 className="text-red-500 font-medium text-xl pl-1">{month}</h3> | |
| <div className="grid grid-cols-7 gap-y-3 text-center"> | |
| {weekDays.map(d => ( | |
| <div key={d} className="text-[10px] text-gray-500 font-medium">{d.charAt(0)}</div> | |
| ))} | |
| {days.map((d, i) => { | |
| if (!d.isCurrentMonth) return <div key={i} /> | |
| const isCurrentDay = isToday(d.day, d.month, d.year) | |
| return ( | |
| <div | |
| key={i} | |
| onClick={() => { | |
| setCurrentDate(new Date(d.year, d.month, d.day)) | |
| setView('month') | |
| }} | |
| className={` | |
| text-xs w-6 h-6 flex items-center justify-center rounded-full mx-auto cursor-pointer hover:bg-white/10 | |
| ${isCurrentDay ? 'bg-red-500 text-white font-bold' : 'text-gray-300'} | |
| `} | |
| > | |
| {d.day} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </motion.div> | |
| )} | |
| {(view === 'week' || view === 'day') && ( | |
| <motion.div | |
| key="week-day" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="h-full flex flex-col" | |
| > | |
| <div className="flex border-b border-white/10 shrink-0 pl-14 pr-4 py-2"> | |
| {view === 'week' ? getWeekDays().map((d, i) => { | |
| const isCurrentDay = isToday(d.getDate(), d.getMonth(), d.getFullYear()) | |
| return ( | |
| <div key={i} className="flex-1 text-center border-l border-white/5 first:border-l-0"> | |
| <div className={`text-xs font-medium mb-1 uppercase ${isCurrentDay ? 'text-red-500' : 'text-gray-500'}`}> | |
| {weekDays[d.getDay()]} | |
| </div> | |
| <div className={` | |
| text-xl font-light w-8 h-8 flex items-center justify-center rounded-full mx-auto | |
| ${isCurrentDay ? 'bg-red-500 text-white font-bold' : 'text-white'} | |
| `}> | |
| {d.getDate()} | |
| </div> | |
| </div> | |
| ) | |
| }) : ( | |
| <div className="flex-1 px-4"> | |
| <div className="text-red-500 font-medium uppercase text-sm">{weekDays[currentDate.getDay()]}</div> | |
| <div className="text-3xl font-light">{currentDate.getDate()}</div> | |
| </div> | |
| )} | |
| </div> | |
| <div ref={scrollContainerRef} className="flex-1 overflow-y-auto relative [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2"> | |
| <div className="absolute top-0 left-0 w-full min-h-full bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjYwIj48bGluZSB4MT0iMCIgeTE9IjYwIiB4Mj0iMTAwJSIgeTI9IjYwIiBzdHJva2U9InJnYmEoMjU1LDI1NSwyNTUsMC4wNSkiIHN0cm9rZS13aWR0aD0iMSIvPjwvc3ZnPg==')]"> | |
| {hours.map((hour) => ( | |
| <div key={hour} className="flex h-[60px] group"> | |
| <div className="w-14 shrink-0 text-right pr-3 text-xs text-gray-500 -mt-2.5 z-10"> | |
| {hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`} | |
| </div> | |
| <div className="flex-1 border-l border-white/5 relative group-hover:bg-white/[0.02] transition-colors"> | |
| {/* Time slots */} | |
| </div> | |
| </div> | |
| ))} | |
| {/* Current Time Indicator */} | |
| <div | |
| className="absolute left-14 right-0 border-t border-red-500 z-20 pointer-events-none" | |
| style={{ top: `${(new Date().getHours() * 60 + new Date().getMinutes())}px` }} | |
| > | |
| <div className="absolute -left-1.5 -top-1.5 w-3 h-3 rounded-full bg-red-500" /> | |
| </div> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| </div> | |
| </Window> | |
| ) | |
| } | |