458 lines
22 KiB
TypeScript
458 lines
22 KiB
TypeScript
import React, { useState, useMemo, useEffect } from 'react';
|
|
import {
|
|
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
|
eachDayOfInterval, isSameMonth, isSameDay, addMonths, isToday, parseISO
|
|
} from 'date-fns';
|
|
import { motion, AnimatePresence, PanInfo } from 'framer-motion';
|
|
import { ChevronLeft, ChevronRight, Plus, LogOut, Settings as SettingsIcon, Sparkles, Bot, Moon, Sun, Zap, Cloud, Wand2, Trash2, Calendar as CalIcon, Activity, X, ChevronDown } from 'lucide-react';
|
|
import { CalendarEvent, UserRole, UserStatus, AppSettings } from '../types';
|
|
import { Button } from './Button';
|
|
import { analyzeScheduleAndSuggest } from '../services/aiService';
|
|
import { Candle } from './Candle';
|
|
import { CosmicPulseModal } from './CosmicPulseModal';
|
|
|
|
interface CalendarViewProps {
|
|
events: CalendarEvent[];
|
|
currentUser: UserRole;
|
|
currentStatus: UserStatus;
|
|
onStatusChange: (status: UserStatus) => void;
|
|
onAddEvent: (date?: Date) => void;
|
|
onEditEvent: (event: CalendarEvent) => void;
|
|
onMoveEvent: (eventId: string, newDate: Date) => void;
|
|
onLogout: () => void;
|
|
onDeleteEvent: (id: string) => void;
|
|
onOpenSettings: () => void;
|
|
onOpenAssistant: () => void;
|
|
settings: AppSettings;
|
|
}
|
|
|
|
export const CalendarView: React.FC<CalendarViewProps> = ({
|
|
events, currentUser, currentStatus, onStatusChange, onAddEvent, onEditEvent,
|
|
onLogout, onDeleteEvent, onMoveEvent, onOpenSettings, onOpenAssistant, settings
|
|
}) => {
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
|
const [direction, setDirection] = useState(0);
|
|
const [showOracle, setShowOracle] = useState(false);
|
|
const [oracleWisdom, setOracleWisdom] = useState<string[]>([]);
|
|
const [isConsulting, setIsConsulting] = useState(false);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const [dragOverDate, setDragOverDate] = useState<string | null>(null);
|
|
const [isPulseOpen, setIsPulseOpen] = useState(false);
|
|
const [isAgendaOpen, setIsAgendaOpen] = useState(false); // New state to control sheet visibility
|
|
|
|
// Mock partner
|
|
const partnerRole: UserRole = currentUser === 'mom' ? 'dad' : 'mom';
|
|
const [partnerStatus, setPartnerStatus] = useState<UserStatus>('offline');
|
|
|
|
useEffect(() => {
|
|
const partnerTimer = setInterval(() => {
|
|
const statuses: UserStatus[] = ['online', 'busy', 'casting_spells', 'thinking'];
|
|
setPartnerStatus(statuses[Math.floor(Math.random() * statuses.length)]);
|
|
}, 10000);
|
|
return () => clearInterval(partnerTimer);
|
|
}, []);
|
|
|
|
const days = useMemo(() => {
|
|
const monthStart = startOfMonth(currentDate);
|
|
const startDate = startOfWeek(monthStart);
|
|
const endDate = endOfWeek(endOfMonth(monthStart));
|
|
return eachDayOfInterval({ start: startDate, end: endDate });
|
|
}, [currentDate]);
|
|
|
|
const changeMonth = (val: number) => {
|
|
setDirection(val);
|
|
setCurrentDate(prev => addMonths(prev, val));
|
|
};
|
|
|
|
const getDayEvents = (day: Date) => {
|
|
const dayEvents = events.filter(event => isSameDay(parseISO(event.startTime), day));
|
|
return dayEvents.sort((a, b) => {
|
|
if (a.category === 'magic' && b.category !== 'magic') return -1;
|
|
if (a.category !== 'magic' && b.category === 'magic') return 1;
|
|
return new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
|
|
});
|
|
};
|
|
|
|
const handleConsultOracle = async () => {
|
|
if (showOracle) { setShowOracle(false); return; }
|
|
setIsConsulting(true);
|
|
setShowOracle(true);
|
|
const wisdom = await analyzeScheduleAndSuggest(events, settings);
|
|
setOracleWisdom(wisdom);
|
|
setIsConsulting(false);
|
|
};
|
|
|
|
const getStatusIcon = (status: UserStatus) => {
|
|
switch (status) {
|
|
case 'online': return <Sun size={14} className="text-yellow-400" />;
|
|
case 'busy': return <Zap size={14} className="text-red-400" />;
|
|
case 'sleeping': return <Moon size={14} className="text-blue-300" />;
|
|
case 'casting_spells': return <Sparkles size={14} className="text-purple-400" />;
|
|
case 'thinking': return <Cloud size={14} className="text-gray-400" />;
|
|
default: return <div className="w-2 h-2 rounded-full bg-gray-500" />;
|
|
}
|
|
};
|
|
|
|
const statusOptions: {id: UserStatus, label: string, icon: any}[] = [
|
|
{ id: 'online', label: 'Present', icon: Sun },
|
|
{ id: 'busy', label: 'Busy', icon: Zap },
|
|
{ id: 'casting_spells', label: 'Magic', icon: Sparkles },
|
|
{ id: 'thinking', label: 'Thinking', icon: Cloud },
|
|
{ id: 'sleeping', label: 'Resting', icon: Moon },
|
|
];
|
|
|
|
const handleDragStart = (e: React.DragEvent, eventId: string) => {
|
|
if (!isEditMode) return;
|
|
e.dataTransfer.setData('eventId', eventId);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent, dateStr: string) => {
|
|
if (!isEditMode) return;
|
|
e.preventDefault();
|
|
setDragOverDate(dateStr);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent, date: Date) => {
|
|
if (!isEditMode) return;
|
|
e.preventDefault();
|
|
const eventId = e.dataTransfer.getData('eventId');
|
|
if (eventId) onMoveEvent(eventId, date);
|
|
setDragOverDate(null);
|
|
};
|
|
|
|
const handleTrashDrop = (e: React.DragEvent) => {
|
|
if (!isEditMode) return;
|
|
e.preventDefault();
|
|
const eventId = e.dataTransfer.getData('eventId');
|
|
if (eventId) onDeleteEvent(eventId);
|
|
};
|
|
|
|
const handleDayClick = (day: Date) => {
|
|
setSelectedDate(day);
|
|
setIsAgendaOpen(true); // Open sheet on tap
|
|
if (isSameDay(day, selectedDate) && !isEditMode) {
|
|
// Optional double tap behavior logic
|
|
}
|
|
};
|
|
|
|
const handleSheetDragEnd = (event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
|
if (info.offset.y > 100 || info.velocity.y > 500) {
|
|
setIsAgendaOpen(false);
|
|
}
|
|
};
|
|
|
|
const selectedDayEvents = getDayEvents(selectedDate);
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto p-4 md:p-8 flex flex-col h-screen max-h-screen relative z-10">
|
|
|
|
{/* Top Bar - Stacked on Mobile */}
|
|
<div className="flex flex-col md:flex-row justify-between items-center mb-4 gap-4">
|
|
{/* Status Bar */}
|
|
<div className="glass-panel rounded-full px-4 py-2 flex items-center justify-between w-full md:w-auto gap-4">
|
|
<div className="flex items-center gap-2 relative group cursor-pointer">
|
|
<div className={`w-8 h-8 rounded-full border-2 border-white/20 flex items-center justify-center ${currentUser === 'mom' ? 'bg-mom-primary' : 'bg-dad-primary'}`}>
|
|
{getStatusIcon(currentStatus)}
|
|
</div>
|
|
<span className="text-xs font-bold text-gray-300">{currentUser.toUpperCase()}</span>
|
|
|
|
<div className="absolute top-10 left-0 glass-panel rounded-xl p-2 hidden group-hover:block min-w-[150px] z-50 animate-fadeIn">
|
|
{statusOptions.map(opt => (
|
|
<button key={opt.id} onClick={() => onStatusChange(opt.id)} className="flex items-center gap-2 w-full p-2 hover:bg-white/10 rounded-lg text-left text-xs text-gray-300">
|
|
<opt.icon size={12} /> {opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 opacity-80">
|
|
<span className="text-xs font-bold text-gray-500">{partnerRole.toUpperCase()}</span>
|
|
<div className={`w-8 h-8 rounded-full border-2 border-white/10 flex items-center justify-center grayscale opacity-70`}>
|
|
{getStatusIcon(partnerStatus)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div className="flex items-end justify-center">
|
|
<Candle />
|
|
<div className="text-center">
|
|
<h1 className="font-magical text-3xl md:text-5xl text-transparent bg-clip-text bg-gradient-to-r from-purple-300 via-pink-300 to-blue-300 text-glow">
|
|
Celestial
|
|
</h1>
|
|
</div>
|
|
<Candle />
|
|
</div>
|
|
|
|
{/* Tools */}
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" onClick={() => setIsPulseOpen(true)} className={`!p-2 rounded-full text-pink-400 hover:text-white hover:bg-pink-900/30`}>
|
|
<Activity size={20} className="animate-pulse" />
|
|
</Button>
|
|
<Button variant="ghost" onClick={onOpenSettings} className="!p-2 rounded-full text-gray-400 hover:text-white"><SettingsIcon size={20} /></Button>
|
|
<Button variant="ghost" onClick={onOpenAssistant} className="!p-2 rounded-full text-blue-300 hover:text-white"><Bot size={20} /></Button>
|
|
<Button variant="ghost" onClick={onLogout} className="!p-2 rounded-full text-red-400 hover:text-red-200"><LogOut size={20} /></Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Glass Panel */}
|
|
<div className={`flex-1 flex flex-col glass-panel rounded-[2rem] p-4 md:p-6 shadow-2xl relative overflow-hidden border transition-colors duration-500 ${isEditMode ? 'border-pink-500/40 bg-pink-900/10' : 'border-purple-500/10'}`}>
|
|
|
|
{/* Calendar Navigation */}
|
|
<div className="flex justify-between items-center mb-4">
|
|
<button onClick={() => changeMonth(-1)} className="p-2 hover:bg-white/10 rounded-full text-purple-200"><ChevronLeft /></button>
|
|
<h2 className="text-xl font-magical font-bold text-white cursor-pointer hover:text-purple-300 transition-colors" onClick={() => setCurrentDate(new Date())}>
|
|
{format(currentDate, 'MMMM yyyy')}
|
|
</h2>
|
|
<button onClick={() => changeMonth(1)} className="p-2 hover:bg-white/10 rounded-full text-purple-200"><ChevronRight /></button>
|
|
</div>
|
|
|
|
{/* Oracle Popup */}
|
|
<AnimatePresence>
|
|
{showOracle && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="mb-4 bg-indigo-950/50 border border-indigo-500/30 rounded-2xl overflow-hidden"
|
|
>
|
|
<div className="p-4">
|
|
<h3 className="text-purple-300 font-magical text-sm mb-2 flex items-center gap-2">
|
|
<Sparkles size={14} /> The Stars Suggest...
|
|
</h3>
|
|
{isConsulting ? (
|
|
<p className="text-xs text-gray-400 animate-pulse">Gazing into the void...</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
{oracleWisdom.map((wis, i) => (
|
|
<div key={i} className="bg-white/5 p-3 rounded-xl text-xs text-indigo-200 border border-white/5 hover:bg-white/10 transition-colors cursor-pointer" onClick={() => onAddEvent()}>
|
|
{wis}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Days Header */}
|
|
<div className="grid grid-cols-7 mb-2 text-center">
|
|
{['S','M','T','W','T','F','S'].map(d => (
|
|
<div key={d} className="font-magical text-purple-400/60 text-xs py-1">{d}</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Calendar Grid */}
|
|
<div className="flex-1 relative">
|
|
<AnimatePresence initial={false} custom={direction} mode="wait">
|
|
<motion.div
|
|
key={currentDate.toISOString()}
|
|
custom={direction}
|
|
variants={{
|
|
enter: (d) => ({ x: d > 0 ? 50 : -50, opacity: 0 }),
|
|
center: { x: 0, opacity: 1 },
|
|
exit: (d) => ({ x: d > 0 ? -50 : 50, opacity: 0 })
|
|
}}
|
|
initial="enter"
|
|
animate="center"
|
|
exit="exit"
|
|
transition={{ duration: 0.3 }}
|
|
className="grid grid-cols-7 grid-rows-5 h-full gap-1 md:gap-2 pb-20 md:pb-0"
|
|
>
|
|
{days.map((day) => {
|
|
const dayEvents = getDayEvents(day);
|
|
const isCurrentMonth = isSameMonth(day, currentDate);
|
|
const isSelected = isSameDay(day, selectedDate);
|
|
const isTodayDate = isToday(day);
|
|
const dayKey = day.toISOString();
|
|
const isDragOver = dragOverDate === dayKey;
|
|
|
|
return (
|
|
<div
|
|
key={dayKey}
|
|
onClick={() => handleDayClick(day)}
|
|
onDragOver={(e) => handleDragOver(e, dayKey)}
|
|
onDrop={(e) => handleDrop(e, day)}
|
|
className={`
|
|
relative p-1 md:p-2 rounded-xl border transition-all duration-200 flex flex-col items-center justify-start
|
|
${isSelected ? 'bg-white/10 border-purple-500/50' : 'border-transparent'}
|
|
${isDragOver ? 'bg-purple-500/30 border-purple-400 scale-105 z-10' : ''}
|
|
${isCurrentMonth ? 'opacity-100' : 'opacity-30'}
|
|
${isTodayDate ? 'shadow-[0_0_10px_rgba(168,85,247,0.3)] border-purple-500/30' : ''}
|
|
`}
|
|
>
|
|
<span className={`text-[10px] md:text-xs font-bold ${isTodayDate ? 'text-purple-300' : 'text-gray-400'}`}>
|
|
{format(day, 'd')}
|
|
</span>
|
|
|
|
{/* Desktop View: Text List */}
|
|
<div className="hidden md:flex flex-col w-full gap-1 mt-1 overflow-y-auto max-h-[80px] custom-scrollbar">
|
|
{dayEvents.map(ev => (
|
|
<div
|
|
key={ev.id}
|
|
draggable={isEditMode}
|
|
onDragStart={(e) => handleDragStart(e, ev.id)}
|
|
onClick={(e) => { e.stopPropagation(); onEditEvent(ev); }}
|
|
className={`
|
|
text-[10px] px-1 rounded truncate cursor-pointer hover:brightness-110
|
|
${ev.category === 'date' ? 'bg-red-900/60 text-red-200' :
|
|
ev.category === 'magic' ? 'bg-gradient-to-r from-purple-600 to-blue-600 text-white' :
|
|
'bg-gray-800/80 text-gray-300'}
|
|
`}
|
|
>
|
|
{ev.title}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Mobile View: Dots */}
|
|
<div className="flex md:hidden flex-wrap justify-center gap-1 mt-1">
|
|
{dayEvents.slice(0, 4).map(ev => (
|
|
<div
|
|
key={ev.id}
|
|
className={`w-1.5 h-1.5 rounded-full ${
|
|
ev.category === 'date' ? 'bg-red-500' :
|
|
ev.category === 'magic' ? 'bg-purple-400 shadow-[0_0_4px_#a855f7]' :
|
|
'bg-gray-500'
|
|
}`}
|
|
/>
|
|
))}
|
|
{dayEvents.length > 4 && <div className="w-1.5 h-1.5 rounded-full bg-white/50" />}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Mobile Agenda Sheet (Replaces inline list) */}
|
|
<AnimatePresence>
|
|
{selectedDate && isAgendaOpen && (
|
|
<motion.div
|
|
initial={{ y: "100%" }}
|
|
animate={{ y: 0 }}
|
|
exit={{ y: "100%" }}
|
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
|
drag="y"
|
|
dragConstraints={{ top: 0 }}
|
|
dragElastic={0.05}
|
|
onDragEnd={handleSheetDragEnd}
|
|
className="fixed bottom-0 left-0 right-0 z-40 bg-[#0B0B15]/95 backdrop-blur-2xl rounded-t-[2rem] border-t border-white/10 shadow-[0_-10px_50px_rgba(0,0,0,0.8)] md:hidden flex flex-col h-[50vh]"
|
|
>
|
|
{/* Drag Handle */}
|
|
<div
|
|
className="w-full flex flex-col items-center justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing touch-none"
|
|
onClick={() => setIsAgendaOpen(false)}
|
|
>
|
|
<div className="w-12 h-1.5 bg-gray-600/50 rounded-full mb-1"></div>
|
|
<ChevronDown size={14} className="text-gray-600/50" />
|
|
</div>
|
|
|
|
<div className="p-6 pt-2 flex-1 overflow-hidden flex flex-col">
|
|
{/* Sheet Header */}
|
|
<div className="flex justify-between items-center mb-4 shrink-0">
|
|
<div>
|
|
<h3 className="text-2xl font-magical text-white">{format(selectedDate, 'EEEE')}</h3>
|
|
<p className="text-sm text-purple-300 font-medium">{format(selectedDate, 'MMMM do')}</p>
|
|
</div>
|
|
<Button onClick={() => onAddEvent(selectedDate)} className="!p-0 w-10 h-10 rounded-full bg-gradient-to-tr from-purple-600 to-pink-600 shadow-lg shadow-purple-900/40">
|
|
<Plus size={20} className="text-white" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Events List */}
|
|
<div className="space-y-3 overflow-y-auto pb-20 custom-scrollbar">
|
|
{selectedDayEvents.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-10 text-gray-500">
|
|
<Moon className="w-8 h-8 mb-2 opacity-20" />
|
|
<p className="text-sm italic">No cosmic events planned.</p>
|
|
</div>
|
|
) : (
|
|
selectedDayEvents.map(ev => (
|
|
<motion.div
|
|
key={ev.id}
|
|
layoutId={ev.id}
|
|
onClick={() => onEditEvent(ev)}
|
|
className="bg-white/5 border border-white/5 p-4 rounded-2xl flex items-center gap-4 active:scale-95 transition-transform shadow-sm"
|
|
>
|
|
<div className={`w-2 h-10 rounded-full ${
|
|
ev.category === 'date' ? 'bg-red-500 shadow-[0_0_10px_#ef4444]' :
|
|
ev.category === 'magic' ? 'bg-purple-500 shadow-[0_0_10px_#a855f7]' :
|
|
'bg-blue-500'
|
|
}`} />
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-bold text-lg text-white truncate">{ev.title}</h4>
|
|
<p className="text-xs text-gray-400 flex items-center gap-1">
|
|
{format(parseISO(ev.startTime), 'h:mm a')} <span className="w-1 h-1 bg-gray-600 rounded-full"/> {ev.category}
|
|
</p>
|
|
</div>
|
|
<div className="p-2 bg-white/5 rounded-full">
|
|
<ChevronRight size={16} className="text-gray-500" />
|
|
</div>
|
|
</motion.div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Edit/Trash Actions */}
|
|
<div className="absolute bottom-6 right-6 md:right-6 flex flex-col gap-3 items-center z-50 pointer-events-none">
|
|
<div className="pointer-events-auto flex flex-col gap-3">
|
|
<Button
|
|
onClick={() => setIsEditMode(!isEditMode)}
|
|
className={`!rounded-full w-12 h-12 !p-0 border shadow-lg transition-all ${isEditMode ? 'bg-pink-600 border-pink-400 animate-pulse' : 'bg-black/60 border-white/10 text-gray-400'}`}
|
|
>
|
|
<Wand2 size={20} className={isEditMode ? "text-white" : ""} />
|
|
</Button>
|
|
|
|
{/* FAB (Visible on Desktop OR when Mobile Agenda is closed) */}
|
|
<AnimatePresence>
|
|
{!isEditMode && (!isAgendaOpen || window.innerWidth >= 768) && (
|
|
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
|
<Button onClick={() => onAddEvent(selectedDate)} className="!rounded-full w-14 h-14 !p-0 bg-gradient-to-tr from-purple-600 to-blue-600 shadow-[0_0_20px_rgba(124,58,237,0.4)] border border-white/20">
|
|
<Plus className="text-white" size={28} />
|
|
</Button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
{/* The Void (Trash) */}
|
|
<AnimatePresence>
|
|
{isEditMode && (
|
|
<motion.div
|
|
initial={{ y: 100 }} animate={{ y: 0 }} exit={{ y: 100 }}
|
|
className="absolute bottom-8 left-1/2 -translate-x-1/2 z-50"
|
|
>
|
|
<div
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={handleTrashDrop}
|
|
className="w-20 h-20 md:w-24 md:h-24 rounded-full bg-black border-2 border-red-900 shadow-[0_0_40px_rgba(255,0,0,0.3)] flex items-center justify-center relative overflow-hidden group backdrop-blur-md"
|
|
>
|
|
<div className="absolute inset-0 bg-[conic-gradient(from_0deg,transparent_0deg,rgba(255,0,0,0.5)_360deg)] animate-spin-slow opacity-50"></div>
|
|
<Trash2 size={28} className="text-red-500/70 z-10 group-hover:text-red-200 md:w-8 md:h-8" />
|
|
<span className="absolute bottom-3 text-[8px] text-red-500 uppercase font-bold tracking-widest">Void</span>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
</div>
|
|
|
|
{/* Pulse Modal */}
|
|
<CosmicPulseModal
|
|
isOpen={isPulseOpen}
|
|
onClose={() => setIsPulseOpen(false)}
|
|
events={events}
|
|
settings={settings}
|
|
/>
|
|
</div>
|
|
);
|
|
}; |