Untitled
Anonymous
plain_text
05/07/2026 7:56 AM
60.1 KB
10
Indexable
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Send,
Trash2,
Sparkles,
ChevronDown,
Loader2,
Sun,
Moon,
Info,
LogOut,
User,
Mail,
Lock,
Eye,
EyeOff,
Camera,
Baby,
UserCog,
Mic,
MicOff,
Volume2,
VolumeX,
} from 'lucide-react';
/* ============ Speech API Types ============ */
interface SpeechRecognitionEvent {
results: SpeechRecognitionResultList;
resultIndex: number;
}
interface SpeechRecognitionResultList {
length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionResult {
isFinal: boolean;
[index: number]: SpeechRecognitionAlternative;
}
interface SpeechRecognitionAlternative {
transcript: string;
}
interface SpeechRecognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
start(): void;
stop(): void;
abort(): void;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: Event) => void) | null;
onend: (() => void) | null;
}
declare global {
interface Window {
SpeechRecognition: new () => SpeechRecognition;
webkitSpeechRecognition: new () => SpeechRecognition;
}
}
/* ============ Interfaces ============ */
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
interface UserProfile {
name: string;
email: string;
password: string;
avatar: string;
avatarType: 'emoji' | 'custom';
}
/* ============ Constants ============ */
const PRESET_AVATARS = ['😀', '🐱', '🐶', '🦊', '🐼', '🦄', '🐲', '🦉', '🐙', '🦋', '🌸', '⭐', '🎯', '🎸', '🚀', '🎮', '🏀', '🍕'];
const SUGGESTIONS = [
{ icon: '💡', text: 'Tell me a joke!' },
{ icon: '🧠', text: 'Explain quantum computing simply' },
{ icon: '✨', text: 'What can Warda do?' },
{ icon: '🌟', text: 'Give me a motivational quote' },
{ icon: '📚', text: 'Help me learn something new' },
{ icon: '🎨', text: 'Suggest a creative project idea' },
];
const KIDS_SUGGESTIONS = [
{ icon: '🌈', text: 'Tell me a fun story!' },
{ icon: '🧩', text: 'Give me a riddle!' },
{ icon: '🦁', text: 'Tell me a cool animal fact!' },
{ icon: '🎨', text: 'What should I draw?' },
{ icon: '🚀', text: 'Tell me about space!' },
{ icon: '🎵', text: 'Sing me a silly song!' },
];
/* ============ Helpers ============ */
function generateId() {
return Math.random().toString(36).substring(2, 15);
}
function loadProfile(): UserProfile | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('warda-profile');
if (stored) return JSON.parse(stored);
} catch {}
return null;
}
function saveProfile(profile: UserProfile) {
localStorage.setItem('warda-profile', JSON.stringify(profile));
}
function loadDarkMode(): boolean {
if (typeof window === 'undefined') return false;
try { return localStorage.getItem('warda-darkmode') === 'true'; } catch {}
return false;
}
function loadKidsMode(): boolean {
if (typeof window === 'undefined') return false;
try { return localStorage.getItem('warda-kidsmode') === 'true'; } catch {}
return false;
}
/* ============ Bot Avatar ============ */
function BotAvatar({ speaking }: { speaking?: boolean }) {
return (
<div className="relative flex-shrink-0">
<motion.div
animate={speaking ? { scale: [1, 1.05, 1], rotate: [0, -2, 2, 0] } : {}}
transition={speaking ? { duration: 0.6, repeat: Infinity, ease: 'easeInOut' } : {}}
className="w-9 h-9 rounded-full bg-gradient-to-br from-yellow-300 to-orange-400 flex items-center justify-center shadow-md border-2 border-white/50"
>
<span className="text-lg">🦆</span>
</motion.div>
{speaking && (
<motion.div
className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full border-2 border-white"
animate={{ scale: [1, 1.3, 1] }}
transition={{ duration: 1, repeat: Infinity }}
/>
)}
</div>
);
}
/* ============ User Avatar ============ */
function UserAvatar({ profile }: { profile: UserProfile }) {
if (profile.avatarType === 'custom' && profile.avatar) {
return (
<div className="w-9 h-9 rounded-full overflow-hidden shadow-md border-2 border-white/50 flex-shrink-0">
<img src={profile.avatar} alt={profile.name} className="w-full h-full object-cover" />
</div>
);
}
return (
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-sky-400 to-blue-500 flex items-center justify-center shadow-md border-2 border-white/50 flex-shrink-0">
<span className="text-lg">{profile.avatar || '👤'}</span>
</div>
);
}
/* ============ Message Bubble ============ */
function MessageBubble({ message, profile }: { message: Message; profile: UserProfile }) {
const isUser = message.role === 'user';
return (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}
>
{isUser ? <UserAvatar profile={profile} /> : <BotAvatar />}
<div className={`max-w-[80%] ${isUser ? 'items-end' : 'items-start'}`}>
<div className={`rounded-2xl px-4 py-3 shadow-sm ${
isUser
? 'bg-gradient-to-br from-sky-400 to-blue-500 text-white rounded-tr-md'
: 'bg-white border border-amber-100 text-gray-800 rounded-tl-md dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100'
}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
</div>
<p className={`text-[10px] text-gray-400 mt-1 ${isUser ? 'text-right' : 'text-left'}`}>
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</motion.div>
);
}
/* ============ Typing Indicator ============ */
function TypingIndicator() {
return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="flex gap-3 items-start">
<BotAvatar speaking />
<div className="bg-white border border-amber-100 rounded-2xl rounded-tl-md px-5 py-3 shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div className="flex gap-1.5 items-center">
<motion.span className="w-2 h-2 bg-amber-400 rounded-full" animate={{ y: [0, -6, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: 0 }} />
<motion.span className="w-2 h-2 bg-orange-400 rounded-full" animate={{ y: [0, -6, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: 0.15 }} />
<motion.span className="w-2 h-2 bg-yellow-400 rounded-full" animate={{ y: [0, -6, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: 0.3 }} />
</div>
</div>
</motion.div>
);
}
/* ============ Welcome Screen ============ */
function WelcomeScreen({ onSuggestionClick, kidsMode, profile }: { onSuggestionClick: (text: string) => void; kidsMode: boolean; profile: UserProfile }) {
const suggestions = kidsMode ? KIDS_SUGGESTIONS : SUGGESTIONS;
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }} className="flex flex-col items-center justify-center h-full px-4 text-center">
<motion.div animate={{ y: [0, -8, 0] }} transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut' }} className="mb-6">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-yellow-200 to-orange-300 flex items-center justify-center shadow-lg border-4 border-white/60 mx-auto">
<img src="/duck-logo.png" alt="Warda" className="w-20 h-20 rounded-full object-cover" />
</div>
</motion.div>
<h2 className="text-3xl font-bold bg-gradient-to-r from-amber-500 to-orange-500 bg-clip-text text-transparent mb-1">
{kidsMode ? 'Hi there!' : `Hi! I'm Warda`}
</h2>
{!kidsMode && <p className="text-sm text-amber-600 font-medium mb-2">Welcome back, {profile.name}! 👋</p>}
<p className="text-gray-500 max-w-md mb-8">
{kidsMode
? "I'm Warda! Ask me anything fun — stories, riddles, animal facts, and more! Everything here is super safe and kid-friendly! 🌟"
: "Your friendly AI assistant! Ask me anything and I'll do my best to help you out! 💡"}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full max-w-lg">
{suggestions.map((suggestion, i) => (
<motion.button key={i} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 * i + 0.3 }}
whileHover={{ scale: 1.03, y: -2 }} whileTap={{ scale: 0.97 }}
onClick={() => onSuggestionClick(suggestion.text)}
className="flex items-center gap-3 px-4 py-3 bg-white dark:bg-gray-800 rounded-xl border border-amber-100 dark:border-gray-700 hover:border-amber-300 hover:shadow-md transition-all text-left">
<span className="text-xl">{suggestion.icon}</span>
<span className="text-sm text-gray-600 dark:text-gray-300">{suggestion.text}</span>
</motion.button>
))}
</div>
</motion.div>
);
}
/* ============ Fun Facts Bar ============ */
function FunFactsBar({ fact }: { fact: string }) {
const [isVisible, setIsVisible] = useState(true);
if (!isVisible) return null;
return (
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }}
className="bg-gradient-to-r from-yellow-50 to-orange-50 border-b border-amber-200/50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700 px-4 py-2">
<div className="max-w-3xl mx-auto flex items-center justify-center gap-2 text-xs text-amber-700 dark:text-amber-300">
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
<span className="truncate">{fact}</span>
<button onClick={() => setIsVisible(false)} className="ml-1 hover:text-amber-900 transition-colors">
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
</motion.div>
);
}
/* ============ SIGN-IN SCREEN ============ */
function SignInScreen({ onSignIn, darkMode }: { onSignIn: (profile: UserProfile) => void; darkMode: boolean }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [selectedAvatar, setSelectedAvatar] = useState('👤');
const [customImage, setCustomImage] = useState('');
const [error, setError] = useState('');
const [step, setStep] = useState<'info' | 'avatar'>('info');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleInfoNext = () => {
if (!name.trim()) { setError('Please enter your name!'); return; }
if (!email.trim() || !email.includes('@')) { setError('Please enter a valid email!'); return; }
if (password.length < 3) { setError('Password must be at least 3 characters!'); return; }
setError('');
setStep('avatar');
};
const handleCustomUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) { setError('Image too big! Max 2MB.'); return; }
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
setCustomImage(result);
setSelectedAvatar(result);
};
reader.readAsDataURL(file);
};
const handleSignIn = () => {
const isCustom = customImage && selectedAvatar === customImage;
const profile: UserProfile = {
name: name.trim(),
email: email.trim(),
password: password,
avatar: selectedAvatar,
avatarType: isCustom ? 'custom' : 'emoji',
};
saveProfile(profile);
onSignIn(profile);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); if (step === 'info') handleInfoNext(); else handleSignIn(); }
};
return (
<div className={`h-screen flex items-center justify-center px-4 transition-colors duration-300 ${darkMode ? 'bg-gradient-to-b from-gray-900 via-gray-850 to-gray-900' : 'bg-gradient-to-b from-amber-50 via-orange-50/30 to-sky-50/40'}`}>
<motion.div initial={{ opacity: 0, y: 30, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ type: 'spring', stiffness: 300, damping: 24 }} className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-6">
<motion.div animate={{ y: [0, -8, 0] }} transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut' }} className="mb-4 inline-block">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-yellow-200 to-orange-300 flex items-center justify-center shadow-lg border-4 border-white/60 mx-auto">
<img src="/duck-logo.png" alt="Warda" className="w-16 h-16 rounded-full object-cover" />
</div>
</motion.div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-amber-500 to-orange-500 bg-clip-text text-transparent mb-1">Welcome to Warda!</h1>
<p className={`text-sm ${darkMode ? 'text-gray-400' : 'text-gray-500'}`}>
{step === 'info' ? 'Create your account to get started' : 'Set your Warda profile picture! 🎨'}
</p>
</div>
{/* Error */}
<AnimatePresence>
{error && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className="mb-4">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-4 py-2 text-sm text-red-600 dark:text-red-400">{error}</div>
</motion.div>
)}
</AnimatePresence>
<div className={`rounded-2xl border p-6 shadow-lg ${darkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-amber-100'}`}>
<AnimatePresence mode="wait">
{step === 'info' ? (
<motion.div key="info-step" initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} className="space-y-4" onKeyDown={handleKeyDown}>
{/* Name */}
<div>
<label className={`block text-xs font-medium mb-1.5 ${darkMode ? 'text-gray-300' : 'text-gray-600'}`}>Your Name</label>
<div className="relative">
<User className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${darkMode ? 'text-gray-500' : 'text-gray-400'}`} />
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Enter your name" autoFocus
className={`w-full pl-10 pr-4 py-2.5 rounded-xl border text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400 ${darkMode ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder:text-gray-500' : 'bg-amber-50/50 border-amber-200 text-gray-800 placeholder:text-gray-400'}`} />
</div>
</div>
{/* Email */}
<div>
<label className={`block text-xs font-medium mb-1.5 ${darkMode ? 'text-gray-300' : 'text-gray-600'}`}>Email</label>
<div className="relative">
<Mail className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${darkMode ? 'text-gray-500' : 'text-gray-400'}`} />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]"
className={`w-full pl-10 pr-4 py-2.5 rounded-xl border text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400 ${darkMode ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder:text-gray-500' : 'bg-amber-50/50 border-amber-200 text-gray-800 placeholder:text-gray-400'}`} />
</div>
</div>
{/* Password */}
<div>
<label className={`block text-xs font-medium mb-1.5 ${darkMode ? 'text-gray-300' : 'text-gray-600'}`}>Password</label>
<div className="relative">
<Lock className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${darkMode ? 'text-gray-500' : 'text-gray-400'}`} />
<input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Create a password"
className={`w-full pl-10 pr-10 py-2.5 rounded-xl border text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400 ${darkMode ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder:text-gray-500' : 'bg-amber-50/50 border-amber-200 text-gray-800 placeholder:text-gray-400'}`} />
<button type="button" onClick={() => setShowPassword(!showPassword)} className={`absolute right-3 top-1/2 -translate-y-1/2 ${darkMode ? 'text-gray-500 hover:text-gray-300' : 'text-gray-400 hover:text-gray-600'}`}>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} onClick={handleInfoNext}
className="w-full py-2.5 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 hover:from-amber-500 hover:to-orange-600 text-white font-medium text-sm shadow-md transition-all">
Next — Set Profile Picture →
</motion.button>
</motion.div>
) : (
<motion.div key="avatar-step" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="space-y-4">
{/* Preview */}
<div className="text-center mb-2">
<motion.div key={selectedAvatar} initial={{ scale: 0.5, rotate: -20 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="w-20 h-20 rounded-full bg-gradient-to-br from-sky-400 to-blue-500 flex items-center justify-center shadow-lg mx-auto mb-3 overflow-hidden">
{customImage && selectedAvatar === customImage ? (
<img src={customImage} alt="Custom avatar" className="w-full h-full object-cover" />
) : (
<span className="text-4xl">{selectedAvatar}</span>
)}
</motion.div>
<p className={`text-sm ${darkMode ? 'text-gray-400' : 'text-gray-500'}`}>This is how you'll look in chat!</p>
</div>
{/* Upload from Computer */}
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleCustomUpload} className="hidden" />
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} onClick={() => fileInputRef.current?.click()}
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border-2 border-dashed text-sm font-medium mb-2 transition-all ${darkMode ? 'border-gray-600 text-gray-300 hover:bg-gray-700' : 'border-amber-200 text-gray-600 hover:bg-amber-50'}`}>
<Camera className="w-4 h-4" /> Upload from Computer
</motion.button>
{/* Divider */}
<div className="flex items-center gap-2">
<div className={`flex-1 h-px ${darkMode ? 'bg-gray-700' : 'bg-amber-200'}`} />
<span className={`text-xs ${darkMode ? 'text-gray-500' : 'text-gray-400'}`}>or pick an emoji</span>
<div className={`flex-1 h-px ${darkMode ? 'bg-gray-700' : 'bg-amber-200'}`} />
</div>
{/* Emoji grid */}
<div className="grid grid-cols-6 gap-2">
{PRESET_AVATARS.map((avatar) => (
<motion.button key={avatar} whileHover={{ scale: 1.15 }} whileTap={{ scale: 0.9 }}
onClick={() => { setSelectedAvatar(avatar); setCustomImage(''); }}
className={`w-11 h-11 rounded-full flex items-center justify-center text-xl border-2 transition-all ${selectedAvatar === avatar && !customImage ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 shadow-md scale-110' : `border-transparent ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-amber-50'}`}`}>
{avatar}
</motion.button>
))}
</div>
<div className="flex gap-2">
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} onClick={() => setStep('info')}
className={`flex-1 py-2.5 rounded-xl border text-sm font-medium transition-all ${darkMode ? 'border-gray-600 text-gray-300 hover:bg-gray-700' : 'border-amber-200 text-gray-600 hover:bg-amber-50'}`}>
← Back
</motion.button>
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} onClick={handleSignIn}
className="flex-1 py-2.5 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 hover:from-amber-500 hover:to-orange-600 text-white font-medium text-sm shadow-md transition-all">
Start Chatting! ✨
</motion.button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</div>
);
}
/* ============ PROFILE PICTURE CHANGER MODAL ============ */
function ProfileChangerModal({ profile, onSave, onClose, darkMode }: { profile: UserProfile; onSave: (updated: UserProfile) => void; onClose: () => void; darkMode: boolean }) {
const [selectedAvatar, setSelectedAvatar] = useState(profile.avatar);
const [customImage, setCustomImage] = useState(profile.avatarType === 'custom' ? profile.avatar : '');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleCustomUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) { alert('Image too big! Max 2MB.'); return; }
const reader = new FileReader();
reader.onload = () => { const result = reader.result as string; setCustomImage(result); setSelectedAvatar(result); };
reader.readAsDataURL(file);
};
const handleSave = () => {
const isCustom = customImage && selectedAvatar === customImage;
const updated: UserProfile = { ...profile, avatar: selectedAvatar, avatarType: isCustom ? 'custom' : 'emoji' };
saveProfile(updated);
onSave(updated);
};
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center px-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<motion.div initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }} onClick={(e) => e.stopPropagation()}
className={`relative w-full max-w-sm rounded-2xl border p-6 shadow-xl ${darkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-amber-100'}`}>
<h3 className={`text-lg font-bold text-center mb-4 ${darkMode ? 'text-gray-100' : 'text-gray-800'}`}>Change Avatar 🎨</h3>
<div className="text-center mb-4">
<motion.div key={selectedAvatar} initial={{ scale: 0.8 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 300 }}
className="w-20 h-20 rounded-full bg-gradient-to-br from-sky-400 to-blue-500 flex items-center justify-center shadow-lg mx-auto overflow-hidden">
{customImage && selectedAvatar === customImage ? (
<img src={customImage} alt="Custom avatar" className="w-full h-full object-cover" />
) : (
<span className="text-4xl">{selectedAvatar}</span>
)}
</motion.div>
<p className={`text-xs mt-2 ${darkMode ? 'text-gray-400' : 'text-gray-500'}`}>Preview</p>
</div>
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleCustomUpload} className="hidden" />
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} onClick={() => fileInputRef.current?.click()}
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border-2 border-dashed text-sm font-medium mb-3 transition-all ${darkMode ? 'border-gray-600 text-gray-300 hover:bg-gray-700' : 'border-amber-200 text-gray-600 hover:bg-amber-50'}`}>
<Camera className="w-4 h-4" /> Upload Custom Picture
</motion.button>
<div className="grid grid-cols-6 gap-2 mb-4">
{PRESET_AVATARS.map((avatar) => (
<motion.button key={avatar} whileHover={{ scale: 1.15 }} whileTap={{ scale: 0.9 }}
onClick={() => { setSelectedAvatar(avatar); setCustomImage(''); }}
className={`w-11 h-11 rounded-full flex items-center justify-center text-xl border-2 transition-all ${selectedAvatar === avatar && !customImage ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 shadow-md scale-110' : `border-transparent ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-amber-50'}`}`}>
{avatar}
</motion.button>
))}
</div>
<div className="flex gap-2">
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} onClick={onClose}
className={`flex-1 py-2.5 rounded-xl border text-sm font-medium transition-all ${darkMode ? 'border-gray-600 text-gray-300 hover:bg-gray-700' : 'border-amber-200 text-gray-600 hover:bg-amber-50'}`}>Cancel</motion.button>
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} onClick={handleSave}
className="flex-1 py-2.5 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 hover:from-amber-500 hover:to-orange-600 text-white font-medium text-sm shadow-md transition-all">Save ✨</motion.button>
</div>
</motion.div>
</motion.div>
);
}
/* ============ MAIN APP ============ */
export default function WardaChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [darkMode, setDarkMode] = useState(false);
const [showInfo, setShowInfo] = useState(false);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [isSignedIn, setIsSignedIn] = useState(false);
const [showProfileChanger, setShowProfileChanger] = useState(false);
const [kidsMode, setKidsMode] = useState(false);
const [isListening, setIsListening] = useState(false);
const [speakerOn, setSpeakerOn] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
// Initialize from localStorage
useEffect(() => {
const saved = loadProfile();
if (saved) { setProfile(saved); setIsSignedIn(true); }
setDarkMode(loadDarkMode());
setKidsMode(loadKidsMode());
}, []);
// Fun facts
const funFacts = [
'💡 Did you know? The average person spends 6 months of their lifetime waiting for red lights!',
'🧠 Warda processes your questions at lightning speed — try me!',
'🌍 Honey never spoils. Archaeologists have found 3000-year-old honey in Egyptian tombs!',
'📚 The shortest war in history lasted only 38 minutes!',
'🌟 A day on Venus is longer than a year on Venus!',
'🎨 Octopuses have three hearts and blue blood!',
];
const randomFact = funFacts[Math.floor(Math.random() * funFacts.length)];
const scrollToBottom = useCallback(() => {
if (messagesEndRef.current) messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => { scrollToBottom(); }, [messages, isLoading, scrollToBottom]);
useEffect(() => {
if (darkMode) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
localStorage.setItem('warda-darkmode', String(darkMode));
}, [darkMode]);
useEffect(() => { localStorage.setItem('warda-kidsmode', String(kidsMode)); }, [kidsMode]);
// Text-to-Speech: speak assistant messages when speaker is ON
const speakText = useCallback((text: string) => {
if (typeof window === 'undefined') return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 1;
utterance.pitch = 1;
utterance.onstart = () => setIsSpeaking(true);
utterance.onend = () => setIsSpeaking(false);
utterance.onerror = () => setIsSpeaking(false);
window.speechSynthesis.speak(utterance);
}, []);
// Track last spoken message ID to avoid re-speaking
const lastSpokenRef = useRef<string>('');
useEffect(() => {
if (speakerOn && messages.length > 0) {
const lastMsg = messages[messages.length - 1];
if (lastMsg.role === 'assistant' && lastMsg.id !== lastSpokenRef.current) {
lastSpokenRef.current = lastMsg.id;
speakText(lastMsg.content);
}
}
}, [messages, speakerOn, speakText]);
// Cleanup speech on unmount
useEffect(() => {
return () => { if (typeof window !== 'undefined') window.speechSynthesis?.cancel(); };
}, []);
// Mic: Speech-to-Text
const toggleListening = useCallback(() => {
if (isListening) {
recognitionRef.current?.stop();
setIsListening(false);
return;
}
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognitionAPI) return;
const recognition = new SpeechRecognitionAPI();
recognition.lang = 'en-US';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
setInput(transcript);
};
recognition.onerror = () => setIsListening(false);
recognition.onend = () => setIsListening(false);
recognition.start();
recognitionRef.current = recognition;
setIsListening(true);
}, [isListening]);
const sendMessage = useCallback(async (text: string) => {
if (!text.trim() || isLoading) return;
const userMessage: Message = { id: generateId(), role: 'user', content: text.trim(), timestamp: new Date() };
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
if (textareaRef.current) textareaRef.current.style.height = 'auto';
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [...messages, userMessage].map((m) => ({ role: m.role, content: m.content })),
kidsMode: kidsMode,
}),
});
if (!response.ok) throw new Error('Failed to get response');
const data = await response.json();
const assistantMessage: Message = { id: generateId(), role: 'assistant', content: data.message.content, timestamp: new Date() };
setMessages((prev) => [...prev, assistantMessage]);
} catch (error) {
console.error('Error:', error);
const errorMessage: Message = { id: generateId(), role: 'assistant', content: 'Oops! I ran into a little problem... Could you try again?', timestamp: new Date() };
setMessages((prev) => [...prev, errorMessage]);
} finally { setIsLoading(false); }
}, [isLoading, messages, kidsMode]);
const handleSubmit = useCallback((e: React.FormEvent) => { e.preventDefault(); sendMessage(input); }, [input, sendMessage]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(input); } }, [input, sendMessage]);
const clearChat = useCallback(() => { setMessages([]); lastSpokenRef.current = ''; }, []);
const handleSignOut = () => { localStorage.removeItem('warda-profile'); setProfile(null); setIsSignedIn(false); setMessages([]); lastSpokenRef.current = ''; };
const handleProfileSave = (updated: UserProfile) => { setProfile(updated); setShowProfileChanger(false); };
const supportsSpeechRecognition = typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition);
// Sign-in screen
if (!isSignedIn) return <SignInScreen onSignIn={(p) => { setProfile(p); setIsSignedIn(true); }} darkMode={darkMode} />;
if (!profile) return null;
return (
<div className={`h-screen flex flex-col transition-colors duration-300 ${darkMode ? 'bg-gradient-to-b from-gray-900 via-gray-850 to-gray-900' : 'bg-gradient-to-b from-amber-50 via-orange-50/30 to-sky-50/40'}`}>
{/* Kids Mode Banner */}
{kidsMode && (
<div className="flex-shrink-0 bg-gradient-to-r from-green-400 to-emerald-400 text-white text-center py-1.5 text-xs font-bold flex items-center justify-center gap-1.5">
<Baby className="w-3.5 h-3.5" /> Kids Mode — Safe & Fun Zone! <Baby className="w-3.5 h-3.5" />
</div>
)}
{/* Fun Facts Bar */}
{messages.length === 0 && <FunFactsBar fact={randomFact} />}
{/* Header */}
<header className={`flex-shrink-0 backdrop-blur-md border-b ${darkMode ? 'bg-gray-900/80 border-gray-800' : 'bg-white/70 border-amber-100'}`}>
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<motion.div whileHover={{ rotate: [0, -10, 10, 0] }} transition={{ duration: 0.5 }} className="w-10 h-10 rounded-full bg-gradient-to-br from-yellow-300 to-orange-400 flex items-center justify-center shadow-md cursor-pointer">
<img src="/duck-logo.png" alt="Warda" className="w-10 h-10 rounded-full object-cover" />
</motion.div>
<div>
<h1 className="text-lg font-bold bg-gradient-to-r from-amber-500 to-orange-500 bg-clip-text text-transparent">Warda</h1>
<div className="flex items-center gap-1.5">
<motion.div animate={{ scale: [1, 1.2, 1] }} transition={{ duration: 2, repeat: Infinity }} className="w-2 h-2 bg-green-400 rounded-full" />
<span className={`text-xs ${darkMode ? 'text-gray-400' : 'text-gray-500'}`}>Online and ready to help!</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5">
<Badge variant="secondary" className={`text-[10px] ${darkMode ? 'bg-amber-900/30 text-amber-300 border-amber-800' : 'bg-amber-50 text-amber-600 border-amber-200'}`}>v7.0 ✨</Badge>
{/* Kids/Adults Mode Toggle */}
<motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} onClick={() => setKidsMode(!kidsMode)}
className={`relative h-8 px-3 rounded-full flex items-center gap-1.5 text-xs font-bold transition-all border ${kidsMode ? 'bg-green-500 text-white border-green-400 shadow-sm' : darkMode ? 'bg-gray-700 text-gray-300 border-gray-600 hover:bg-gray-600' : 'bg-gray-100 text-gray-600 border-gray-200 hover:bg-gray-200'}`}
title={kidsMode ? 'Switch to Adults Mode' : 'Switch to Kids Mode'}>
{kidsMode ? (<><Baby className="w-3.5 h-3.5" /> Kids</>) : (<><UserCog className="w-3.5 h-3.5" /> Adult</>)}
</motion.button>
{/* User avatar */}
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => setShowProfileChanger(true)}
className={`w-8 h-8 rounded-full flex items-center justify-center shadow-sm border-2 transition-all ${darkMode ? 'border-gray-600' : 'border-white'} ${profile.avatarType === 'custom' && profile.avatar ? '' : 'bg-gradient-to-br from-sky-400 to-blue-500'}`}
title="Change profile picture">
{profile.avatarType === 'custom' && profile.avatar ? (
<img src={profile.avatar} alt={profile.name} className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-sm">{profile.avatar || '👤'}</span>
)}
</motion.button>
<span className={`text-xs font-medium hidden sm:inline ${darkMode ? 'text-gray-300' : 'text-gray-600'}`}>{profile.name}</span>
{/* Speaker Toggle: ON = hear Warda, OFF = silent */}
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => {
const newVal = !speakerOn;
setSpeakerOn(newVal);
if (!newVal) { window.speechSynthesis.cancel(); setIsSpeaking(false); }
}}
className={`h-8 w-8 rounded-full flex items-center justify-center transition-all ${speakerOn ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-500' : darkMode ? 'text-gray-500 hover:bg-gray-800' : 'text-gray-400 hover:bg-amber-50'}`}
title={speakerOn ? 'Voice ON — Warda speaks aloud' : 'Voice OFF — silent'}>
{speakerOn ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
</motion.button>
<Button variant="ghost" size="icon" onClick={() => setShowInfo(!showInfo)} className={`h-8 w-8 ${darkMode ? 'hover:bg-gray-800' : 'hover:bg-amber-50'}`}>
<Info className={`w-4 h-4 ${darkMode ? 'text-gray-400' : 'text-gray-500'}`} />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDarkMode(!darkMode)} className={`h-8 w-8 ${darkMode ? 'hover:bg-gray-800' : 'hover:bg-amber-50'}`}>
{darkMode ? <Sun className="w-4 h-4 text-yellow-400" /> : <Moon className="w-4 h-4 text-gray-500" />}
</Button>
{messages.length > 0 && (
<Button variant="ghost" size="icon" onClick={clearChat} className={`h-8 w-8 ${darkMode ? 'hover:bg-gray-800' : 'hover:bg-amber-50'}`} title="Clear chat">
<Trash2 className="w-4 h-4 text-gray-500" />
</Button>
)}
<Button variant="ghost" size="icon" onClick={handleSignOut} className={`h-8 w-8 ${darkMode ? 'hover:bg-gray-800' : 'hover:bg-amber-50'}`} title="Sign out">
<LogOut className="w-4 h-4 text-gray-500" />
</Button>
</div>
</div>
{/* Info Banner */}
<AnimatePresence>
{showInfo && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }} className="overflow-hidden">
<div className={`mx-4 mb-3 p-3 rounded-lg text-xs ${darkMode ? 'bg-gray-800 text-gray-300' : 'bg-amber-50 text-amber-700'}`}>
<p className="font-medium mb-1">About Warda</p>
<p>Warda is a smart and friendly AI chatbot powered by a real language model. Ask about coding, writing, brainstorming, or just chat for fun! Press Shift+Enter for new lines.</p>
<p className="mt-2"><strong>Kids Mode:</strong> Filters content to be child-friendly and safe. Switch between Adults and Kids mode using the toggle button! 🎮</p>
</div>
</motion.div>
)}
</AnimatePresence>
</header>
{/* Chat Area */}
<div ref={chatContainerRef} className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-4 py-4">
{messages.length === 0 ? (
<WelcomeScreen onSuggestionClick={sendMessage} kidsMode={kidsMode} profile={profile} />
) : (
<div className="space-y-4">
<AnimatePresence>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} profile={profile} />
))}
</AnimatePresence>
<AnimatePresence>{isLoading && <TypingIndicator />}</AnimatePresence>
<div ref={messagesEndRef} />
</div>
)}
</div>
</div>
{/* Input Area */}
<div className={`flex-shrink-0 border-t backdrop-blur-md ${darkMode ? 'bg-gray-900/80 border-gray-800' : 'bg-white/70 border-amber-100'}`}>
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto p-4">
{/* Listening indicator */}
<AnimatePresence>
{isListening && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className="mb-2 flex items-center justify-center gap-2">
<motion.div animate={{ scale: [1, 1.5, 1] }} transition={{ duration: 0.8, repeat: Infinity }} className="w-3 h-3 bg-red-500 rounded-full" />
<motion.div animate={{ scale: [1, 1.5, 1] }} transition={{ duration: 0.8, repeat: Infinity, delay: 0.2 }} className="w-3 h-3 bg-red-400 rounded-full" />
<motion.div animate={{ scale: [1, 1.5, 1] }} transition={{ duration: 0.8, repeat: Infinity, delay: 0.4 }} className="w-3 h-3 bg-red-300 rounded-full" />
<span className="text-sm text-red-500 font-medium ml-1">Listening...</span>
</motion.div>
)}
</AnimatePresence>
<div className={`flex items-end gap-2 rounded-2xl border px-3 py-2 ${darkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-amber-100'}`}>
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={kidsMode ? 'Ask Warda anything fun! 🌟' : 'Ask Warda anything...'}
disabled={isLoading}
rows={1}
className={`flex-1 resize-none border-0 shadow-none focus-visible:ring-0 p-2 text-sm min-h-[36px] max-h-32 ${darkMode ? 'bg-transparent text-gray-100 placeholder:text-gray-500' : 'bg-transparent text-gray-800 placeholder:text-gray-400'}`}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = Math.min(target.scrollHeight, 128) + 'px';
}}
/>
{/* Mic Button */}
{supportsSpeechRecognition && (
<motion.button
type="button"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={toggleListening}
className={`h-9 w-9 rounded-full flex items-center justify-center transition-all flex-shrink-0 ${
isListening
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
: darkMode
? 'bg-gray-700 text-gray-300 hover:bg-gray-600'
: 'bg-amber-50 text-amber-500 hover:bg-amber-100'
}`}
title={isListening ? 'Stop listening' : 'Start voice input'}>
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
</motion.button>
)}
{/* Send Button */}
<motion.button
type="submit"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
disabled={!input.trim() || isLoading}
className="h-9 w-9 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 hover:from-amber-500 hover:to-orange-600 text-white flex items-center justify-center shadow-md transition-all flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed">
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</motion.button>
</div>
<p className={`text-center text-[10px] mt-2 ${darkMode ? 'text-gray-600' : 'text-gray-400'}`}>
Warda may make mistakes. Always double-check important information.
</p>
</form>
</div>
{/* Profile Changer Modal */}
<AnimatePresence>
{showProfileChanger && (
<ProfileChangerModal profile={profile} onSave={handleProfileSave} onClose={() => setShowProfileChanger(false)} darkMode={darkMode} />
)}
</AnimatePresence>
</div>
);
}
Editor is loading...
Leave a Comment