187
components/AssistantView.tsx
Normal file
187
components/AssistantView.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Send, Bot, User as UserIcon, X, Loader2 } from 'lucide-react';
|
||||
import { ChatMessage, UserRole, AppSettings, CalendarEvent } from '../types';
|
||||
import { generateChatResponse } from '../services/aiService';
|
||||
|
||||
interface AssistantViewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentUser: UserRole;
|
||||
settings: AppSettings;
|
||||
onEventCreated: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export const AssistantView: React.FC<AssistantViewProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentUser,
|
||||
settings,
|
||||
onEventCreated
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: `Hi ${currentUser === 'mom' ? 'Mom' : 'Dad'}! I am your cosmic assistant. How can I help you plan?`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isOpen]);
|
||||
|
||||
const handleSend = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
const { text, event } = await generateChatResponse(input, settings, currentUser);
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMsg]);
|
||||
setIsLoading(false);
|
||||
|
||||
if (event) {
|
||||
onEventCreated(event);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
{/* Side Panel */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed inset-y-0 right-0 w-full md:w-[450px] bg-gray-900 border-l border-gray-800 shadow-2xl z-50 flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-800 flex justify-between items-center bg-gray-900/95 backdrop-blur">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-purple-600 to-blue-600 flex items-center justify-center shadow-lg shadow-purple-900/50">
|
||||
<Bot className="text-white" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Home Assistant</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<span className="text-xs text-gray-400 font-mono">
|
||||
{settings.aiProvider === 'ollama' ? 'Local Spirit' : 'Gemini Cloud'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-800 rounded-full text-gray-400 hover:text-white transition-colors">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
|
||||
{messages.map((msg) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
key={msg.id}
|
||||
className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}
|
||||
>
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center shrink-0
|
||||
${msg.role === 'user'
|
||||
? (currentUser === 'mom' ? 'bg-mom-primary' : 'bg-dad-primary')
|
||||
: 'bg-gray-700'}
|
||||
`}>
|
||||
{msg.role === 'user' ? <UserIcon size={14} className="text-white" /> : <Bot size={14} className="text-purple-300" />}
|
||||
</div>
|
||||
|
||||
<div className={`
|
||||
max-w-[80%] p-3 rounded-2xl text-sm leading-relaxed
|
||||
${msg.role === 'user'
|
||||
? 'bg-gray-800 text-white rounded-tr-none border border-gray-700'
|
||||
: 'bg-purple-900/20 text-gray-200 rounded-tl-none border border-purple-500/20'}
|
||||
`}>
|
||||
{msg.content}
|
||||
<div className="text-[10px] opacity-30 mt-1 text-right">
|
||||
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center shrink-0">
|
||||
<Bot size={14} className="text-purple-300" />
|
||||
</div>
|
||||
<div className="bg-purple-900/20 p-3 rounded-2xl rounded-tl-none border border-purple-500/20 flex items-center gap-2">
|
||||
<Loader2 size={16} className="animate-spin text-purple-400" />
|
||||
<span className="text-xs text-purple-300">Consulting the stars...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 bg-gray-900/95 backdrop-blur border-t border-gray-800">
|
||||
<form onSubmit={handleSend} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Schedule a date on Friday at 7pm..."
|
||||
className="w-full bg-gray-800 text-white pl-4 pr-12 py-3 rounded-xl border border-gray-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none placeholder-gray-500 transition-all"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="absolute right-2 top-2 p-1.5 bg-purple-600 hover:bg-purple-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
52
components/Button.tsx
Normal file
52
components/Button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
roleTheme?: 'mom' | 'dad' | 'neutral';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
roleTheme = 'neutral',
|
||||
isLoading,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
|
||||
let baseStyles = "px-6 py-3 rounded-2xl font-bold transition-all duration-200 flex items-center justify-center gap-2 outline-none focus:ring-4 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
const getThemeColors = () => {
|
||||
if (variant === 'ghost') return "bg-transparent hover:bg-gray-100 text-gray-600 focus:ring-gray-200";
|
||||
if (variant === 'danger') return "bg-red-100 text-red-600 hover:bg-red-200 focus:ring-red-100";
|
||||
if (variant === 'secondary') return "bg-white text-gray-700 border-2 border-gray-100 hover:border-gray-200 focus:ring-gray-100";
|
||||
|
||||
switch (roleTheme) {
|
||||
case 'mom':
|
||||
return "bg-mom-primary text-white hover:bg-pink-500 shadow-lg shadow-pink-200 focus:ring-pink-200";
|
||||
case 'dad':
|
||||
return "bg-dad-primary text-white hover:bg-blue-500 shadow-lg shadow-blue-200 focus:ring-blue-200";
|
||||
default:
|
||||
return "bg-gray-800 text-white hover:bg-gray-900 shadow-lg shadow-gray-300 focus:ring-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={`${baseStyles} ${getThemeColors()} ${className}`}
|
||||
disabled={isLoading || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : children}
|
||||
</motion.button>
|
||||
);
|
||||
};
|
||||
458
components/CalendarView.tsx
Normal file
458
components/CalendarView.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
23
components/Candle.tsx
Normal file
23
components/Candle.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Candle: React.FC = () => {
|
||||
return (
|
||||
<div className="relative w-8 h-24 mx-2">
|
||||
{/* Flame */}
|
||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 w-4 h-8 candle-flame animate-flicker z-20 shadow-[0_0_20px_rgba(255,165,0,0.8)]"></div>
|
||||
|
||||
{/* Wick */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-0.5 h-2 bg-black z-10 opacity-50"></div>
|
||||
|
||||
{/* Candle Body */}
|
||||
<div className="absolute top-2 w-full h-full bg-gradient-to-b from-purple-100 to-purple-300 rounded-t-lg shadow-inner">
|
||||
{/* Drip */}
|
||||
<div className="absolute top-0 left-0 w-full h-4 bg-purple-100 rounded-t-lg blur-[1px]"></div>
|
||||
<div className="absolute top-2 left-1 w-2 h-6 bg-purple-100 rounded-b-full opacity-80 shadow-sm"></div>
|
||||
</div>
|
||||
|
||||
{/* Glow Base */}
|
||||
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-16 h-4 bg-purple-500/20 blur-xl rounded-full"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
173
components/CosmicPulseModal.tsx
Normal file
173
components/CosmicPulseModal.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Activity, Heart, AlertTriangle, Zap, MessageCircle, Moon, Cloud } from 'lucide-react';
|
||||
import { AppSettings, CalendarEvent, PulseAnalysis } from '../types';
|
||||
import { analyzeRelationshipPulse, interpretDream } from '../services/aiService';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface CosmicPulseModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
events: CalendarEvent[];
|
||||
settings: AppSettings;
|
||||
}
|
||||
|
||||
export const CosmicPulseModal: React.FC<CosmicPulseModalProps> = ({ isOpen, onClose, events, settings }) => {
|
||||
const [analysis, setAnalysis] = useState<PulseAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dreamText, setDreamText] = useState('');
|
||||
const [dreamResult, setDreamResult] = useState('');
|
||||
const [dreamLoading, setDreamLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLoading(true);
|
||||
analyzeRelationshipPulse(events, settings).then(res => {
|
||||
setAnalysis(res);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
// Reset state when closed
|
||||
setAnalysis(null);
|
||||
setDreamResult('');
|
||||
setDreamText('');
|
||||
}
|
||||
}, [isOpen, events, settings]);
|
||||
|
||||
const handleInterpretDream = async () => {
|
||||
if (!dreamText) return;
|
||||
setDreamLoading(true);
|
||||
const res = await interpretDream(dreamText, settings);
|
||||
setDreamResult(res);
|
||||
setDreamLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-md z-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0, y: 50 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.8, opacity: 0, y: 50 }}
|
||||
className="fixed inset-0 flex items-center justify-center z-50 pointer-events-none p-4"
|
||||
>
|
||||
<div className="glass-panel w-full max-w-lg rounded-3xl overflow-hidden pointer-events-auto flex flex-col max-h-[90vh] border border-pink-500/30 shadow-[0_0_60px_rgba(236,72,153,0.3)]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-white/10 flex justify-between items-center bg-black/40">
|
||||
<h2 className="text-2xl font-magical text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-purple-400 flex items-center gap-3">
|
||||
<Activity className="animate-pulse text-pink-500" /> Cosmic Pulse
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-gray-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar">
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 border-4 border-pink-500/30 border-t-pink-500 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Activity size={24} className="text-pink-500 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-pink-300 font-magical animate-pulse">Analyzing Relationship Rythm...</p>
|
||||
<p className="text-xs text-gray-500">Using {settings.aiProvider === 'ollama' ? 'Local Spirit (Ollama)' : 'Cloud Mind (Gemini)'}</p>
|
||||
</div>
|
||||
) : analysis ? (
|
||||
<>
|
||||
{/* Harmony Score */}
|
||||
<div className="bg-gradient-to-br from-purple-900/50 to-pink-900/50 p-6 rounded-3xl border border-pink-500/20 text-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/stardust.png')] opacity-20"></div>
|
||||
<p className="text-pink-200 text-sm font-bold uppercase tracking-widest mb-2">Harmony Score</p>
|
||||
<div className="text-6xl font-black text-white drop-shadow-[0_0_15px_rgba(236,72,153,0.5)]">
|
||||
{analysis.harmonyScore}
|
||||
</div>
|
||||
<div className="mt-2 inline-block px-4 py-1 rounded-full bg-white/10 text-sm font-bold text-pink-200 border border-white/10">
|
||||
Current Vibe: {analysis.vibe}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{analysis.burnoutWarning && (
|
||||
<motion.div initial={{ x: -20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} className="bg-red-900/30 border border-red-500/30 p-4 rounded-2xl flex items-start gap-3">
|
||||
<AlertTriangle className="text-red-400 shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-bold text-red-200 text-sm">Burnout Warning</h4>
|
||||
<p className="text-xs text-red-300/80 mt-1">{analysis.burnoutWarning}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Conflicts */}
|
||||
{analysis.upcomingConflicts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<Zap size={14} /> Friction Points
|
||||
</h3>
|
||||
{analysis.upcomingConflicts.map((c, i) => (
|
||||
<div key={i} className="bg-orange-900/20 border border-orange-500/20 p-3 rounded-xl text-xs text-orange-200">
|
||||
{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Nudges */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<Heart size={14} /> Smart Nudges
|
||||
</h3>
|
||||
{analysis.smartNudges.map((nudge, i) => (
|
||||
<div key={i} className="bg-gradient-to-r from-blue-900/30 to-purple-900/30 border border-blue-500/20 p-3 rounded-xl flex items-center gap-3">
|
||||
<MessageCircle size={16} className="text-blue-300" />
|
||||
<p className="text-sm text-blue-100 italic">"{nudge}"</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Dream Journal Section - Always visible */}
|
||||
<div className="border-t border-white/10 pt-6">
|
||||
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2 mb-3">
|
||||
<Moon size={14} /> Dream Journal
|
||||
</h3>
|
||||
<div className="bg-indigo-950/40 rounded-2xl p-4 border border-indigo-500/20">
|
||||
<textarea
|
||||
value={dreamText}
|
||||
onChange={(e) => setDreamText(e.target.value)}
|
||||
placeholder="I dreamt I was flying over a grocery store made of clouds..."
|
||||
className="w-full bg-black/30 border-0 rounded-xl p-3 text-sm text-indigo-100 placeholder-indigo-400/50 resize-none h-20 focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<span className="text-[10px] text-gray-500">AI Interpreter Ready</span>
|
||||
<Button size="sm" onClick={handleInterpretDream} disabled={!dreamText || dreamLoading} className="text-xs py-1 px-3 bg-indigo-600 hover:bg-indigo-500">
|
||||
{dreamLoading ? <Cloud className="animate-bounce" size={14}/> : 'Interpret'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dreamResult && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-4 p-3 bg-white/5 rounded-xl border border-white/5">
|
||||
<p className="text-xs text-purple-200 italic">✨ {dreamResult}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
308
components/EventModal.tsx
Normal file
308
components/EventModal.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Sparkles, Calendar as CalendarIcon, Clock, Briefcase, Heart, Home, Smile, Tag, Trash2 } from 'lucide-react';
|
||||
import { UserRole, CalendarEvent, EventCategory } from '../types';
|
||||
import { Button } from './Button';
|
||||
import { parseNaturalLanguageEvent } from '../services/aiService';
|
||||
import { getSettings } from '../services/storageService';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface EventModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (event: CalendarEvent) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
currentUserRole: UserRole;
|
||||
selectedDate?: Date;
|
||||
initialEvent?: CalendarEvent | null;
|
||||
}
|
||||
|
||||
export const EventModal: React.FC<EventModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
currentUserRole,
|
||||
selectedDate,
|
||||
initialEvent
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'manual' | 'ai'>('manual');
|
||||
const [aiPrompt, setAiPrompt] = useState('');
|
||||
const [isProcessingAi, setIsProcessingAi] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [title, setTitle] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('10:00');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState<EventCategory>('general');
|
||||
|
||||
// Effect to load initial data for editing or reset for new
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (initialEvent) {
|
||||
setTitle(initialEvent.title);
|
||||
setDescription(initialEvent.description || '');
|
||||
setCategory(initialEvent.category);
|
||||
|
||||
const start = new Date(initialEvent.startTime);
|
||||
const end = new Date(initialEvent.endTime);
|
||||
|
||||
setStartDate(start.toISOString().split('T')[0]);
|
||||
setStartTime(start.toTimeString().slice(0, 5));
|
||||
setEndTime(end.toTimeString().slice(0, 5));
|
||||
setActiveTab('manual');
|
||||
} else {
|
||||
// Reset
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setCategory('general');
|
||||
setAiPrompt('');
|
||||
|
||||
if (selectedDate) {
|
||||
const year = selectedDate.getFullYear();
|
||||
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(selectedDate.getDate()).padStart(2, '0');
|
||||
setStartDate(`${year}-${month}-${day}`);
|
||||
} else {
|
||||
setStartDate(new Date().toISOString().split('T')[0]);
|
||||
}
|
||||
setStartTime('09:00');
|
||||
setEndTime('10:00');
|
||||
setActiveTab('manual');
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialEvent, selectedDate]);
|
||||
|
||||
const handleAiSubmit = async () => {
|
||||
if (!aiPrompt.trim()) return;
|
||||
setIsProcessingAi(true);
|
||||
const settings = getSettings();
|
||||
const result = await parseNaturalLanguageEvent(aiPrompt, settings);
|
||||
setIsProcessingAi(false);
|
||||
|
||||
if (result) {
|
||||
setTitle(result.title);
|
||||
setDescription(result.description || '');
|
||||
|
||||
const start = new Date(result.startTime);
|
||||
const end = new Date(result.endTime);
|
||||
|
||||
setStartDate(start.toISOString().split('T')[0]);
|
||||
setStartTime(start.toTimeString().slice(0, 5));
|
||||
setEndTime(end.toTimeString().slice(0, 5));
|
||||
setCategory((result.category as EventCategory) || 'general');
|
||||
|
||||
setActiveTab('manual'); // Switch to manual to review
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const startDateTime = new Date(`${startDate}T${startTime}`);
|
||||
const endDateTime = new Date(`${startDate}T${endTime}`);
|
||||
|
||||
const newEvent: CalendarEvent = {
|
||||
id: initialEvent ? initialEvent.id : uuidv4(),
|
||||
title,
|
||||
description,
|
||||
startTime: startDateTime.toISOString(),
|
||||
endTime: endDateTime.toISOString(),
|
||||
createdBy: initialEvent ? initialEvent.createdBy : currentUserRole,
|
||||
category
|
||||
};
|
||||
|
||||
onSave(newEvent);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (initialEvent && onDelete) {
|
||||
onDelete(initialEvent.id);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const categories: { id: EventCategory; label: string; icon: any; color: string }[] = [
|
||||
{ id: 'general', label: 'General', icon: Tag, color: 'bg-gray-800 text-gray-300 border-gray-600' },
|
||||
{ id: 'work', label: 'Work', icon: Briefcase, color: 'bg-orange-900/30 text-orange-400 border-orange-800' },
|
||||
{ id: 'chore', label: 'Chore', icon: Home, color: 'bg-green-900/30 text-green-400 border-green-800' },
|
||||
{ id: 'date', label: 'Date', icon: Heart, color: 'bg-red-900/30 text-red-400 border-red-800' },
|
||||
{ id: 'fun', label: 'Fun', icon: Smile, color: 'bg-yellow-900/30 text-yellow-400 border-yellow-800' },
|
||||
{ id: 'magic', label: 'Magic', icon: Sparkles, color: 'bg-purple-900/30 text-purple-400 border-purple-800' },
|
||||
];
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
className="fixed inset-0 flex items-center justify-center z-50 pointer-events-none"
|
||||
>
|
||||
<div className="glass-panel w-full max-w-md mx-4 rounded-3xl overflow-hidden pointer-events-auto flex flex-col max-h-[90vh] border border-white/10 shadow-2xl">
|
||||
|
||||
{/* Header */}
|
||||
<div className={`p-6 ${currentUserRole === 'mom' ? 'bg-mom-soft' : 'bg-dad-soft'} flex justify-between items-center`}>
|
||||
<h2 className={`text-2xl font-extrabold ${currentUserRole === 'mom' ? 'text-mom-primary' : 'text-dad-primary'}`}>
|
||||
{initialEvent ? 'Edit Event' : 'New Event'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-black/20 rounded-full transition-colors">
|
||||
<X size={24} className={currentUserRole === 'mom' ? 'text-mom-primary' : 'text-dad-primary'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs (Only for New Events) */}
|
||||
{!initialEvent && (
|
||||
<div className="flex border-b border-white/5 p-2 gap-2 bg-gray-900/30">
|
||||
<button
|
||||
onClick={() => setActiveTab('manual')}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-bold transition-all ${activeTab === 'manual' ? 'bg-gray-700 text-white' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
Manual Entry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ai')}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-bold transition-all flex items-center justify-center gap-2 ${activeTab === 'ai' ? 'bg-purple-900/40 text-purple-300 border border-purple-500/20' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
<Sparkles size={16} /> Magic Plan
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar">
|
||||
{activeTab === 'ai' && !initialEvent ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-purple-900/20 p-4 rounded-2xl border border-purple-500/20">
|
||||
<p className="text-purple-300 text-sm font-medium mb-2">Try saying...</p>
|
||||
<p className="text-purple-400/70 text-xs italic">"Dinner with Mom next Friday at 7pm"</p>
|
||||
<p className="text-purple-400/70 text-xs italic">"Weekend getaway to the beach July 12-14"</p>
|
||||
</div>
|
||||
<textarea
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
placeholder="Describe your event..."
|
||||
className="w-full h-32 p-4 rounded-2xl bg-gray-800 border-2 border-transparent focus:border-purple-500 focus:ring-0 resize-none transition-colors text-white placeholder-gray-500"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAiSubmit}
|
||||
disabled={!aiPrompt.trim()}
|
||||
isLoading={isProcessingAi}
|
||||
className="w-full bg-purple-600 hover:bg-purple-500 text-white"
|
||||
>
|
||||
<Sparkles size={18} /> Generate Details
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Event Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="What's happening?"
|
||||
className="w-full p-4 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 focus:border-transparent transition-all font-bold text-lg text-white placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Smart Category</label>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
|
||||
{categories.map((cat) => {
|
||||
const Icon = cat.icon;
|
||||
const isSelected = category === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setCategory(cat.id)}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap border-2
|
||||
${isSelected ? `${cat.color} border-current` : 'bg-gray-800 text-gray-500 border-transparent hover:bg-gray-700'}
|
||||
`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{cat.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Date</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full p-3 pl-10 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 text-white"
|
||||
/>
|
||||
<CalendarIcon className="absolute left-3 top-3.5 text-gray-500" size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Time</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="w-full p-3 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 text-sm text-white"
|
||||
/>
|
||||
<span className="text-gray-500">-</span>
|
||||
<input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full p-3 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Notes</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add details, location, etc."
|
||||
className="w-full p-4 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 focus:border-transparent transition-all resize-none h-24 text-white placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex gap-3">
|
||||
{initialEvent && (
|
||||
<Button type="button" variant="danger" onClick={handleDelete} className="flex-1">
|
||||
<Trash2 size={18} /> Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" roleTheme={currentUserRole} className="flex-[2]">
|
||||
{initialEvent ? 'Save Changes' : 'Create Event'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
76
components/FairyDust.tsx
Normal file
76
components/FairyDust.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
id: number;
|
||||
size: number;
|
||||
color: string;
|
||||
velocity: { x: number; y: number };
|
||||
life: number;
|
||||
}
|
||||
|
||||
export const FairyDust: React.FC = () => {
|
||||
const [points, setPoints] = useState<Point[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const colors = ['#FF00CC', '#3333FF', '#7000FF', '#FFFFFF', '#00FFFF'];
|
||||
const newPoint: Point = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
id: Date.now() + Math.random(),
|
||||
size: Math.random() * 4 + 2,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
velocity: {
|
||||
x: (Math.random() - 0.5) * 2,
|
||||
y: (Math.random() - 0.5) * 2
|
||||
},
|
||||
life: 1.0
|
||||
};
|
||||
setPoints(prev => [...prev.slice(-40), newPoint]); // Limit trail length
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPoints(prev => prev
|
||||
.map(p => ({
|
||||
...p,
|
||||
x: p.x + p.velocity.x,
|
||||
y: p.y + p.velocity.y,
|
||||
life: p.life - 0.05
|
||||
}))
|
||||
.filter(p => p.life > 0)
|
||||
);
|
||||
}, 16); // 60fps
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-[9999]">
|
||||
{points.map(point => (
|
||||
<div
|
||||
key={point.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: point.x,
|
||||
top: point.y,
|
||||
width: point.size,
|
||||
height: point.size,
|
||||
backgroundColor: point.color,
|
||||
borderRadius: '50%',
|
||||
opacity: point.life,
|
||||
transform: `scale(${point.life})`,
|
||||
boxShadow: `0 0 ${point.size * 2}px ${point.color}`,
|
||||
transition: 'opacity 0.1s linear'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
106
components/LoginScreen.tsx
Normal file
106
components/LoginScreen.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { UserRole } from '../types';
|
||||
import { Button } from './Button';
|
||||
import { Heart } from 'lucide-react';
|
||||
|
||||
interface LoginScreenProps {
|
||||
onLogin: (role: UserRole) => void;
|
||||
}
|
||||
|
||||
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const user = username.toLowerCase().trim();
|
||||
const pass = password.trim();
|
||||
|
||||
if (pass !== 'password') {
|
||||
setError('Wrong password! Hint: password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (user === 'mom') {
|
||||
onLogin('mom');
|
||||
} else if (user === 'dad') {
|
||||
onLogin('dad');
|
||||
} else {
|
||||
setError('Username must be "mom" or "dad"');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gray-900">
|
||||
{/* Background decorative blobs */}
|
||||
<div className="fixed top-0 left-0 w-64 h-64 bg-purple-600 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||||
<div className="fixed top-0 right-0 w-64 h-64 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||
<div className="fixed -bottom-8 left-20 w-64 h-64 bg-pink-600 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="glass-panel p-8 rounded-[2.5rem] shadow-2xl w-full max-w-sm border border-white/10 relative z-10"
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-20 h-20 bg-gradient-to-tr from-pink-500 to-blue-500 rounded-full mx-auto mb-4 flex items-center justify-center shadow-lg shadow-purple-500/20">
|
||||
<Heart className="text-white fill-white" size={40} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-white tracking-tight">Welcome Home</h1>
|
||||
<p className="text-gray-400 font-medium mt-1">Shared Calendar App</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Who are you? (mom/dad)"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="w-full px-6 py-4 rounded-2xl bg-gray-800 border-2 border-transparent focus:bg-gray-700 focus:border-blue-500 focus:ring-0 transition-all font-bold text-gray-200 placeholder-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="w-full px-6 py-4 rounded-2xl bg-gray-800 border-2 border-transparent focus:bg-gray-700 focus:border-blue-500 focus:ring-0 transition-all font-bold text-gray-200 placeholder-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
className="text-red-400 text-sm font-bold text-center bg-red-900/20 py-2 rounded-xl border border-red-900/50"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-mom-primary to-dad-primary hover:from-pink-400 hover:to-blue-400 shadow-lg shadow-purple-900/50 text-white border-0"
|
||||
>
|
||||
Enter House
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-500 mt-8 font-semibold">
|
||||
Secure • Shared • Simple
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
185
components/SettingsModal.tsx
Normal file
185
components/SettingsModal.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Save, Server, Palette, Cpu, BookOpen, Star, Cat, Cloud, Database } from 'lucide-react';
|
||||
import { AppSettings, ZodiacSign, Familiar, AIProvider } from '../types';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
settings: AppSettings;
|
||||
onSave: (settings: AppSettings) => void;
|
||||
}
|
||||
|
||||
const zodiacSigns: ZodiacSign[] = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'];
|
||||
const familiars: Familiar[] = ['Cat', 'Owl', 'Toad', 'Bat', 'Crow', 'Wolf', 'None'];
|
||||
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, settings, onSave }) => {
|
||||
const [localSettings, setLocalSettings] = useState<AppSettings>(settings);
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'identity' | 'ai'>('general');
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(localSettings);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="fixed inset-0 flex items-center justify-center z-50 pointer-events-none"
|
||||
>
|
||||
<div className="glass-panel w-full max-w-lg mx-4 rounded-3xl overflow-hidden pointer-events-auto flex flex-col max-h-[85vh] border border-purple-500/20 shadow-[0_0_50px_rgba(112,0,255,0.2)]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-white/10 flex justify-between items-center bg-black/40">
|
||||
<h2 className="text-2xl font-magical text-purple-300 flex items-center gap-2">
|
||||
<BookOpen size={24} /> The Grimoire
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors">
|
||||
<X size={24} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex p-2 gap-2 bg-black/20 overflow-x-auto">
|
||||
<button onClick={() => setActiveTab('general')} className={`flex-1 py-2 px-3 whitespace-nowrap rounded-xl text-xs font-bold transition-all ${activeTab === 'general' ? 'bg-purple-900/40 text-purple-200 border border-purple-500/30' : 'text-gray-500'}`}>
|
||||
Runes & Colors
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('identity')} className={`flex-1 py-2 px-3 whitespace-nowrap rounded-xl text-xs font-bold transition-all ${activeTab === 'identity' ? 'bg-pink-900/40 text-pink-200 border border-pink-500/30' : 'text-gray-500'}`}>
|
||||
Cosmic Identity
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('ai')} className={`flex-1 py-2 px-3 whitespace-nowrap rounded-xl text-xs font-bold transition-all ${activeTab === 'ai' ? 'bg-blue-900/40 text-blue-200 border border-blue-500/30' : 'text-gray-500'}`}>
|
||||
Brain & Spirit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar space-y-6">
|
||||
{activeTab === 'general' ? (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Palette size={14} /> Aura Colors
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
|
||||
<label className="text-xs font-semibold text-gray-400 block mb-2">Mom's Aura</label>
|
||||
<input type="color" value={localSettings.momColor} onChange={(e) => setLocalSettings({...localSettings, momColor: e.target.value})} className="w-full h-10 rounded bg-transparent cursor-pointer" />
|
||||
</div>
|
||||
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
|
||||
<label className="text-xs font-semibold text-gray-400 block mb-2">Dad's Aura</label>
|
||||
<input type="color" value={localSettings.dadColor} onChange={(e) => setLocalSettings({...localSettings, dadColor: e.target.value})} className="w-full h-10 rounded bg-transparent cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Server size={14} /> Telepathy Link (SSE)
|
||||
</h3>
|
||||
<div className="bg-white/5 p-4 rounded-2xl border border-white/5 space-y-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" checked={localSettings.enableMcp} onChange={(e) => setLocalSettings({...localSettings, enableMcp: e.target.checked})} className="w-4 h-4 rounded bg-gray-800 border-gray-600 text-purple-500" />
|
||||
<span className="text-sm font-medium text-gray-300">Enable Neural Sync</span>
|
||||
</label>
|
||||
{localSettings.enableMcp && (
|
||||
<input type="text" value={localSettings.mcpServerUrl} onChange={(e) => setLocalSettings({...localSettings, mcpServerUrl: e.target.value})} className="w-full p-3 rounded-xl bg-black/50 border border-white/10 text-gray-300 text-sm font-mono focus:border-purple-500 outline-none" placeholder="http://server..." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : activeTab === 'identity' ? (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Star size={14} /> Star Signs
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
|
||||
<label className="text-xs font-semibold text-gray-400 block mb-2">Mom</label>
|
||||
<select value={localSettings.momZodiac} onChange={(e) => setLocalSettings({...localSettings, momZodiac: e.target.value as ZodiacSign})} className="w-full bg-black/50 text-gray-200 text-sm rounded-lg p-2 border border-white/10 outline-none">
|
||||
{zodiacSigns.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
|
||||
<label className="text-xs font-semibold text-gray-400 block mb-2">Dad</label>
|
||||
<select value={localSettings.dadZodiac} onChange={(e) => setLocalSettings({...localSettings, dadZodiac: e.target.value as ZodiacSign})} className="w-full bg-black/50 text-gray-200 text-sm rounded-lg p-2 border border-white/10 outline-none">
|
||||
{zodiacSigns.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Cat size={14} /> House Familiar
|
||||
</h3>
|
||||
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
|
||||
<select value={localSettings.familiar} onChange={(e) => setLocalSettings({...localSettings, familiar: e.target.value as Familiar})} className="w-full bg-black/50 text-gray-200 text-sm rounded-lg p-2 border border-white/10 outline-none">
|
||||
{familiars.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-500 mt-2">Your spiritual guardian.</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Cpu size={14} /> Intelligence Source
|
||||
</h3>
|
||||
<div className="flex bg-black/50 p-1 rounded-xl border border-white/10 mb-4">
|
||||
<button
|
||||
onClick={() => setLocalSettings({...localSettings, aiProvider: 'gemini'})}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all ${localSettings.aiProvider === 'gemini' ? 'bg-purple-600 text-white shadow-lg' : 'text-gray-400 hover:text-white'}`}
|
||||
>
|
||||
<Cloud size={14} /> Gemini
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLocalSettings({...localSettings, aiProvider: 'ollama'})}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all ${localSettings.aiProvider === 'ollama' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-400 hover:text-white'}`}
|
||||
>
|
||||
<Database size={14} /> Ollama
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localSettings.aiProvider === 'ollama' && (
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<div className="bg-blue-900/10 p-4 rounded-2xl border border-blue-500/20">
|
||||
<h4 className="text-blue-300 font-bold text-sm flex items-center gap-2"><Cpu size={16} /> Local Spirit</h4>
|
||||
<p className="text-blue-400/60 text-xs mt-1">Ensure Ollama is running with CORS enabled.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-400 block mb-1">URL</label>
|
||||
<input type="text" value={localSettings.ollamaUrl} onChange={(e) => setLocalSettings({...localSettings, ollamaUrl: e.target.value})} className="w-full p-3 rounded-xl bg-black/50 border border-white/10 text-gray-300 text-sm font-mono focus:border-blue-500 outline-none" placeholder="http://localhost:11434" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-400 block mb-1">Model</label>
|
||||
<input type="text" value={localSettings.ollamaModel} onChange={(e) => setLocalSettings({...localSettings, ollamaModel: e.target.value})} className="w-full p-3 rounded-xl bg-black/50 border border-white/10 text-gray-300 text-sm font-mono focus:border-blue-500 outline-none" placeholder="llama3" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-white/10 bg-black/40">
|
||||
<Button onClick={handleSave} className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-500 hover:to-blue-500 text-white shadow-lg shadow-purple-900/50">
|
||||
<Save size={18} /> Inscribe Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
54
components/SpaceBackground.tsx
Normal file
54
components/SpaceBackground.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
export const SpaceBackground: React.FC = () => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[-1] overflow-hidden bg-[#050510]">
|
||||
{/* Deep Space Gradients */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(17,24,39,0)_0%,#050510_100%)]" />
|
||||
<div className="absolute top-[-20%] left-[-20%] w-[80%] h-[80%] bg-purple-900/10 rounded-full blur-[150px] animate-pulse" />
|
||||
<div className="absolute bottom-[-20%] right-[-20%] w-[80%] h-[80%] bg-blue-900/10 rounded-full blur-[150px] animate-pulse" style={{ animationDelay: '3s' }} />
|
||||
|
||||
{/* Stars */}
|
||||
<div className="absolute inset-0 opacity-60">
|
||||
{[...Array(80)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-white animate-twinkle"
|
||||
style={{
|
||||
top: `${Math.random() * 100}%`,
|
||||
left: `${Math.random() * 100}%`,
|
||||
width: `${Math.random() * 2 + 1}px`,
|
||||
height: `${Math.random() * 2 + 1}px`,
|
||||
animationDelay: `${Math.random() * 5}s`,
|
||||
boxShadow: `0 0 ${Math.random() * 4}px rgba(255, 255, 255, 0.8)`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shooting Stars */}
|
||||
<div className="absolute top-[10%] left-[80%] w-[2px] h-[2px] bg-white shadow-[0_0_20px_2px_white] animate-shoot opacity-0" />
|
||||
<div className="absolute top-[30%] left-[90%] w-[2px] h-[2px] bg-white shadow-[0_0_20px_2px_white] animate-shoot opacity-0" style={{ animationDelay: '5s' }} />
|
||||
<div className="absolute top-[5%] left-[60%] w-[3px] h-[3px] bg-cyan-200 shadow-[0_0_20px_2px_cyan] animate-shoot opacity-0" style={{ animationDelay: '12s', animationDuration: '6s' }} />
|
||||
|
||||
|
||||
{/* Nebula Blobs - More intense */}
|
||||
<div className="absolute top-1/4 left-1/4 w-[400px] h-[400px] bg-pink-900/10 rounded-full blur-[100px] animate-float" style={{ animationDuration: '20s' }} />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] bg-indigo-900/10 rounded-full blur-[120px] animate-float" style={{ animationDelay: '2s', animationDuration: '25s' }} />
|
||||
|
||||
{/* Orbiting Planets - Detailed */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[900px] h-[900px] border border-white/5 rounded-full animate-spin-slow">
|
||||
{/* Planet 1 */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-blue-900 shadow-[0_0_30px_rgba(59,130,246,0.4)]">
|
||||
<div className="absolute inset-0 rounded-full bg-black/20" style={{ clipPath: 'inset(0 0 50% 0)' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-white/5 rounded-full animate-spin-slow" style={{ animationDirection: 'reverse', animationDuration: '90s' }}>
|
||||
{/* Planet 2 */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 w-8 h-8 rounded-full bg-gradient-to-br from-purple-400 to-purple-900 shadow-[0_0_20px_rgba(168,85,247,0.4)]"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user