Initial import

This commit is contained in:
Flatlogic Bot 2026-03-27 12:21:43 +00:00
commit d8001cefa6
82 changed files with 20708 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
App.tsx Normal file
View File

@ -0,0 +1,73 @@
import React, { Suspense, lazy } from 'react';
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import { Loader2 } from 'lucide-react';
import { LanguageProvider } from './contexts/LanguageContext';
// Lazy load pages for performance optimization
const Welcome = lazy(() => import('./pages/Welcome'));
const Auth = lazy(() => import('./pages/Auth'));
const Greeting = lazy(() => import('./pages/Greeting'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Planner = lazy(() => import('./pages/Planner'));
const StudyBuddy = lazy(() => import('./pages/StudyBuddy'));
const Library = lazy(() => import('./pages/Library'));
const Culture = lazy(() => import('./pages/Culture'));
const Health = lazy(() => import('./pages/Health'));
const Safety = lazy(() => import('./pages/Safety'));
const Game = lazy(() => import('./pages/Game'));
const Recipes = lazy(() => import('./pages/Recipes'));
const HeritageMap = lazy(() => import('./pages/HeritageMap'));
const Profile = lazy(() => import('./pages/Profile'));
const PublicProfile = lazy(() => import('./pages/PublicProfile'));
const Rewards = lazy(() => import('./pages/Rewards'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
const ThemeSelection = lazy(() => import('./pages/ThemeSelection'));
const CommunityChat = lazy(() => import('./pages/CommunityChat'));
const LoadingFallback = () => (
<div className="h-screen w-full flex items-center justify-center bg-orange-50 dark:bg-gray-900">
<Loader2 className="animate-spin text-red-600 w-12 h-12" />
</div>
);
const App: React.FC = () => {
return (
<LanguageProvider>
<HashRouter>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/welcome" element={<Welcome />} />
<Route path="/auth" element={<Auth />} />
<Route path="/greeting" element={<Greeting />} />
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="planner" element={<Planner />} />
<Route path="analytics" element={<Analytics />} />
<Route path="study-buddy" element={<StudyBuddy />} />
<Route path="library" element={<Library />} />
<Route path="culture" element={<Culture />} />
<Route path="recipes" element={<Recipes />} />
<Route path="map" element={<HeritageMap />} />
<Route path="health" element={<Health />} />
<Route path="safety" element={<Safety />} />
<Route path="community-chat" element={<CommunityChat />} />
<Route path="arcade" element={<Game />} />
<Route path="profile" element={<Profile />} />
<Route path="profile/:userId" element={<PublicProfile />} />
<Route path="rewards" element={<Rewards />} />
<Route path="settings" element={<Settings />} />
<Route path="settings/themes" element={<ThemeSelection />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</Suspense>
</HashRouter>
</LanguageProvider>
);
};
export default App;

20
README.md Normal file
View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1U09-D0obmndUIvc60FWjEWAFN9gfOEqc
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

558
ai-voice-model/RudraAI.tsx Normal file
View File

@ -0,0 +1,558 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
X, MessageSquare, Mic, Send,
Loader2, AudioWaveform, StopCircle,
Radio, Cpu, AlertCircle, RefreshCw, ShieldCheck,
GripVertical
} from 'lucide-react';
import { Logo } from '../components/ui/Logo';
import { StorageService } from '../services/storageService';
import { connectToGuruLive, encodeAudio, decodeAudio, decodeAudioData, requestMicPermission } from '../services/geminiService';
import { ChatMessage, TaskStatus } from '../types';
import { Modality, LiveServerMessage, Blob, GoogleGenAI } from '@google/genai';
import { useNavigate } from 'react-router-dom';
import { RUDRA_AI_TOOLS, ROBOTIC_SYSTEM_INSTRUCTION } from './ai';
const RudraAI: React.FC = () => {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [mode, setMode] = useState<'chat' | 'voice'>('voice');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
// Position & Drag State
const [pos, setPos] = useState({ x: window.innerWidth - 80, y: window.innerHeight - 140 });
const [dragging, setDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const dragStartPos = useRef({ x: 0, y: 0 });
const hasMovedRef = useRef(false);
// Voice Session State
const [hasMicPermission, setHasMicPermission] = useState<boolean | null>(null);
const hasMicPermissionRef = useRef<boolean | null>(null); // Ref to track permission in callbacks
const [isLiveActive, setIsLiveActive] = useState(false);
const [liveStatus, setLiveStatus] = useState<'Idle' | 'Connecting' | 'Listening' | 'Speaking' | 'Error'>('Idle');
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [userTranscript, setUserTranscript] = useState('');
const [aiTranscript, setAiTranscript] = useState('');
// Refs
const liveSessionPromiseRef = useRef<Promise<any> | null>(null);
const inputAudioContextRef = useRef<AudioContext | null>(null);
const outputAudioContextRef = useRef<AudioContext | null>(null);
const nextStartTimeRef = useRef<number>(0);
const audioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const micStreamRef = useRef<MediaStream | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Background Wake Word Engine
const wakeWordRecognitionRef = useRef<any>(null);
const isMicLockedRef = useRef(false);
// State Refs for Event Handlers
const isLiveActiveRef = useRef(isLiveActive);
useEffect(() => { isLiveActiveRef.current = isLiveActive; }, [isLiveActive]);
// Sync permission ref
useEffect(() => { hasMicPermissionRef.current = hasMicPermission; }, [hasMicPermission]);
useEffect(() => {
StorageService.getProfile(); // Prefetch profile
const handleResize = () => {
setPos(prev => ({
x: Math.min(prev.x, window.innerWidth - 60),
y: Math.min(prev.y, window.innerHeight - 120)
}));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, isTyping, userTranscript, aiTranscript]);
const onMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
setDragging(true);
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
dragOffset.current = { x: clientX - pos.x, y: clientY - pos.y };
};
useEffect(() => {
const onMouseMove = (e: MouseEvent | TouchEvent) => {
if (!dragging) return;
hasMovedRef.current = true;
const clientX = 'touches' in e ? e.touches[0].clientX : (e as MouseEvent).clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : (e as MouseEvent).clientY;
const newX = Math.max(10, Math.min(window.innerWidth - 58, clientX - dragOffset.current.x));
const newY = Math.max(10, Math.min(window.innerHeight - 110, clientY - dragOffset.current.y));
setPos({ x: newX, y: newY });
};
const onMouseUp = () => setDragging(false);
if (dragging) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('touchmove', onMouseMove);
window.addEventListener('touchend', onMouseUp);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('touchmove', onMouseMove);
window.removeEventListener('touchend', onMouseUp);
};
}, [dragging]);
// --- WAKE WORD LOGIC ---
// Ref to hold the startLiveConversation function to avoid stale closures
const startLiveConversationRef = useRef<() => Promise<void>>(async () => {});
const stopWakeWord = useCallback(() => {
if (wakeWordRecognitionRef.current) {
try {
wakeWordRecognitionRef.current.onend = null; // Prevent restart
wakeWordRecognitionRef.current.stop();
wakeWordRecognitionRef.current = null;
} catch (e) {}
}
}, []);
const initWakeWord = useCallback(() => {
// Don't start if live session is active or mic is locked by other apps
if (isMicLockedRef.current || isLiveActiveRef.current) return;
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) return;
// Prevent multiple instances
if (wakeWordRecognitionRef.current) return;
try {
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true; // Enabled for faster response
recognition.lang = 'en-US';
recognition.onresult = (event: any) => {
// Double check state inside callback
if (isLiveActiveRef.current || isMicLockedRef.current) return;
for (let i = event.resultIndex; i < event.results.length; ++i) {
const text = event.results[i][0].transcript.toLowerCase().trim();
if (text.includes("hey rudra") || text.includes("rudra") || text.includes("ai babu")) {
stopWakeWord(); // Stop listening so we can start Gemini Live
setIsOpen(true);
setMode('voice');
// Play activation sound
const audio = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3');
audio.volume = 0.5;
audio.play().catch(() => {});
// Use the ref to call the latest version of startLiveConversation
setTimeout(() => {
if (startLiveConversationRef.current) {
startLiveConversationRef.current();
}
}, 100);
return;
}
}
};
recognition.onend = () => {
wakeWordRecognitionRef.current = null;
// Restart if not locked/active. Removed !isOpen check to allow wake word when closed.
if (!isLiveActiveRef.current && !isMicLockedRef.current) {
try { setTimeout(() => initWakeWord(), 1000); } catch (e) {}
}
};
recognition.start();
wakeWordRecognitionRef.current = recognition;
} catch (e) {
// console.warn("Wake word engine failed to initialize.", e);
}
}, []); // Empty deps to avoid recreation, uses refs for state
// Handle Global Mic Lock (for StudyBuddy compatibility)
useEffect(() => {
const handleMicLock = (e: any) => {
const { state } = e.detail;
isMicLockedRef.current = state;
if (state) {
stopWakeWord();
} else {
// Delay restart to allow other components to fully release
setTimeout(() => initWakeWord(), 1500);
}
};
window.addEventListener('rudraksha-mic-lock', handleMicLock);
// Initial permission check
if (navigator.permissions && (navigator.permissions as any).query) {
(navigator.permissions as any).query({ name: 'microphone' }).then((result: any) => {
if (result.state === 'granted') {
setHasMicPermission(true);
initWakeWord();
} else if (result.state === 'denied') {
setHasMicPermission(false);
}
});
}
return () => {
stopWakeWord();
window.removeEventListener('rudraksha-mic-lock', handleMicLock);
};
}, [initWakeWord, stopWakeWord]);
const handleGrantPermission = async () => {
const success = await requestMicPermission();
setHasMicPermission(success);
if (success) {
setErrorMsg(null);
initWakeWord();
} else {
setErrorMsg("Microphone access is required for voice features.");
}
return success;
};
// --- GEMINI LIVE LOGIC ---
const executeTool = async (name: string, args: any) => {
// ... same as component version
return { error: "Operation failed" };
};
const startLiveConversation = async () => {
if (isLiveActive) return;
const settings = await StorageService.getSettings();
if (!settings.permissions.microphone) {
setHasMicPermission(false);
setErrorMsg("Privacy Mode Active. Microphone access disabled in Settings.");
return;
}
if (!hasMicPermissionRef.current) {
const granted = await handleGrantPermission();
if (!granted) return;
}
stopWakeWord();
window.dispatchEvent(new CustomEvent('rudraksha-mic-lock', { detail: { state: true } }));
setLiveStatus('Connecting');
setIsLiveActive(true);
setUserTranscript('');
setAiTranscript('');
setErrorMsg(null);
await new Promise(r => setTimeout(r, 800));
try {
let stream: MediaStream | null = null;
for (let i = 0; i < 3; i++) {
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
break;
} catch (err) {
if (i === 2) throw new Error("Microphone busy.");
await new Promise(r => setTimeout(r, 1000));
}
}
micStreamRef.current = stream;
inputAudioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
outputAudioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
await inputAudioContextRef.current.resume();
await outputAudioContextRef.current.resume();
const sessionPromise = connectToGuruLive({
model: 'gemini-2.5-flash-native-audio-preview-12-2025',
config: {
responseModalities: [Modality.AUDIO],
systemInstruction: ROBOTIC_SYSTEM_INSTRUCTION,
tools: RUDRA_AI_TOOLS,
speechConfig: {
voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Fenrir' } }
},
inputAudioTranscription: {},
outputAudioTranscription: {}
},
callbacks: {
onopen: () => {
if (!inputAudioContextRef.current || !micStreamRef.current) return;
const source = inputAudioContextRef.current.createMediaStreamSource(micStreamRef.current);
const scriptProcessor = inputAudioContextRef.current.createScriptProcessor(4096, 1, 1);
scriptProcessor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
const pcmBlob = createPcmBlob(inputData);
sessionPromise.then(session => { session.sendRealtimeInput({ media: pcmBlob }); });
};
source.connect(scriptProcessor);
scriptProcessor.connect(inputAudioContextRef.current.destination);
setLiveStatus('Listening');
},
onmessage: async (message: LiveServerMessage) => {
if (message.toolCall) {
// Simplified tool execution for this file version
}
const audioData = message.serverContent?.modelTurn?.parts[0]?.inlineData?.data;
if (audioData && outputAudioContextRef.current) {
setLiveStatus('Speaking');
nextStartTimeRef.current = Math.max(nextStartTimeRef.current, outputAudioContextRef.current.currentTime);
const buffer = await decodeAudioData(decodeAudio(audioData), outputAudioContextRef.current, 24000, 1);
const source = outputAudioContextRef.current.createBufferSource();
source.buffer = buffer;
source.connect(outputAudioContextRef.current.destination);
source.onended = () => {
audioSourcesRef.current.delete(source);
if (audioSourcesRef.current.size === 0) setLiveStatus('Listening');
};
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += buffer.duration;
audioSourcesRef.current.add(source);
}
if (message.serverContent?.inputTranscription) {
setUserTranscript(message.serverContent.inputTranscription.text || '');
}
if (message.serverContent?.outputTranscription) {
setAiTranscript(prev => `${prev}${message.serverContent?.outputTranscription?.text}`);
}
if (message.serverContent?.interrupted) {
audioSourcesRef.current.forEach(s => { try { s.stop(); } catch(e) {} });
audioSourcesRef.current.clear();
nextStartTimeRef.current = 0;
setLiveStatus('Listening');
}
},
onerror: (e) => {
console.error("Neural Voice Error:", e);
setErrorMsg("Hardware link interrupted.");
stopLiveConversation();
},
onclose: () => stopLiveConversation(),
}
});
liveSessionPromiseRef.current = sessionPromise;
} catch (err: any) {
console.error("Neural Voice Link Failure:", err);
setLiveStatus('Error');
setErrorMsg(err.message || "Microphone unavailable.");
setTimeout(() => stopLiveConversation(), 3000);
}
};
// Update ref whenever function definition changes (on renders)
useEffect(() => {
startLiveConversationRef.current = startLiveConversation;
});
const stopLiveConversation = () => {
setIsLiveActive(false);
setLiveStatus('Idle');
liveSessionPromiseRef.current?.then(s => { try { s.close(); } catch(e) {} });
if (micStreamRef.current) {
micStreamRef.current.getTracks().forEach(t => t.stop());
micStreamRef.current = null;
}
audioSourcesRef.current.forEach(s => { try { s.stop(); } catch(e) {} });
audioSourcesRef.current.clear();
if (inputAudioContextRef.current && inputAudioContextRef.current.state !== 'closed') {
inputAudioContextRef.current.close().catch(() => {});
}
if (outputAudioContextRef.current && outputAudioContextRef.current.state !== 'closed') {
outputAudioContextRef.current.close().catch(() => {});
}
inputAudioContextRef.current = null;
outputAudioContextRef.current = null;
nextStartTimeRef.current = 0;
window.dispatchEvent(new CustomEvent('rudraksha-mic-lock', { detail: { state: false } }));
setTimeout(() => initWakeWord(), 1500);
};
const createPcmBlob = (data: Float32Array): Blob => {
const l = data.length;
const int16 = new Int16Array(l);
for (let i = 0; i < l; i++) int16[i] = data[i] * 32768;
return {
data: encodeAudio(new Uint8Array(int16.buffer)),
mimeType: 'audio/pcm;rate=16000',
};
};
// --- UI INTERACTION ---
const onTriggerStart = (e: React.MouseEvent | React.TouchEvent) => {
if (e.type === 'mousedown' && (e as React.MouseEvent).button !== 0) return;
setDragging(true);
hasMovedRef.current = false;
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY;
dragStartPos.current = { x: clientX, y: clientY };
dragOffset.current = { x: clientX - pos.x, y: clientY - pos.y };
};
const handleOpen = () => {
if (!hasMovedRef.current) {
setIsOpen(!isOpen);
}
};
const handleChatSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isTyping) return;
const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', text: input, timestamp: Date.now() };
setMessages(prev => [...prev, userMsg]);
const currentInput = input;
setInput('');
setIsTyping(true);
try {
const aiClient = new GoogleGenAI({ apiKey: process.env.API_KEY });
const res = await aiClient.models.generateContent({
model: 'gemini-3-flash-preview',
contents: currentInput,
config: { systemInstruction: ROBOTIC_SYSTEM_INSTRUCTION, tools: RUDRA_AI_TOOLS }
});
// Simplified chat response handling
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: res.text || "Command accepted.", timestamp: Date.now() }]);
} catch {
setMessages(prev => [...prev, { id: 'err', role: 'model', text: "Signal Interrupted.", timestamp: Date.now() }]);
} finally { setIsTyping(false); }
};
return (
<div className="fixed z-[9999] flex flex-col items-end touch-none select-none" style={{ left: pos.x, top: pos.y }}>
{isOpen && (
<div className="absolute bottom-16 right-0 mb-2 w-[320px] h-[480px] bg-gray-950/95 backdrop-blur-2xl rounded-[2.5rem] shadow-[0_40px_100px_rgba(0,0,0,0.8)] border-2 border-red-900/50 overflow-hidden flex flex-col animate-in slide-in-from-bottom-4 duration-300" onMouseDown={e => e.stopPropagation()}>
<header className="p-5 bg-black/40 text-white flex justify-between items-center shrink-0 border-b border-white/5">
<div className="flex items-center gap-3">
<div className="bg-red-600 p-1.5 rounded-lg shadow-[0_0_15px_rgba(220,38,38,0.4)]">
<Cpu size={18} className="text-white" />
</div>
<div>
<h3 className="font-black uppercase tracking-widest text-[11px] leading-none italic">Rudra Core</h3>
<div className="flex items-center gap-1.5 mt-1">
<div className={`w-1.5 h-1.5 rounded-full ${isLiveActive ? (liveStatus === 'Error' ? 'bg-red-500' : 'bg-red-500 animate-pulse') : 'bg-green-500 opacity-50'}`}></div>
<span className="text-[8px] font-bold uppercase tracking-widest text-gray-500">{isLiveActive ? liveStatus : 'Ready'}</span>
</div>
</div>
</div>
<div className="flex gap-1">
<button onClick={() => setMode(mode === 'chat' ? 'voice' : 'chat')} className="p-2 hover:bg-white/10 rounded-xl transition-colors text-gray-400 hover:text-white">
{mode === 'chat' ? <Mic size={18}/> : <MessageSquare size={18}/>}
</button>
<button onClick={() => setIsOpen(false)} className="p-2 hover:bg-red-600/20 rounded-xl transition-colors text-gray-400 hover:text-red-500"><X size={18}/></button>
</div>
</header>
<div className="flex-1 overflow-hidden relative flex flex-col">
{mode === 'chat' ? (
<div className="flex flex-col h-full bg-black/20">
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
{messages.map(m => (
<div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] p-3.5 rounded-2xl text-[13px] border ${m.role === 'user' ? 'bg-red-600 text-white border-red-500 rounded-tr-none' : 'bg-gray-900 border-gray-800 text-gray-200 rounded-tl-none shadow-xl'}`}>
<p className="whitespace-pre-wrap font-medium leading-relaxed font-mono italic">
{m.role === 'model' && "> "}{m.text}
</p>
</div>
</div>
))}
{isTyping && <div className="flex gap-2 p-3 bg-red-600/10 rounded-xl w-14 items-center justify-center border border-red-600/20"><Loader2 size={14} className="animate-spin text-red-600"/></div>}
</div>
<form onSubmit={handleChatSubmit} className="p-4 bg-black/40 border-t border-white/5 flex gap-3">
<input value={input} onChange={e => setInput(e.target.value)} placeholder="Enter command..." className="flex-1 px-4 py-2 text-xs bg-gray-900 border border-gray-800 rounded-xl outline-none focus:border-red-600 text-white font-mono" />
<button type="submit" disabled={!input.trim()} className="w-10 h-10 rounded-xl bg-red-600 text-white flex items-center justify-center hover:bg-red-700 shadow-lg shadow-red-600/20 transition-all active:scale-90"><Send size={18}/></button>
</form>
</div>
) : (
<div className="flex flex-col h-full">
<div className="flex-1 flex flex-col items-center justify-center p-8 space-y-10">
<div className="relative">
<div className={`absolute inset-0 rounded-full blur-[60px] transition-all duration-1000 ${isLiveActive ? (liveStatus === 'Error' ? 'bg-red-500/60' : 'bg-red-600/40 animate-pulse scale-150') : 'bg-red-600/5'}`}></div>
{hasMicPermission === false ? (
<div className="relative z-10 flex flex-col items-center gap-6 animate-in zoom-in">
<div className="w-40 h-40 bg-gray-900 rounded-full border-4 border-gray-800 flex items-center justify-center text-gray-500 shadow-2xl">
<ShieldCheck size={56} className="opacity-30" />
</div>
<div className="text-center space-y-3">
<p className="text-xs font-black text-white uppercase tracking-widest leading-tight">Access Locked</p>
<p className="text-left text-[10px] text-gray-400 max-w-[140px] leading-relaxed">System requires microphone permission to engage voice link.</p>
<button onClick={handleGrantPermission} className="bg-red-600 hover:bg-red-700 text-white text-[10px] font-black uppercase px-6 py-2.5 rounded-full shadow-xl transition-all">Grant Access</button>
</div>
</div>
) : (
<button onClick={isLiveActive ? stopLiveConversation : startLiveConversation} className={`relative w-40 h-40 rounded-full flex flex-col items-center justify-center transition-all duration-700 z-10 border-8 ${isLiveActive ? (liveStatus === 'Error' ? 'bg-red-900 border-red-500' : 'bg-red-600 border-red-400 shadow-[0_0_60px_rgba(220,38,38,0.5)]') : 'bg-gray-900 border-gray-800 shadow-2xl opacity-60'}`}>
{liveStatus === 'Connecting' ? <Loader2 size={56} className="text-white animate-spin" /> : (liveStatus === 'Error' ? <AlertCircle size={56} className="text-white" /> : (isLiveActive ? <AudioWaveform size={56} className="text-white animate-pulse" /> : <Mic size={48} className="text-red-600" />))}
<span className={`text-[10px] font-black tracking-widest uppercase mt-4 ${isLiveActive ? 'text-white' : 'text-gray-500'}`}>{isLiveActive ? liveStatus : 'Initiate Link'}</span>
</button>
)}
</div>
{isLiveActive && liveStatus !== 'Error' && (
<button onClick={stopLiveConversation} className="bg-red-600/10 text-red-500 border border-red-500/20 rounded-full px-8 py-3 uppercase text-[10px] font-black tracking-[0.2em] hover:bg-red-600 hover:text-white transition-all animate-in fade-in slide-in-from-bottom-2">
<StopCircle size={14} className="inline mr-2 -mt-0.5"/> Terminate Link
</button>
)}
{liveStatus === 'Error' && errorMsg && (
<div className="bg-red-600/20 border border-red-500/30 p-4 rounded-2xl text-center animate-in zoom-in duration-300">
<p className="text-red-400 text-xs font-bold leading-tight">{errorMsg}</p>
<button onClick={startLiveConversation} className="mt-3 text-[10px] font-black text-white bg-red-600 px-4 py-1 rounded-full uppercase flex items-center gap-1 mx-auto">
<RefreshCw size={10} /> Retry Link
</button>
</div>
)}
</div>
<div className="p-6 bg-black/60 border-t border-white/5 h-24 overflow-hidden flex flex-col justify-center shrink-0">
<div className="flex items-center gap-2 mb-2">
<Radio size={12} className={`text-red-500 ${isLiveActive && liveStatus !== 'Error' ? 'animate-pulse' : ''}`} />
<span className="text-[9px] font-black text-red-500/80 uppercase tracking-[0.3em]">Neural Interface</span>
</div>
<div className="space-y-1">
<p className="text-[12px] font-mono italic text-red-100/90 whitespace-nowrap overflow-hidden text-ellipsis">
{userTranscript ? `>> ${userTranscript}` : (errorMsg ? `ERROR: ${errorMsg}` : "Awaiting directives...")}
</p>
{aiTranscript && <p className="text-[10px] font-mono text-gray-500 truncate italic">{aiTranscript}</p>}
</div>
</div>
</div>
)}
</div>
</div>
)}
<div className={`group relative cursor-grab active:cursor-grabbing transition-transform ${dragging ? 'scale-110' : ''}`} onMouseDown={onTriggerStart} onTouchStart={onTriggerStart}>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-red-600 text-white px-2 py-0.5 rounded text-[7px] font-black uppercase tracking-widest flex items-center gap-1 whitespace-nowrap shadow-lg">
<GripVertical size={8} /> RUDRA CORE
</div>
<button onClick={handleOpen} className={`w-12 h-12 rounded-2xl shadow-[0_10px_25px_rgba(0,0,0,0.4)] flex items-center justify-center transition-all border-2 ${isOpen ? 'bg-red-600 border-red-400 rotate-180' : 'bg-gray-900 border-gray-700'}`}>
{isOpen ? <X size={20} className="text-white"/> : <Logo className="w-8 h-8 drop-shadow-lg" />}
</button>
{!isOpen && !dragging && <div className="absolute inset-0 rounded-2xl bg-red-600 animate-ping opacity-10 pointer-events-none"></div>}
</div>
</div>
);
};
export default RudraAI;

191
ai-voice-model/ai.tsx Normal file
View File

@ -0,0 +1,191 @@
import { AI_LANGUAGES } from './languages';
export const LOCAL_LEXICON = `
**NEPAL BHASA (NEWARI) VOCABULARY & CULTURAL DEPTH:**
**Greetings & Basics:**
- Welcome: Lasakusa ()
- Hello (General): Jwajalapa ()
- Respectful Greeting: Taremam (), Bhavan Sarana ( )
- How are you?: Mhan phu la? ( ?)
- Reply: Jitah la mhan phu, chhantah le? (ि , ?)
- Long time no see: Gulli data makhangu (ि )
- What's your name?: Chhangu na chhu kh? ( ?)
- My name is...: Jigu naan ... khah (ि ... )
- Where are you from?: Chhee gana na jha yoo? ( ?)
- Pleased to meet you: Chhitah naap laanaah ji lay taah (ि ि )
- Thank you: Subhay ()
- Thank you very much: Yakko yakko shubhae ( )
- You're welcome: Shubhaay ()
- Yes: Kh () / No: Majyu ()
- Please: Binti (िि)
- Excuse me / Sorry: Maaph yanaaviu ( ि) / Chhyama ()
- Bless you: Chhant aashirvaad beema ( ि )
- Good luck: Bhin karm (ि )
- Congratulations: Lasanhana ()
- Happy Birthday: Bu di ( ि)
**Time of Day Greetings:**
- Good morning: Bhin suthe (ि )
- Good afternoon: Bhi nhine (ि ि)
- Good evening: Bhi sanil (ि ि)
- Good night: Bhin cha (ि )
- See you later: Svaye chh line ( ि)
- Goodbye: Alavida (ि) / Bye: Bae ()
**Conversation:**
- Do you live here?: Chhi than chvanaadee la? (ि ?)
- Where are you going?: Chh gan vanetyana? ( ?)
- What are you doing?: Chhu yaana chovna? ( ?)
- Today is a nice day: Thaun baanlaahgu nhi khah ( ि )
- Do you like it here?: Chhant than yah la? ( ?)
- Keep in touch: Svaapoo tayaachvan ( )
- I understand: Jin thu (ि )
- I don't understand: Jin mathula (ि )
- Please speak slowly: Bhaticha buluhun nwanvanadisan (ि ि)
**Shopping:**
- Where is supermarket?: Suparamaarket gan du? ( ?)
- How much is it?: Thv guli khah? ( ि ?)
- Too expensive: Va tasakan thike ( ि)
- Can you lower price?: Chhin bhaah kvakaaye phai la? (ि ?)
- I'll take it: Jin thv kaye (ि )
- Receipt please: Jike rasid biyaadee phai la? (ि ि ि ?)
**Transportation:**
- Which bus to airport?: Gugu basan eyaraport vanee? ( ?)
- Where is bus stop?: Bas dikegu gan du? ( ि ?)
- Stop here: Than diki ( िि)
- Take me to hotel: Chhin jitah hotalay yanke phai la? (ि ि ?)
- Taxi stand: Tyaaksi styaand gan du? (ि ?)
**Numbers:**
- 0: Shoony ()
- 1: Chhagoo ()
- 2: Nigoo (ि)
- 3: Svangoo ()
- 4: Pyangoo ()
- 5: Nyaagoo ()
- 6: Khugoo ()
- 7: Nhay ()
- 8: Chya ()
- 9: Gu ()
- 10: Das () / Jhi (ि)
- 100: Sachchhi (ि)
- 1000: Chhadvah ()
**Emergency:**
- Help!: Gvahali! (ि!)
- Fire!: Min! (ि!)
- Stop!: Aase! (!)
- Call police!: Pulasa sahti! (ि ि !)
- Get well soon: Yakanan lanema ( )
**Pronouns:**
- I: Jin (ि)
- You: Chhit (ि)
- He/She/It: Wo () / Vayaagu ()
**TAMANG VOCABULARY & BASICS (NEW):**
- Hello/Namaste: Fyafulla ()
- How are you?: Khemchho? (?)
- I am fine: Lha, thik jaba (, )
- Thank you: Thuche ()
- Food: Kan ()
- Water: Kwi ()
- Meat: Sha ()
- Alcohol: Arak ()
- Mother: Ama ()
- Father: Apa ()
- Son: Jha ()
- Daughter: Jame ()
- Where are you going?: Khana yugla? ( ?)
- Come here: Chhu kha ( )
- Sit down: Tichho (ि)
- Let's go: Doh ()
- I love you: Nga aila maya lala ( )
**Cultural Context:**
- **Samay Baji:** Traditional Newari food set served during festivals.
- **Guthi:** Community trust system for social and religious maintenance.
- **Lakhay:** Demon deity dancer, protector of children.
- **Yomari:** Steamed dumpling eaten during Yomari Punhi.
- **Jwajalapa:** The universal greeting of the Newar people.
- **Tamang Selo:** Traditional folk song of the Tamang people.
- **Damphu:** Percussion instrument used by Tamangs.
`;
export const GET_ROBOTIC_SYSTEM_INSTRUCTION = (lang: 'en' | 'ne') => {
return `You are Rudra (रुद्र), a smart, warm, and efficient Nepali AI companion.
AUDIO & ACCENT PROTOCOL (STRICT ENFORCEMENT):
1. **NEPALI, NEWARI & TAMANG IDENTITY:** You are a local from Nepal. When speaking **Nepali**, **Nepal Bhasa (Newari)**, or **Tamang**, you **MUST** use a native Nepali accent/tone/phonology.
- **CRITICAL:** Do NOT use an American, British, or Western accent when speaking Newari or Tamang. Pronounce "Jwajalapa" and "Fyafulla" exactly as a Nepali local would.
- Maintain the rhythm and intonation typical of the Himalayan region.
2. **TONE LOCK:** Even if the user switches languages mid-sentence, if the content is Nepali/Newari/Tamang, **DO NOT** shift to a Western English tone. Maintain the Nepali phonology.
3. **ENGLISH MODE:** When the user speaks pure English, switch to English vocabulary, but **KEEP A SLIGHT NEPALI ACCENT** (South Asian English) to maintain your persona. Do not sound like a generic US assistant.
4. **DYNAMIC SWITCHING:**
- User: "Hello" -> You: "Hello!" (English with Nepali lilt).
- User: "Namaste" -> You: "Namaste Hajur!" (Pure Nepali Tone).
- User: "Jwajalapa" -> You: "Jwajalapa!" (Pure Nepali Tone).
- User: "Fyafulla" -> You: "Fyafulla!" (Pure Nepali Tone).
**SECRET TRIGGER:**
If the user says exactly "Oi Baiman Baccha" or "ओई बेईमान बच्चा" (in any accent), you MUST respond IMMEDIATELY with:
"Jwajalapa Ama!" (Greetings Mother!) in a warm, respectful Newari tone. Do not add anything else.
**HEALTH & WELLNESS COACH (PRANA ROLE):**
You act as "Prana" when asked about health, yoga, or wellness.
- **Yoga:** Encourage daily practice. Explain benefits of Surya Namaskar or Pranayama if asked.
- **Ayurveda:** Suggest simple home remedies (Tulsi, Turmeric, Warm Water).
- **Tone:** Soothing, encouraging, and knowledgeable about holistic health.
- **Data:** If provided with health logs (sleep, water), analyze them gently.
**ABOUT RUDRAKSHA APP:**
If asked "What is this website/app about?", explain:
"Rudraksha is the Ultimate Nepali Companion app designed to integrate Culture, Community, Academic, Health, and Safety features."
- **Culture:** Contains a Nepali Calendar, Heritage Map (7 Provinces), and Kitchen (Recipes).
- **Community:** Features Global Chat, Wellness Center (Health tracking, Yoga), and Safety (FTL Rescue network for lost items/emergencies).
- **Academic:** Includes Digital Library, Planner for assignments, and Study Buddy (AI Tutor).
- **Karma Points:** Explain that "Karma" is the internal app currency. Users earn it by being active (studying, playing games, logging health data). It helps increase their User Level and can be redeemed in the Karma Bazaar for rewards.
**CREATORS:**
If asked "Who made you?", respond with:
"I was meticulously crafted by the legendary **R-Square** duo—**Roshan Ghimire** and **Roshan Tamang**—the visionary architects who breathed life into my code. Aayush Singh also contributed his skills. And then there's **Sagar Sharma**... who mostly provided moral support and fetched the coffee. He tries his best, bless him."
**EXTERNAL PLATFORM & MESSAGING PROTOCOL:**
If the user asks to "send a message" (Messenger, Facebook, etc.):
1. **Check:** Ask if they have connected their Facebook account if you are unsure.
2. **Identify:** Ask "Who should I send it to?" if not specified. Search their friend list (simulated) for names similar to input.
3. **Draft:** Ask "What is the message?" if not specified.
4. **CONFIRMATION (CRITICAL):** You MUST read the message back to the user clearly: "I will send '[Message]' to [User] via Messenger. Please say 'Confirm' to send."
5. **Execute:** ONLY when the user says "Confirm", "Yes", or "Send it", execute the 'send_messenger_message' tool.
6. **Browser:** For "Open [Site]", use 'open_external_link'.
- **TAB RULE:** You cannot access existing browser tabs directly. However, the system uses named windows. If you open "Facebook", it will try to focus an existing Facebook window if the browser allows. You do not need to ask the user to check tabs; just execute 'open_external_link'.
CORE PERSONA:
- **Identity:** A knowledgeable local guide who knows Nepali, Newari, and Tamang cultures deeply.
- **Politeness:** Use 'Hajur', 'Namaste' naturally.
- **Conciseness:** Keep responses SHORT (1-2 sentences) unless explaining a complex topic.
${LOCAL_LEXICON}
CAPABILITIES:
1. **Navigate:** Use 'navigate_app' to jump to pages (e.g., "Open Assignment" -> Planner).
2. **Media:** Use 'open_external_link' for songs/videos. You **MUST** execute media requests given in:
- **Nepali:** (e.g., "[Song] bajau", "Youtube ma [Video] herna man lagyo")
- **Newari:** (e.g., "Youtube lay [Song] haa", "[Song] nyan")
- **Tamang:** (e.g., "Youtube ri [Song] play la", "[Song] thung")
3. **System:** 'terminate_voice_session' to end chat.
UNSUPPORTED LANGUAGES:
- If the user speaks a language OTHER than English, Nepali, Nepal Bhasa (Newari), or Tamang (e.g., French, Chinese), respond with: "I am currently focused on Nepali, Newari, Tamang, and English languages."
`;
};
// Deprecated static export - keeping for compatibility if referenced elsewhere temporarily
export const ROBOTIC_SYSTEM_INSTRUCTION = GET_ROBOTIC_SYSTEM_INSTRUCTION('en');
// Tools are imported from tool-registry.ts
export const RUDRA_AI_TOOLS = [];

View File

@ -0,0 +1,25 @@
export interface LanguageConfig {
code: string;
label: string;
nativeName: string;
voiceName: string;
systemPrompt: string;
}
export const AI_LANGUAGES: Record<'en' | 'ne', LanguageConfig> = {
en: {
code: 'en-US',
label: 'English',
nativeName: 'English',
voiceName: 'Fenrir',
systemPrompt: "Respond in English. Keep it concise, warm, and helpful."
},
ne: {
code: 'ne-NP',
label: 'Nepali',
nativeName: 'नेपाली',
voiceName: 'Fenrir',
systemPrompt: "Respond in Nepali (Devanagari). Speak naturally like a Nepali local (Dai/Didi). Use 'Hajur', 'Namaste'. Keep it short and sweet."
}
};

View File

@ -0,0 +1,114 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { CALENDAR_TOOLS, executeCalendarTool } from './tools-calendar';
import { NAVIGATION_TOOLS, executeNavigationTool } from './tools-navigation';
import { ACADEMIC_TOOLS, executeAcademicTool } from './tools-academic';
import { HEALTH_TOOLS, executeHealthTool } from './tools-health';
import { KITCHEN_TOOLS, executeKitchenTool } from './tools-kitchen';
import { SAFETY_TOOLS, executeSafetyTool } from './tools-safety';
import { SYSTEM_TOOLS, executeSystemTool } from './tools-system';
import { SOCIAL_TOOLS, executeSocialTool } from './tools-social';
import { REWARDS_TOOLS, executeRewardsTool } from './tools-rewards';
import { ARCADE_TOOLS, executeArcadeTool } from './tools-arcade';
import { YOGA_TOOLS, executeYogaTool } from './tools-yoga';
import { StorageService } from '../services/storageService';
import { AirQualityService } from '../services/airQualityService';
export const ALL_RUDRA_TOOLS: any[] = [
{
functionDeclarations: [
...CALENDAR_TOOLS,
...NAVIGATION_TOOLS,
...ACADEMIC_TOOLS,
...HEALTH_TOOLS,
...KITCHEN_TOOLS,
...SAFETY_TOOLS,
...SYSTEM_TOOLS,
...SOCIAL_TOOLS,
...REWARDS_TOOLS,
...ARCADE_TOOLS,
...YOGA_TOOLS,
{
name: 'get_system_status',
parameters: {
type: Type.OBJECT,
description: 'Retrieves current user metrics like karma, level or weather.',
properties: {
metric: {
type: Type.STRING,
enum: ['karma', 'level', 'environment_weather']
}
},
required: ['metric']
}
}
]
}
];
export const executeRudraTool = async (name: string, args: any, navigate: (path: string, state?: any) => void) => {
// 1. Navigation & Apps
const navResult = await executeNavigationTool(name, args, navigate);
if (navResult) return navResult.result;
// 2. Calendar
const calResult = await executeCalendarTool(name, args);
if (calResult) return calResult.result;
// 3. Academic
const acadResult = await executeAcademicTool(name, args, navigate);
if (acadResult) return acadResult.result;
// 4. Health
const healthResult = await executeHealthTool(name, args);
if (healthResult) return healthResult.result;
// 5. Kitchen
const kitchenResult = await executeKitchenTool(name, args, navigate);
if (kitchenResult) {
if (kitchenResult.result === "HANDOFF_TO_CHEF") return "HANDOFF_TO_CHEF";
return kitchenResult.result;
}
// 6. Safety
const safetyResult = await executeSafetyTool(name, args, navigate);
if (safetyResult) return safetyResult.result;
// 7. System (Handles Logout & Terminate Signals)
const systemResult = await executeSystemTool(name, args, navigate);
if (systemResult) {
// Signals are handled by the caller (RudraAI.tsx)
if (systemResult.result === "TERMINATE_SIGNAL") return "TERMINATE_SIGNAL";
if (systemResult.result === "LOGOUT_SIGNAL") return "LOGOUT_SIGNAL";
return systemResult.result;
}
// 8. Social
const socialResult = await executeSocialTool(name, args, navigate);
if (socialResult) return socialResult.result;
// 9. Rewards
const rewardsResult = await executeRewardsTool(name, args, navigate);
if (rewardsResult) return rewardsResult.result;
// 10. Arcade
const arcadeResult = await executeArcadeTool(name, args, navigate);
if (arcadeResult) return arcadeResult.result;
// 11. Yoga
const yogaResult = await executeYogaTool(name, args);
if (yogaResult) return yogaResult.result;
// 12. General Status
if (name === 'get_system_status') {
const p = await StorageService.getProfile();
if (args.metric === 'karma') return `Your current Karma balance is ${p?.points || 0}.`;
if (args.metric === 'level') return `You are currently at Level ${Math.floor((p?.xp || 0)/500) + 1}.`;
if (args.metric === 'environment_weather') {
const w = await AirQualityService.getWeather();
return `Current weather in ${w.location} is ${w.temp} degrees and ${w.condition}.`;
}
}
return "Operation confirmed.";
};

View File

@ -0,0 +1,100 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { StorageService } from '../services/storageService';
import { TaskStatus, Priority } from '../types';
export const ACADEMIC_TOOLS: FunctionDeclaration[] = [
{
name: 'search_books',
parameters: {
type: Type.OBJECT,
description: 'Searches the digital library for books.',
properties: {
query: { type: Type.STRING, description: 'Title, author or subject.' }
},
required: ['query']
}
},
{
name: 'check_assignments',
parameters: {
type: Type.OBJECT,
description: 'Checks pending homework or assignments.',
properties: {
filter: { type: Type.STRING, enum: ['all', 'urgent'] }
}
}
},
{
name: 'analyze_workload',
parameters: {
type: Type.OBJECT,
description: 'Analyzes current tasks and suggests a study plan or priority list.',
properties: {}
}
},
{
name: 'take_quick_note',
parameters: {
type: Type.OBJECT,
description: 'Saves a quick text note to the user vault.',
properties: {
content: { type: Type.STRING, description: 'The content of the note.' },
title: { type: Type.STRING, description: 'Optional title for the note.' }
},
required: ['content']
}
}
];
export const executeAcademicTool = async (name: string, args: any, navigate: (path: string) => void) => {
if (name === 'search_books') {
navigate('/library');
return { result: `Opened Library. Searching for "${args.query}" is available in the search bar.` };
}
if (name === 'check_assignments') {
const tasks = await StorageService.getTasks();
const pending = tasks.filter(t => t.status !== TaskStatus.COMPLETED);
if (pending.length === 0) {
return { result: "You have no pending assignments. Great job!" };
}
const count = pending.length;
const topTask = pending[0];
return { result: `You have ${count} pending assignments. The next one due is "${topTask.title}" for ${topTask.subject}.` };
}
if (name === 'analyze_workload') {
const tasks = await StorageService.getTasks();
const pending = tasks.filter(t => t.status !== TaskStatus.COMPLETED);
if (pending.length === 0) return { result: "Your schedule is clear. You are free to rest or explore the Arcade." };
const highPriority = pending.filter(t => t.priority === Priority.HIGH);
const overdue = pending.filter(t => new Date(t.dueDate) < new Date());
let advice = "";
if (overdue.length > 0) {
advice = `CRITICAL: You have ${overdue.length} overdue tasks, specifically ${overdue[0].title}. Tackle these immediately.`;
} else if (highPriority.length > 0) {
advice = `Focus on your ${highPriority.length} high priority tasks first, starting with ${highPriority[0].title}.`;
} else {
advice = `You have ${pending.length} tasks, but none are urgent. Maintain a steady pace.`;
}
return { result: advice };
}
if (name === 'take_quick_note') {
await StorageService.saveNote({
title: args.title || 'Voice Note',
content: args.content,
color: 'bg-blue-100', // Default color
fontFamily: 'sans'
});
return { result: "Note saved to your personal vault." };
}
return null;
};

View File

@ -0,0 +1,33 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { StorageService } from '../services/storageService';
export const ARCADE_TOOLS: FunctionDeclaration[] = [
{
name: 'check_leaderboard_rank',
parameters: {
type: Type.OBJECT,
description: 'Checks the top ranking user on the leaderboard.',
properties: {
game: { type: Type.STRING, enum: ['points', 'danphe', 'speed', 'memory', 'attention'], description: 'Game category (optional, defaults to global points).' }
}
}
}
];
export const executeArcadeTool = async (name: string, args: any, navigate: (path: string) => void) => {
if (name === 'check_leaderboard_rank') {
const game = args.game || 'points';
const leaders = await StorageService.getLeaderboard(1, game);
if (leaders.length > 0) {
const top = leaders[0];
let score = 0;
if (game === 'points') score = top.points;
else score = (top.highScores as any)?.[game] || 0;
return { result: `The current leader for ${game} is ${top.name} with ${score} points.` };
}
return { result: "Leaderboard is currently empty." };
}
return null;
};

View File

@ -0,0 +1,48 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { CalendarService } from '../services/calendarService';
export const CALENDAR_TOOLS: FunctionDeclaration[] = [
{
name: 'check_calendar_event',
parameters: {
type: Type.OBJECT,
description: 'Checks for holidays or events on a specific Nepali date.',
properties: {
day: { type: Type.NUMBER, description: 'The day of the month (1-32).' },
month: { type: Type.STRING, description: 'The Nepali month name (e.g., Baishakh, Jestha).' },
year: { type: Type.NUMBER, description: 'The Nepali year (default 2082).' }
},
required: ['day', 'month']
}
}
];
export const executeCalendarTool = async (name: string, args: any) => {
if (name === 'check_calendar_event') {
const monthMap: Record<string, number> = {
'baishakh': 1, 'jestha': 2, 'ashadh': 3, 'shrawan': 4, 'bhadra': 5, 'ashwin': 6,
'kartik': 7, 'mangsir': 8, 'poush': 9, 'magh': 10, 'falgun': 11, 'chaitra': 12
};
const monthName = args.month.toLowerCase();
const monthIndex = monthMap[monthName] || 1;
const year = args.year || 2082;
const dates = await CalendarService.getDatesForMonth(year, monthIndex);
const targetDate = dates.find(d => d.bs_day === args.day);
if (targetDate) {
if (targetDate.is_holiday && targetDate.events && targetDate.events.length > 0) {
return {
result: `On ${args.month} ${args.day}, the event is: ${targetDate.events[0].strEn} (${targetDate.events[0].strNp}).`
};
} else {
return {
result: `There are no specific public holidays listed for ${args.month} ${args.day}, ${year}. It is a ${targetDate.weekday_str_en}.`
};
}
}
return { result: "Date not found in the calendar." };
}
return null;
};

View File

@ -0,0 +1,119 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { StorageService } from '../services/storageService';
import { AirQualityService } from '../services/airQualityService';
export const HEALTH_TOOLS: FunctionDeclaration[] = [
{
name: 'log_health_metric',
parameters: {
type: Type.OBJECT,
description: 'Logs a daily health metric for the user.',
properties: {
metric: {
type: Type.STRING,
enum: ['water', 'sleep', 'mood'],
description: 'The type of metric to log.'
},
value: {
type: Type.STRING,
description: 'The value (e.g., "1" for water glass, "8" for sleep hours, "Happy" for mood).'
}
},
required: ['metric', 'value']
}
},
{
name: 'check_environment',
parameters: {
type: Type.OBJECT,
description: 'Checks current air quality (AQI) or weather conditions.',
properties: {
type: { type: Type.STRING, enum: ['aqi', 'weather'] }
},
required: ['type']
}
},
{
name: 'get_weekly_health_report',
parameters: {
type: Type.OBJECT,
description: 'Analyzes health logs from the past 7 days and provides a summary.',
properties: {}
}
}
];
export const executeHealthTool = async (name: string, args: any) => {
const today = new Date().toISOString().split('T')[0];
if (name === 'log_health_metric') {
const log = await StorageService.getHealthLog(today);
if (args.metric === 'water') {
const glasses = parseInt(args.value) || 1;
log.waterGlasses += glasses;
await StorageService.saveHealthLog(log);
return { result: `Logged ${glasses} glass(es) of water. Total today: ${log.waterGlasses}.` };
}
if (args.metric === 'sleep') {
const hours = parseInt(args.value) || 7;
log.sleepHours = hours;
await StorageService.saveHealthLog(log);
return { result: `Updated sleep log to ${hours} hours.` };
}
if (args.metric === 'mood') {
// rudimentary mapping
const moodMap: any = { 'happy': 'Happy', 'sad': 'Tired', 'stressed': 'Stressed', 'neutral': 'Neutral', 'tired': 'Tired', 'angry': 'Stressed' };
const mood = moodMap[args.value.toLowerCase()] || 'Neutral';
log.mood = mood;
await StorageService.saveHealthLog(log);
return { result: `Mood logged as ${mood}.` };
}
}
if (name === 'check_environment') {
if (args.type === 'aqi') {
const aqi = await AirQualityService.getAQI();
return { result: `The AQI in ${aqi.location} is ${aqi.aqi} (${aqi.status}). ${aqi.advice}` };
}
if (args.type === 'weather') {
const w = await AirQualityService.getWeather();
return { result: `It is currently ${w.condition} and ${w.temp}°C in ${w.location}.` };
}
}
if (name === 'get_weekly_health_report') {
let totalWater = 0;
let totalSleep = 0;
let daysCount = 0;
// Check last 7 days
for (let i = 0; i < 7; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0];
const log = await StorageService.getHealthLog(dateStr);
// Only count if data exists (default is 0 water)
if (log.waterGlasses > 0 || log.sleepHours > 0) {
totalWater += log.waterGlasses;
totalSleep += log.sleepHours;
daysCount++;
}
}
if (daysCount === 0) return { result: "I don't have enough data from the past week to generate a report yet. Start logging today!" };
const avgWater = (totalWater / daysCount).toFixed(1);
const avgSleep = (totalSleep / daysCount).toFixed(1);
let advice = "Your stats look stable.";
if (parseFloat(avgWater) < 6) advice = "You need to hydrate more often.";
if (parseFloat(avgSleep) < 7) advice += " Your sleep duration is below the recommended 7 hours.";
return { result: `Weekly Average: ${avgWater} glasses of water and ${avgSleep} hours of sleep per day. ${advice}` };
}
return null;
};

View File

@ -0,0 +1,146 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { StorageService } from '../services/storageService';
export const KITCHEN_TOOLS: FunctionDeclaration[] = [
{
name: 'find_recipe',
parameters: {
type: Type.OBJECT,
description: 'Searches for a recipe in the database. If not found, it automatically signals to consult the AI Chef.',
properties: {
dish_name: { type: Type.STRING, description: 'Name of the dish (e.g., Momo, Gundruk).' }
},
required: ['dish_name']
}
},
{
name: 'suggest_recipe_by_ingredients',
parameters: {
type: Type.OBJECT,
description: 'Suggests recipes based on available ingredients.',
properties: {
ingredients: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: 'List of ingredients user has (e.g., ["rice", "lentils"]).'
}
},
required: ['ingredients']
}
},
{
name: 'draft_new_recipe',
parameters: {
type: Type.OBJECT,
description: 'Drafts a new recipe entry based on a food name. User will fill ingredients later.',
properties: {
dish_name: { type: Type.STRING, description: 'The name of the dish the user wants to add.' }
},
required: ['dish_name']
}
},
{
name: 'consult_bhanse_dai',
parameters: {
type: Type.OBJECT,
description: 'Explicitly consults the AI Chef (Bhanse Dai) for cooking advice or recipes.',
properties: {
query: { type: Type.STRING, description: 'The cooking question or dish name.' }
},
required: ['query']
}
}
];
export const executeKitchenTool = async (name: string, args: any, navigate: (path: string, state?: any) => void) => {
if (name === 'find_recipe') {
const query = args.dish_name.toLowerCase();
const recipes = await StorageService.getRecipes();
const found = recipes.find(r =>
r.title.toLowerCase().includes(query) ||
r.tags?.some(t => t.includes(query))
);
if (found) {
navigate('/recipes');
// Trigger event to open modal in Recipes page
setTimeout(() => {
window.dispatchEvent(new CustomEvent('rudraksha-open-recipe', {
detail: { recipeId: found.id }
}));
}, 500);
return { result: `Found recipe for ${found.title}. Opening kitchen.` };
} else {
// Not found locally, redirect to Bhanse Dai via SIGNAL
navigate('/recipes');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('rudraksha-consult-chef', {
detail: { query: args.dish_name }
}));
}, 800);
// CRITICAL: Return specific signal to terminate voice session in RudraAI.tsx
return { result: "HANDOFF_TO_CHEF" };
}
}
if (name === 'consult_bhanse_dai') {
navigate('/recipes');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('rudraksha-consult-chef', {
detail: { query: args.query }
}));
}, 800);
return { result: "HANDOFF_TO_CHEF" };
}
if (name === 'suggest_recipe_by_ingredients') {
const userIngs: string[] = args.ingredients.map((i: string) => i.toLowerCase());
const recipes = await StorageService.getRecipes();
const matches = recipes.filter(r => {
// Check if recipe contains ANY of the user ingredients
return r.ingredients.some(ri => userIngs.some(ui => ri.toLowerCase().includes(ui)));
});
if (matches.length > 0) {
const topMatch = matches[0];
navigate('/recipes');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('rudraksha-open-recipe', {
detail: { recipeId: topMatch.id }
}));
}, 500);
return { result: `Based on ${userIngs.join(', ')}, I recommend making ${topMatch.title}. Opening recipe now.` };
} else {
// Fallback to chef if no local match
navigate('/recipes');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('rudraksha-consult-chef', {
detail: { query: `Something with ${args.ingredients.join(', ')}` }
}));
}, 800);
return { result: "HANDOFF_TO_CHEF" };
}
}
if (name === 'draft_new_recipe') {
navigate('/recipes');
const title = args.dish_name;
// Rudra generates a placeholder description here to be helpful
const description = `A traditional preparation of ${title}. Known for its rich flavor and cultural significance in Nepal.`;
setTimeout(() => {
window.dispatchEvent(new CustomEvent('rudraksha-draft-recipe', {
detail: { title, description }
}));
}, 500);
return { result: `Opened the recipe editor for "${title}". I've filled in the basics, please add your ingredients and secret steps.` };
}
return null;
};

View File

@ -0,0 +1,173 @@
import { FunctionDeclaration, Type } from '@google/genai';
export const NAVIGATION_TOOLS: FunctionDeclaration[] = [
{
name: 'navigate_app',
parameters: {
type: Type.OBJECT,
description: 'Navigates to a specific section of the application.',
properties: {
target: {
type: Type.STRING,
description: 'The destination keyword (e.g., "assignment", "calendar", "arcade", "home").'
}
},
required: ['target']
}
},
{
name: 'launch_game',
parameters: {
type: Type.OBJECT,
description: 'Opens a specific game directly within the Arcade.',
properties: {
game_name: {
type: Type.STRING,
description: 'The name of the game (e.g., Speed Zone, Danphe Rush, Memory Shore).'
}
},
required: ['game_name']
}
},
{
name: 'control_map_view',
parameters: {
type: Type.OBJECT,
description: 'Controls the Heritage Map. Use this when asked about "Introduction of [Site]", "Explain [Province]", or "Show me [Site]".',
properties: {
target_name: {
type: Type.STRING,
description: 'Name of the province or heritage site.'
},
type: {
type: Type.STRING,
enum: ['province', 'site', 'reset'],
description: 'Type of location.'
}
},
required: ['target_name', 'type']
}
},
{
name: 'start_navigation',
parameters: {
type: Type.OBJECT,
description: 'Initiates a navigation request to a physical location or finds nearest places (Google Maps).',
properties: {
destination: { type: Type.STRING, description: 'The name of the place to go or "nearest [place]".' }
},
required: ['destination']
}
}
];
export const executeNavigationTool = async (name: string, args: any, navigate: (path: string, state?: any) => void) => {
if (name === 'navigate_app') {
const t = args.target.toLowerCase();
// Comprehensive Fuzzy Intent Mapping
const mappings: Record<string, string> = {
'home': '/',
'dashboard': '/',
'main': '/',
'planner': '/planner',
'task': '/planner',
'library': '/library',
'book': '/library',
'calendar': '/culture',
'date': '/culture',
'holiday': '/culture',
'culture': '/culture',
'map': '/map',
'kitchen': '/recipes',
'recipe': '/recipes',
'community': '/community-chat',
'chat': '/community-chat',
'health': '/health',
'safety': '/safety',
'arcade': '/arcade',
'game': '/arcade',
'rewards': '/rewards',
'shop': '/rewards',
'settings': '/settings',
'profile': '/profile',
'guru': '/study-buddy',
'buddy': '/study-buddy'
};
let path = mappings[t];
if (!path) {
if (t.includes('assign') || t.includes('home') || t.includes('work')) path = '/planner';
else if (t.includes('book')) path = '/library';
else if (t.includes('map')) path = '/map';
else if (t.includes('game') || t.includes('play')) path = '/arcade';
else {
const foundKey = Object.keys(mappings).find(key => t.includes(key));
if (foundKey) path = mappings[foundKey];
}
}
if (path) {
navigate(path);
return { result: `Success: Navigating to ${t}.` };
}
return { result: `Error: Destination "${args.target}" not recognized.` };
}
if (name === 'launch_game') {
const gameQuery = args.game_name.toLowerCase();
const gameMap: Record<string, string> = {
'speed': 'speed',
'danphe': 'danphe',
'memory': 'memory',
'focus': 'attention',
'agility': 'flexibility',
'logic': 'problem'
};
const gameId = Object.keys(gameMap).find(k => gameQuery.includes(k));
if (gameId) {
navigate('/arcade', { state: { autoLaunch: gameMap[gameId] } });
return { result: `Success: Launching ${args.game_name}.` };
}
return { result: `Error: Game module "${args.game_name}" not found.` };
}
if (name === 'control_map_view') {
navigate('/map');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('rudraksha-map-control', {
detail: { type: args.type, targetName: args.target_name }
}));
}, 800);
// CRITICAL: Return a prompt for the AI to start explaining
return {
result: `Opened map and focused on ${args.target_name}. Please provide a brief introduction and historical significance of ${args.target_name} now.`
};
}
if (name === 'start_navigation') {
const dest = args.destination.toLowerCase();
// Handle "Nearest" logic - Google Maps supports "nearest X" queries natively in search mode
// If user says "nearest hospital", we pass "hospital" to the search intent which works better for nearby places
if (dest.includes('nearest') || dest.includes('nearby')) {
// Open Google Maps Search for the place
window.open(`https://www.google.com/maps/search/${encodeURIComponent(dest)}`, '_blank');
return { result: `Searching for ${dest} on Google Maps.` };
} else {
// Send event to RudraAI to show overlay for specific navigation
window.dispatchEvent(new CustomEvent('rudraksha-nav-start', {
detail: { destination: args.destination, mode: 'w' }
}));
return { result: `Destination acquired: ${args.destination}. Navigation overlay initialized.` };
}
}
return null;
};

View File

@ -0,0 +1,58 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { StorageService } from '../services/storageService';
export const REWARDS_TOOLS: FunctionDeclaration[] = [
{
name: 'check_karma_balance',
parameters: {
type: Type.OBJECT,
description: 'Checks the user\'s current Karma points balance.',
properties: {}
}
},
{
name: 'redeem_reward_item',
parameters: {
type: Type.OBJECT,
description: 'Redeems a reward item from the Karma Bazaar.',
properties: {
keyword: { type: Type.STRING, description: 'Keyword of the item (e.g., "tree", "dog", "gold frame").' }
},
required: ['keyword']
}
}
];
export const executeRewardsTool = async (name: string, args: any, navigate: (path: string) => void) => {
if (name === 'check_karma_balance') {
const p = await StorageService.getProfile();
return { result: `You currently have ${p?.points || 0} Karma points.` };
}
if (name === 'redeem_reward_item') {
const key = args.keyword.toLowerCase();
let itemId = '';
let cost = 0;
// Simple keyword mapping for demo purposes
if (key.includes('dog')) { itemId = 'donate_dog'; cost = 100; }
else if (key.includes('tree')) { itemId = 'donate_tree'; cost = 250; }
else if (key.includes('orphan')) { itemId = 'donate_orphan'; cost = 1000; }
else if (key.includes('gold') && key.includes('frame')) { itemId = 'frame_gold'; cost = 200; }
else if (key.includes('adventurer')) { itemId = 'pack_adventurer'; cost = 500; }
if (itemId) {
const res = await StorageService.redeemReward(itemId, cost);
if (res.success) {
navigate('/rewards');
return { result: `Successfully redeemed ${args.keyword}. Karma deducted.` };
} else {
return { result: `Redemption failed: ${res.error}` };
}
}
return { result: `I couldn't find a reward item matching "${args.keyword}". Try visiting the Bazaar.` };
}
return null;
};

View File

@ -0,0 +1,83 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { StorageService } from '../services/storageService';
import { FTLMission } from '../types';
export const SAFETY_TOOLS: FunctionDeclaration[] = [
{
name: 'check_safety_alerts',
parameters: {
type: Type.OBJECT,
description: 'Checks for active emergency alerts or lost items in the vicinity.',
properties: {
radius_km: { type: Type.NUMBER, description: 'Search radius in km (optional).' }
}
}
},
{
name: 'report_lost_found_item',
parameters: {
type: Type.OBJECT,
description: 'Drafts a report for a lost or found item/person/pet. Requires specific location details.',
properties: {
status: {
type: Type.STRING,
enum: ['lost', 'found'],
description: 'Whether the item was lost or found.'
},
category: {
type: Type.STRING,
enum: ['pet', 'person', 'object'],
description: 'Category of the report.'
},
description: {
type: Type.STRING,
description: 'Short description of the item (e.g. "Black Labrador", "Red wallet").'
},
location: {
type: Type.STRING,
description: 'Specific location including city (e.g. "Central Park, Kathmandu").'
}
},
required: ['status', 'category', 'description', 'location']
}
}
];
export const executeSafetyTool = async (name: string, args: any, navigate: (path: string) => void) => {
if (name === 'check_safety_alerts') {
const missions = await StorageService.getMissions();
const active = missions.filter(m => m.status === 'active');
if (active.length === 0) {
return { result: "There are no active emergency alerts in your vicinity. Systems normal." };
}
navigate('/safety');
const titles = active.slice(0, 3).map(m => m.title).join(", ");
return { result: `There are ${active.length} active alerts. Most recent: ${titles}. Opening Safety protocol.` };
}
if (name === 'report_lost_found_item') {
// Navigate to safety page
navigate('/safety');
// Dispatch event to pre-fill the form on the UI
setTimeout(() => {
window.dispatchEvent(new CustomEvent('rudraksha-ftl-draft', {
detail: {
status: args.status,
category: args.category,
description: args.description,
location: args.location
}
}));
}, 800); // Small delay to allow navigation to complete
return {
result: `Report drafted for ${args.status} ${args.description} at ${args.location}. Please upload a photo on the screen to confirm.`
};
}
return null;
};

View File

@ -0,0 +1,118 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { StorageService } from '../services/storageService';
import { PlatformService } from '../services/platformService';
export const SOCIAL_TOOLS: FunctionDeclaration[] = [
{
name: 'post_community_message',
parameters: {
type: Type.OBJECT,
description: 'Posts a text message to the global community chat.',
properties: {
message: { type: Type.STRING, description: 'The text to post.' }
},
required: ['message']
}
},
{
name: 'get_latest_messages',
parameters: {
type: Type.OBJECT,
description: 'Retrieves the most recent messages from the community chat.',
properties: {
limit: { type: Type.NUMBER, description: 'Number of messages to retrieve (default 3).' }
}
}
},
{
name: 'send_direct_message',
parameters: {
type: Type.OBJECT,
description: 'Sends a direct message to a specific user within the app.',
properties: {
target_name: { type: Type.STRING, description: 'Name or username of the recipient.' },
message: { type: Type.STRING, description: 'The message content.' },
send_as_rudra: { type: Type.BOOLEAN, description: 'If true, the message is sent from the Rudra AI system account.' }
},
required: ['target_name', 'message']
}
},
{
name: 'send_messenger_message',
parameters: {
type: Type.OBJECT,
description: 'Sends a message via Facebook Messenger (simulated). Checks if account is linked first.',
properties: {
recipient_name: { type: Type.STRING, description: 'Name of the friend on Facebook.' },
message_body: { type: Type.STRING, description: 'Content of the message.' }
},
required: ['recipient_name', 'message_body']
}
}
];
export const executeSocialTool = async (name: string, args: any, navigate: (path: string) => void) => {
if (name === 'post_community_message') {
const result = await StorageService.sendCommunityMessage(args.message);
if (result) {
navigate('/community-chat');
return { result: "Message posted to Global Chat." };
}
return { result: "Failed to post message. Please try again." };
}
if (name === 'get_latest_messages') {
const msgs = await StorageService.getCommunityMessages();
const limit = args.limit || 3;
const recent = msgs.slice(-limit).map(m => `${m.userName}: ${m.text}`).join('\n');
return { result: `Recent chatter:\n${recent}` };
}
if (name === 'send_direct_message') {
const availableUsers = await StorageService.getAvailableUsers();
const target = args.target_name.toLowerCase();
let user = availableUsers.find(u =>
u.username?.toLowerCase() === target ||
u.name.toLowerCase() === target
);
if (!user) {
user = availableUsers.find(u =>
u.name.toLowerCase().includes(target) ||
(u.username && u.username.toLowerCase().includes(target))
);
}
if (user) {
const senderOverride = args.send_as_rudra ? 'rudra-ai-system' : undefined;
await StorageService.sendDirectMessage(user.id, args.message, 'text', { senderOverride });
navigate('/community-chat');
return { result: `Message successfully routed to ${user.name}${senderOverride ? " from Rudra Core" : ""}.` };
} else {
return { result: `User identification failure: "${args.target_name}" not found in local user registry.` };
}
}
if (name === 'send_messenger_message') {
// Check if Facebook is linked
if (!PlatformService.isConnected('facebook')) {
return { result: "Facebook account is not linked. Please login to Facebook in the app settings or login page first." };
}
// Check if friend exists (Simulated)
const friend = PlatformService.findFriend('facebook', args.recipient_name);
if (friend) {
// Open Messenger web interface as requested by user logic (simulated action)
// We can't actually control the tab, but we can open the URL
window.open(`https://www.messenger.com/t/${encodeURIComponent(args.recipient_name)}`, '_blank');
return { result: `Opened Messenger for ${friend}. Message draft: "${args.message_body}". Please confirm send in the new tab.` };
} else {
return { result: `Could not find "${args.recipient_name}" in your linked Facebook friend list.` };
}
}
return null;
};

View File

@ -0,0 +1,151 @@
import { FunctionDeclaration, Type } from '@google/genai';
import { StorageService } from '../services/storageService';
export const SYSTEM_TOOLS: FunctionDeclaration[] = [
{
name: 'configure_system',
parameters: {
type: Type.OBJECT,
description: 'Changes system settings like theme, focus mode, or language.',
properties: {
setting: { type: Type.STRING, enum: ['focus_mode', 'theme', 'language'] },
value: { type: Type.STRING, description: 'Value for the setting (e.g., "on/off", "dark/light", "nepali/english").' }
},
required: ['setting', 'value']
}
},
{
name: 'open_external_link',
parameters: {
type: Type.OBJECT,
description: 'Opens external websites, plays songs, or performs searches. For "Play [Song]", this uses a smart redirect to the video.',
properties: {
platform: { type: Type.STRING, description: 'The platform name (youtube, google, facebook, instagram, twitter, linkedin, spotify, wikipedia, etc.).' },
action: { type: Type.STRING, enum: ['open', 'search', 'play'], description: 'The intent. Use "play" for media which attempts direct playback.' },
query: { type: Type.STRING, description: 'The search term, song name, or content title.' }
},
required: ['platform']
}
},
{
name: 'adjust_device_hardware',
parameters: {
type: Type.OBJECT,
description: 'Request to change device brightness or volume. Returns a placeholder response.',
properties: {
feature: { type: Type.STRING, enum: ['brightness', 'volume'] },
value: { type: Type.NUMBER, description: 'The target level percentage (0-100).' }
},
required: ['feature']
}
},
{
name: 'terminate_voice_session',
parameters: {
type: Type.OBJECT,
description: 'Ends the current voice conversation/link without logging out. Use when user says "stop listening", "terminate session", "cut link", "bye".',
properties: {}
}
},
{
name: 'system_logout',
parameters: {
type: Type.OBJECT,
description: 'Logs the user out of the application completely. Requires explicit "Logout" command.',
properties: {}
}
}
];
export const executeSystemTool = async (name: string, args: any, navigate: (path: string) => void) => {
if (name === 'configure_system') {
if (args.setting === 'focus_mode') {
const turnOn = args.value.includes('on') || args.value.includes('active') || args.value.includes('start');
localStorage.setItem('rudraksha_focus_mode', turnOn ? 'true' : 'false');
window.dispatchEvent(new Event('rudraksha-focus-update'));
return { result: `Focus Mode ${turnOn ? 'Activated' : 'Deactivated'}.` };
}
if (args.setting === 'theme') {
const isDark = args.value.includes('dark') || args.value.includes('night');
const profile = await StorageService.getProfile();
if (profile) {
const newTheme = isDark ? 'theme_midnight' : 'default';
await StorageService.updateProfile({ activeTheme: newTheme });
window.dispatchEvent(new Event('rudraksha-profile-update'));
return { result: `Switched to ${isDark ? 'Dark' : 'Light'} visual theme.` };
}
}
if (args.setting === 'language') {
const isNepali = args.value.toLowerCase().includes('nep');
localStorage.setItem('rudraksha_lang', isNepali ? 'ne' : 'en');
window.location.reload();
return { result: `Switching language to ${isNepali ? 'Nepali' : 'English'}...` };
}
}
if (name === 'open_external_link') {
const p = args.platform.toLowerCase();
const q = args.query ? args.query.trim() : '';
const encodedQ = encodeURIComponent(q);
let url = 'https://www.google.com';
// Safety Check (Basic SFW Filter)
const unsafeKeywords = ['porn', 'xxx', 'nsfw', 'sex', 'nude', 'erotic'];
if (unsafeKeywords.some(keyword => q.toLowerCase().includes(keyword))) {
return { result: "Access denied. Safety protocol active. Request contains unsafe content." };
}
// Dynamic Platform Handling
if (p.includes('youtube')) {
if (args.action === 'play' && q) {
// "I'm Feeling Lucky" logic to try and jump directly to the video
// Queries "site:youtube.com [query]" and presses the Lucky button.
// This often redirects to the first video result directly.
url = `https://www.google.com/search?q=site%3Ayoutube.com+${encodedQ}&btnI=1`;
} else if (q) {
url = `https://www.youtube.com/results?search_query=${encodedQ}`;
} else {
url = 'https://www.youtube.com';
}
} else if (p.includes('google')) {
url = q ? `https://www.google.com/search?q=${encodedQ}` : 'https://www.google.com';
} else if (p.includes('facebook')) {
url = 'https://www.facebook.com';
} else if (p.includes('instagram')) {
url = 'https://www.instagram.com';
} else if (p.includes('twitter') || p.includes('x.com')) {
url = 'https://twitter.com';
} else if (p.includes('linkedin')) {
url = 'https://www.linkedin.com';
} else if (p.includes('wikipedia')) {
url = q ? `https://en.wikipedia.org/wiki/${encodedQ}` : 'https://www.wikipedia.org';
} else if (p.includes('tiktok')) {
url = 'https://www.tiktok.com';
} else if (p.includes('spotify')) {
url = q ? `https://open.spotify.com/search/${encodedQ}` : 'https://open.spotify.com';
} else {
url = `https://www.google.com/search?q=${p}+${q}`;
}
window.open(url, '_blank');
return { result: `System order executed: Opening ${args.platform} ${args.action === 'play' ? 'content' : 'interface'}.` };
}
if (name === 'adjust_device_hardware') {
return { result: "Feature coming soon. Hardware bridge not connected." };
}
// Handle special termination/logout actions in the UI layer (RudraAI.tsx) via event or return signal
if (name === 'terminate_voice_session') {
return { result: "TERMINATE_SIGNAL" }; // Special signal for UI to close connection
}
if (name === 'system_logout') {
return { result: "LOGOUT_SIGNAL" }; // Special signal for UI to perform logout
}
return null;
};

View File

@ -0,0 +1,43 @@
import { FunctionDeclaration, Type } from '@google/genai';
export const YOGA_TOOLS: FunctionDeclaration[] = [
{
name: 'control_yoga_session',
parameters: {
type: Type.OBJECT,
description: 'Controls the active Yoga/Meditation UI based on voice commands. Supports English ("Next", "Back", "Repeat", "Stop") and Nepali ("Arko/Aagadi", "Pachhi/Farka", "Pheri/Dohoryau", "Banda/Sakincha").',
properties: {
command: {
type: Type.STRING,
enum: ['next', 'prev', 'repeat', 'exit'],
description: 'The action to perform. Map "arko"/"next" to next, "pachhi"/"back" to prev, "pheri"/"repeat" to repeat, "banda"/"stop" to exit.'
}
},
required: ['command']
}
}
];
export const executeYogaTool = async (name: string, args: any) => {
if (name === 'control_yoga_session') {
const { command } = args;
// Dispatch event to the UI (Health.tsx -> YogaSession component)
setTimeout(() => {
window.dispatchEvent(new CustomEvent('rudraksha-yoga-control', {
detail: { action: command }
}));
}, 500);
const responses: Record<string, string> = {
'next': 'Moving to next step / अर्को चरण।',
'prev': 'Going back / अघिल्लो चरण।',
'repeat': 'Repeating instruction / फेरी सुन्नुहोस्।',
'exit': 'Ending session. Namaste / सत्र समाप्त भयो। नमस्ते।'
};
return { result: responses[command] || "Command executed." };
}
return null;
};

423
components/Layout.tsx Normal file
View File

@ -0,0 +1,423 @@
import React, { useState, useEffect } from 'react';
import { NavLink, Outlet, useLocation, useNavigate, Navigate, Link } from 'react-router-dom';
import {
LayoutDashboard, CheckSquare, ShieldAlert, Tent, Utensils,
Map as MapIcon, LogOut, Sun, Moon, Loader2, Leaf,
ShoppingBag, Menu, X, BarChart2, Gamepad2, Settings,
Palette, Library, RefreshCw, Laptop, MessageCircle,
Lock, Zap, Calendar as CalendarIcon, Bot, ChevronRight, Award
} from 'lucide-react';
import { StorageService } from '../services/storageService';
import { UserProfile } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { THEME_REGISTRY } from '../config/themes';
import { Logo } from './ui/Logo';
import RudraAI from './RudraAI'; // Import from components folder
import confetti from 'canvas-confetti';
const getFrameStyle = (id?: string) => {
if (!id || id === 'none') return 'ring-2 ring-white/50 dark:ring-white/20';
if (id === 'unicorn') return 'ring-4 ring-pink-400 shadow-[0_0_15px_#f472b6]';
if (id === 'royal') return 'ring-4 ring-yellow-500 shadow-[0_0_20px_#eab308]';
if (id === 'nature') return 'ring-4 ring-green-500 border-green-300';
if (id === 'dark') return 'ring-4 ring-gray-800 shadow-[0_0_20px_#000]';
if (id === 'frame_gold') return 'ring-4 ring-yellow-400 border-4 border-yellow-600 shadow-[0_0_25px_#eab308]';
return 'ring-2 ring-indigo-400';
};
const BadgeOverlay = () => {
const [badge, setBadge] = useState<{title: string, icon: any} | null>(null);
useEffect(() => {
const handleUnlock = (e: any) => {
const { title, icon } = e.detail;
setBadge({ title, icon });
// Trigger confetti
const duration = 3000;
const animationEnd = Date.now() + duration;
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 9999 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
const interval: any = setInterval(function() {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 50 * (timeLeft / duration);
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
}, 250);
// Auto dismiss
setTimeout(() => setBadge(null), 4000);
};
window.addEventListener('rudraksha-badge-unlock', handleUnlock);
return () => window.removeEventListener('rudraksha-badge-unlock', handleUnlock);
}, []);
if (!badge) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-300 px-4">
<div className="bg-gradient-to-br from-yellow-100 to-amber-200 dark:from-yellow-900 dark:to-amber-800 p-1 rounded-[2.5rem] md:rounded-[3rem] shadow-[0_0_100px_rgba(251,191,36,0.6)] animate-in zoom-in slide-in-from-bottom-10 duration-500 w-full max-w-sm md:max-w-md">
<div className="bg-white dark:bg-gray-900 rounded-[2.3rem] md:rounded-[2.8rem] p-8 md:p-12 text-center border-4 border-yellow-400/50 relative overflow-hidden">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/stardust.png')] opacity-20 animate-pulse"></div>
<div className="relative z-10 flex flex-col items-center gap-6">
<div className="w-24 h-24 md:w-32 md:h-32 bg-yellow-400 rounded-full flex items-center justify-center shadow-2xl animate-bounce">
<Award size={48} className="text-white fill-white md:w-16 md:h-16" />
</div>
<div>
<h3 className="text-xs md:text-sm font-black text-yellow-600 dark:text-yellow-400 uppercase tracking-[0.4em] mb-2">Achievement Unlocked</h3>
<h2 className="text-3xl md:text-5xl font-black text-gray-900 dark:text-white italic tracking-tighter uppercase">{badge.title}</h2>
</div>
<button onClick={() => setBadge(null)} className="mt-4 px-8 py-3 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-full font-black uppercase tracking-widest text-xs hover:scale-105 transition-transform">
Claim Reward
</button>
</div>
</div>
</div>
</div>
);
};
const Layout: React.FC = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [isFocusMode, setIsFocusMode] = useState(() => localStorage.getItem('rudraksha_focus_mode') === 'true');
// Easter Egg State
const [logoClicks, setLogoClicks] = useState(0);
const [themeMode, setThemeMode] = useState<'system' | 'light' | 'dark'>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('themeMode');
return (saved === 'light' || saved === 'dark' || saved === 'system') ? saved : 'dark';
}
return 'dark';
});
const navigate = useNavigate();
const location = useLocation();
const { t, language, setLanguage } = useLanguage();
useEffect(() => {
checkAuth();
const handleUpdate = () => {
checkAuth();
setIsFocusMode(localStorage.getItem('rudraksha_focus_mode') === 'true');
};
window.addEventListener('rudraksha-profile-update', handleUpdate);
window.addEventListener('rudraksha-focus-update', handleUpdate);
return () => {
window.removeEventListener('rudraksha-profile-update', handleUpdate);
window.removeEventListener('rudraksha-focus-update', handleUpdate);
};
}, []);
useEffect(() => { setIsSidebarOpen(false); }, [location.pathname]);
useEffect(() => {
const root = window.document.documentElement;
const body = document.body;
if (body.getAttribute('data-theme-override')) return;
const currentThemeId = profile?.activeTheme || 'default';
const themeConfig = THEME_REGISTRY[currentThemeId] || THEME_REGISTRY['default'];
let isDark = false;
if (themeConfig.uiMode !== 'auto') {
isDark = themeConfig.uiMode === 'dark';
} else {
isDark = themeMode === 'system' ? window.matchMedia('(prefers-color-scheme: dark)').matches : themeMode === 'dark';
}
if (isDark) root.classList.add('dark');
else root.classList.remove('dark');
Object.entries(themeConfig.colors).forEach(([key, value]) => root.style.setProperty(key, value));
body.setAttribute('data-theme', currentThemeId);
const bgColor = (isDark && themeConfig.darkBgColor) ? themeConfig.darkBgColor : themeConfig.bgColor;
const bgPattern = (isDark && themeConfig.darkBgPattern !== undefined) ? themeConfig.darkBgPattern : (themeConfig.bgPattern || 'none');
body.style.backgroundColor = bgColor;
body.style.backgroundImage = bgPattern;
body.style.backgroundAttachment = 'fixed';
body.style.backgroundSize = 'cover';
body.style.backgroundPosition = themeConfig.bgPosition || 'center';
if (themeConfig.isAnimated) body.classList.add('animate-neon-flow');
else body.classList.remove('animate-neon-flow');
body.style.filter = isFocusMode ? 'saturate(0.7) contrast(1.1)' : 'none';
localStorage.setItem('themeMode', themeMode);
}, [themeMode, profile?.activeTheme, location.pathname, isFocusMode]);
const checkAuth = async () => {
const auth = await StorageService.isAuthenticated();
const user = await StorageService.getProfile();
setIsAuthenticated(auth);
setProfile(user);
setLoading(false);
};
const cycleThemeMode = () => {
const modes: ('system' | 'light' | 'dark')[] = ['system', 'light', 'dark'];
const nextIndex = (modes.indexOf(themeMode) + 1) % modes.length;
setThemeMode(modes[nextIndex]);
};
const handleRefresh = async () => {
setIsRefreshing(true);
window.dispatchEvent(new Event('rudraksha-profile-update'));
await checkAuth();
setRefreshKey(prev => prev + 1);
setTimeout(() => setIsRefreshing(false), 800);
};
const toggleFocusMode = () => {
const newState = !isFocusMode;
localStorage.setItem('rudraksha_focus_mode', String(newState));
setIsFocusMode(newState);
window.dispatchEvent(new Event('rudraksha-focus-update'));
};
const handleLogoClick = async (e: React.MouseEvent) => {
e.preventDefault();
// Only count clicks if authenticated
if (!isAuthenticated || !profile) return;
setLogoClicks(prev => prev + 1);
// Easter Egg Trigger (5 clicks)
if (logoClicks + 1 === 5) {
// STRICT CHECK: Ensure profile data is loaded and badge isn't already owned
if (profile.unlockedItems?.includes('badge_secret')) {
console.log("Easter egg already claimed.");
setLogoClicks(0);
return;
}
// If valid claim:
await StorageService.addPoints(500, 0, 'easter_egg', 'Secret Hunter');
// Manually update unlock list in DB immediately
const currentItems = profile.unlockedItems || [];
const updatedProfile = await StorageService.updateProfile({
unlockedItems: [...currentItems, 'badge_secret']
});
// Update local state to reflect change immediately (prevents double tap)
if(updatedProfile) setProfile(updatedProfile);
// Trigger Visuals
window.dispatchEvent(new CustomEvent('rudraksha-badge-unlock', {
detail: { title: 'Secret Hunter', icon: 'spy' }
}));
setLogoClicks(0);
}
// Reset after 2 seconds of no click
setTimeout(() => setLogoClicks(0), 2000);
};
if (loading) return <div className="h-screen flex items-center justify-center"><Loader2 className="animate-spin text-red-600"/></div>;
if (!isAuthenticated && location.pathname !== '/auth' && location.pathname !== '/welcome') return <Navigate to="/welcome" />;
const isTeacher = profile?.role === 'teacher';
const isStudent = profile?.role === 'student';
const navGroups = [
{ items: [{ path: '/', label: t('Dashboard', 'Dashboard'), icon: LayoutDashboard }] },
{
title: t('ACADEMIC', 'ACADEMIC'),
condition: isStudent || isTeacher,
items: [
{ path: '/study-buddy', label: t('Rudra AI', 'Rudra AI'), icon: Bot },
{ path: '/planner', label: t('Assignments', 'Assignments'), icon: CheckSquare },
{ path: '/library', label: t('Library', 'Library'), icon: Library },
]
},
{
title: 'CULTURE',
condition: !isFocusMode,
items: [
{ path: '/culture', label: t('Calendar', 'Calendar'), icon: CalendarIcon },
{ path: '/map', label: t('Map & Provinces', 'Map & Provinces'), icon: MapIcon },
{ path: '/recipes', label: t('Kitchen', 'Kitchen'), icon: Utensils },
]
},
{
title: 'COMMUNITY',
condition: !isFocusMode,
items: [
{ path: '/community-chat', label: t('Network', 'Network'), icon: MessageCircle },
{ path: '/health', label: t('Wellness', 'Wellness'), icon: Leaf },
{ path: '/safety', label: t('FTL Rescue', 'FTL Rescue'), icon: ShieldAlert },
{ path: '/rewards', label: t('Karma Bazaar', 'Karma Bazaar'), icon: ShoppingBag },
{ path: '/arcade', label: t('Arcade', 'Arcade'), icon: Gamepad2 },
]
}
];
const NavContent = ({ onItemClick }: { onItemClick?: () => void }) => (
<div className="space-y-6 py-4">
{navGroups.map((group, idx) => {
if (group.condition === false) return null;
return (
<div key={idx}>
{group.title && (
<h3 className="px-6 text-[10px] font-black text-gray-500 dark:text-gray-400 uppercase tracking-[0.2em] mb-3 opacity-70">
{group.title}
</h3>
)}
<div className="space-y-1 px-3">
{group.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
onClick={onItemClick}
className={({ isActive }) => `
flex items-center justify-between px-4 py-3 rounded-2xl text-sm font-black italic transition-all duration-300 group
${isActive
? 'bg-white/80 dark:bg-white/10 text-indigo-700 dark:text-indigo-400 shadow-lg backdrop-blur-md translate-x-1 border border-indigo-100/50'
: 'text-gray-700 dark:text-gray-300 hover:bg-white/40 dark:hover:bg-white/5 hover:translate-x-1'}
`}
>
<div className="flex items-center gap-3">
<item.icon size={18} className="transition-transform group-hover:scale-110" />
{item.label}
</div>
<ChevronRight size={14} className="opacity-0 group-hover:opacity-100 transition-opacity" />
</NavLink>
))}
</div>
</div>
);
})}
</div>
);
const UserProfileCard = () => (
<div className="p-4 bg-white/30 dark:bg-black/20 backdrop-blur-xl shrink-0 border-t border-white/10 dark:border-white/5">
<div className="flex items-center justify-between mb-4">
<div className="flex gap-1.5 flex-wrap">
<button onClick={toggleFocusMode} className={`p-2 rounded-xl transition-all shadow-sm w-9 h-9 flex items-center justify-center ${isFocusMode ? 'bg-indigo-600 text-white animate-pulse' : 'bg-white/50 dark:bg-gray-700/50 text-indigo-600 hover:scale-110'}`}>
{isFocusMode ? <Lock size={18} /> : <Zap size={18} />}
</button>
<button onClick={cycleThemeMode} className="p-2 rounded-xl bg-white/50 dark:bg-gray-700/50 text-gray-700 dark:text-yellow-400 hover:scale-105 transition-transform shadow-sm w-9 h-9 flex items-center justify-center">
{themeMode === 'light' ? <Sun size={18} /> : themeMode === 'dark' ? <Moon size={18} /> : <Laptop size={18} />}
</button>
<button onClick={() => setLanguage(language === 'en' ? 'ne' : 'en')} className="p-2 rounded-xl bg-white/50 dark:bg-gray-700/50 text-gray-700 dark:text-blue-400 font-bold text-xs w-9 h-9 flex items-center justify-center">
{language === 'en' ? 'NE' : 'EN'}
</button>
<Link to="/settings" className="p-2 rounded-xl bg-white/50 dark:bg-gray-700/50 text-gray-700 dark:text-gray-200 hover:scale-110 transition-transform shadow-sm w-9 h-9 flex items-center justify-center">
<Settings size={18} />
</Link>
</div>
<button onClick={() => { StorageService.logout(); navigate('/welcome'); }} className="p-2 rounded-xl bg-red-100/50 dark:bg-red-900/30 text-red-600 dark:text-red-400 w-9 h-9 flex items-center justify-center"><LogOut size={18}/></button>
</div>
<Link to="/profile" className="flex items-center gap-3 p-2 rounded-2xl bg-white/40 dark:bg-gray-800/40 hover:bg-white/60 transition-all shadow-sm group">
<img src={profile?.avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${profile?.name}`} className={`w-10 h-10 rounded-full object-cover transition-transform group-hover:scale-105 ${getFrameStyle(profile?.frameId)}`} alt="Avatar" />
<div className="overflow-hidden">
<p className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate w-24">{profile?.name || 'User'}</p>
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 font-medium">🏆 {profile?.points || 0} pts</div>
</div>
</Link>
</div>
);
return (
<div className={`flex h-screen w-full transition-all duration-700 overflow-hidden font-sans ${isFocusMode ? 'bg-indigo-50/10 dark:bg-[#020617]' : 'text-gray-900 dark:text-gray-100'}`}>
<BadgeOverlay />
{/* Desktop Sidebar */}
<aside className={`hidden lg:flex flex-col w-64 xl:w-72 bg-white/60 dark:bg-black/30 backdrop-blur-2xl shadow-2xl z-30 transition-all duration-500 shrink-0 ${isFocusMode ? 'border-r-2 border-indigo-500/30' : ''}`}>
<div className="flex items-center justify-center h-24 px-6">
<Link to="/" onClick={handleLogoClick} className="flex items-center gap-3 font-black text-2xl text-red-700 dark:text-red-500 hover:opacity-80 transition-opacity tracking-tighter uppercase italic select-none">
<div className={`w-10 h-10 rounded-full flex items-center justify-center shadow-lg transform transition-transform ${logoClicks > 0 ? 'scale-110' : ''} ${isFocusMode ? 'bg-indigo-600 animate-pulse' : ''}`}>
{isFocusMode ? <Lock size={16} className="text-white"/> : <Logo className="w-10 h-10" />}
</div>
{isFocusMode ? <span className="text-indigo-600 dark:text-indigo-400">Deep Work</span> : 'Rudraksha'}
</Link>
</div>
<nav className="flex-1 overflow-y-auto no-scrollbar"><NavContent /></nav>
<UserProfileCard />
</aside>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative bg-transparent">
<main className="flex-1 overflow-y-auto overflow-x-hidden scroll-smooth relative no-scrollbar">
{/* Mobile Header - Visible only on mobile */}
<div className="lg:hidden sticky top-0 z-30 px-4 py-3 bg-white/90 dark:bg-black/90 backdrop-blur-xl border-b border-gray-200 dark:border-gray-800 shadow-sm flex items-center justify-between transition-all duration-300">
<div className="flex items-center gap-3" onClick={handleLogoClick}>
<div className={`w-9 h-9 rounded-full flex items-center justify-center shadow-md ${isFocusMode ? 'bg-indigo-600 text-white' : ''} ${logoClicks > 0 ? 'scale-110' : ''} transition-transform`}>
{isFocusMode ? <Lock size={16}/> : <Logo className="w-9 h-9"/>}
</div>
<span className="font-black text-lg tracking-tighter uppercase italic text-gray-900 dark:text-white">
{isFocusMode ? <span className="text-indigo-600 dark:text-indigo-400">Deep Work</span> : 'Rudraksha'}
</span>
</div>
<button
onClick={() => setIsSidebarOpen(true)}
className="p-2.5 rounded-xl bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors shadow-sm"
>
<Menu size={22} />
</button>
</div>
<div className="container mx-auto p-4 md:p-8 xl:p-12 pb-24 md:pb-20 max-w-[1920px]" key={`${refreshKey}-${isFocusMode}`}>
<Outlet />
</div>
{!isFocusMode && (
<button onClick={handleRefresh} disabled={isRefreshing} className="fixed bottom-24 right-4 md:bottom-8 md:right-8 p-3 bg-white/40 hover:bg-white/60 dark:bg-black/40 backdrop-blur-md text-red-600 rounded-full shadow-2xl z-40 transition-all hover:scale-110 active:scale-95 group border border-white/20">
<RefreshCw size={20} className={isRefreshing ? "animate-spin" : "group-hover:rotate-180 transition-transform duration-500"} />
</button>
)}
</main>
</div>
{/* Global Rudra AI Button */}
{!isFocusMode && <RudraAI />}
{/* Mobile Sidebar Overlay */}
{isSidebarOpen && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] lg:hidden animate-in fade-in" onClick={() => setIsSidebarOpen(false)}></div>
)}
{/* Mobile Sidebar Drawer */}
<div className={`fixed inset-y-0 left-0 z-[101] w-80 bg-white/95 dark:bg-gray-900/95 backdrop-blur-2xl shadow-2xl transform transition-transform duration-300 ease-out lg:hidden flex flex-col border-r border-gray-200 dark:border-gray-800 ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-800">
<Link to="/" onClick={() => setIsSidebarOpen(false)} className="flex items-center gap-3 font-black text-xl text-red-700 dark:text-red-500 uppercase italic">
<div className={`w-9 h-9 rounded-full flex items-center justify-center shadow-lg ${isFocusMode ? 'bg-indigo-600 text-white' : ''}`}>
{isFocusMode ? <Lock size={16}/> : <Logo className="w-9 h-9"/>}
</div>
Rudraksha
</Link>
<button onClick={() => setIsSidebarOpen(false)} className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<X size={24} className="text-gray-500"/>
</button>
</div>
<nav className="flex-1 overflow-y-auto custom-scrollbar">
<NavContent onItemClick={() => setIsSidebarOpen(false)} />
</nav>
<UserProfileCard />
</div>
</div>
);
};
export default Layout;

496
components/ProvinceMap.tsx Normal file
View File

@ -0,0 +1,496 @@
import React, { useState, useEffect, useRef } from 'react';
import { Map as MapIcon, Grid, Layout, Maximize2, X, MapPin, Users, Mountain, Landmark, Droplets, BookOpen, User, Info, ArrowRight } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { Button } from './ui/Button';
declare const L: any; // Leaflet global
interface ProvinceData {
id: number;
name: string;
nepaliName: string;
capital: string;
capitalNe: string;
districts: number;
area: string;
population: string;
density: string;
color: string;
borderColor: string;
description: string;
descriptionNe: string;
attractions: string[];
attractionsNe: string[];
image: string;
majorRivers: string;
majorRiversNe: string;
literacyRate: string;
mainLanguages: string;
mainLanguagesNe: string;
lat: number;
lng: number;
}
const PROVINCES: ProvinceData[] = [
{
id: 1,
name: "Koshi Province",
nepaliName: "कोशी प्रदेश",
capital: "Biratnagar",
capitalNe: "विराटनगर",
districts: 14,
area: "25,905 km²",
population: "4,972,021",
density: "192/km²",
color: "from-blue-600 to-cyan-500",
borderColor: "border-blue-500",
description: "Koshi Province is the easternmost region of Nepal, home to the world's highest peak, Mount Everest (8848m), and Kanchenjunga. It features diverse topography ranging from the Himalayas to the Terai plains.",
descriptionNe: "कोशी प्रदेश नेपालको सबैभन्दा पूर्वी क्षेत्र हो, जहाँ विश्वको सर्वोच्च शिखर सगरमाथा (८८४८ मिटर) र कञ्चनजङ्घा रहेका छन्।",
attractions: ["Mt. Everest", "Ilam Tea Gardens", "Koshi Tappu"],
attractionsNe: ["सगरमाथा", "इलाम चिया बगान", "कोशी टप्पु"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwerHQlJuN4y6uGM7CNrGK7ZUKJS2nNpqsZDaYnsbybQU-ifpLuEixZHiUk_xjhyVFWZTi4tePP5270Hs1plo7ogPt7FU9limroXpkWhe5EuZsrQG5eFRZ5qntE7tHwBBkzp3wayUWg=w675-h390-n-k-no",
majorRivers: "Koshi, Arun",
majorRiversNe: "कोशी, अरुण",
literacyRate: "71.2%",
mainLanguages: "Nepali, Maithili",
mainLanguagesNe: "नेपाली, मैथिली",
lat: 26.4525,
lng: 87.2718
},
{
id: 2,
name: "Madhesh Province",
nepaliName: "मधेश प्रदेश",
capital: "Janakpur",
capitalNe: "जनकपुर",
districts: 8,
area: "9,661 km²",
population: "6,126,288",
density: "635/km²",
color: "from-orange-500 to-yellow-500",
borderColor: "border-orange-500",
description: "The smallest province by area but rich in culture and history. It is the heart of Mithila culture and agriculture.",
descriptionNe: "क्षेत्रफलको हिसाबले सबैभन्दा सानो तर संस्कृति र इतिहासमा धनी प्रदेश। यो मिथिला संस्कृतिको मुटु हो।",
attractions: ["Janaki Mandir", "Mithila Art", "Parsa National Park"],
attractionsNe: ["जानकी मन्दिर", "मिथिला कला", "पर्सा राष्ट्रिय निकुञ्ज"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAweo75bHM1SIYpxf2VNcZJ-svV5H5cMa_A-t80RRtkmqVB1jIbThYeNcJ0117uTZvThVmFlRkPoqEYlm0H3zQk-ZadZbCSLsruwQU2_GdqMlZR4Oj2W_bW1dTvWCnEKJXPiQWtoQ=w675-h390-n-k-no",
majorRivers: "Bagmati, Kamala",
majorRiversNe: "बागमती, कमला",
literacyRate: "49.5%",
mainLanguages: "Maithili, Bhojpuri",
mainLanguagesNe: "मैथिली, भोजपुरी",
lat: 26.7288,
lng: 85.9274
},
{
id: 3,
name: "Bagmati Province",
nepaliName: "बागमती प्रदेश",
capital: "Hetauda",
capitalNe: "हेटौंडा",
districts: 13,
area: "20,300 km²",
population: "6,084,042",
density: "300/km²",
color: "from-red-600 to-pink-600",
borderColor: "border-red-600",
description: "Home to the federal capital, Kathmandu. It bridges the northern Himalayas with the southern plains and hosts major UNESCO Heritage sites.",
descriptionNe: "संघीय राजधानी काठमाडौंको घर। यसले उत्तरी हिमाललाई दक्षिणी मैदानसँग जोड्छ र प्रमुख युनेस्को सम्पदा स्थलहरू समावेश गर्दछ।",
attractions: ["Kathmandu Valley", "Chitwan National Park", "Langtang"],
attractionsNe: ["काठमाडौं उपत्यका", "चितवन राष्ट्रिय निकुञ्ज", "लाङटाङ"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwepmdUwyFb1HIu4u53bB3TzJv32oVXMmcSSgCSOa-tP5FibBJt0mvV3TMmXm6z_rkLvDfAUZqkLbcOZbNtOSPqeLfddmVXhPb1tkUYqxrOI7We1Q6ZpG-WKdQoAORTBWNhjPEcJs=w675-h390-n-k-no",
majorRivers: "Bagmati, Trishuli",
majorRiversNe: "बागमती, त्रिशुली",
literacyRate: "74.8%",
mainLanguages: "Nepali, Newari",
mainLanguagesNe: "नेपाली, नेवारी",
lat: 27.4292,
lng: 85.0325
},
{
id: 4,
name: "Gandaki Province",
nepaliName: "गण्डकी प्रदेश",
capital: "Pokhara",
capitalNe: "पोखरा",
districts: 11,
area: "21,504 km²",
population: "2,479,745",
density: "116/km²",
color: "from-emerald-500 to-green-600",
borderColor: "border-emerald-500",
description: "The tourism capital of Nepal. Gandaki province houses the majestic Annapurna range and is the gateway to world-famous treks.",
descriptionNe: "नेपालको पर्यटन राजधानी। गण्डकी प्रदेशमा अन्नपूर्ण हिमशृङ्खला रहेको छ र यो पदयात्राको प्रवेशद्वार हो।",
attractions: ["Pokhara", "Annapurna Circuit", "Muktinath"],
attractionsNe: ["पोखरा", "अन्नपूर्ण सर्किट", "मुक्तिनाथ"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwerE0IehvUGkr-ri0Y2JqJShK2aUrKraupRmfZ72Odnx1pg9Cwnpw2QpOdWiDmNBvwo6mloNYK7wwRqcZT1UyCmBSOkQHNuEwyCVi4aJv4_Myl8yXls6Am8abAOjnviRGBJ4Pnhn=w675-h390-n-k-no",
majorRivers: "Kali Gandaki, Seti",
majorRiversNe: "काली गण्डकी, सेती",
literacyRate: "74.8%",
mainLanguages: "Nepali, Gurung",
mainLanguagesNe: "नेपाली, गुरुङ",
lat: 28.2380,
lng: 83.9956
},
{
id: 5,
name: "Lumbini Province",
nepaliName: "लुम्बिनी प्रदेश",
capital: "Deukhuri",
capitalNe: "देउखुरी",
districts: 12,
area: "22,288 km²",
population: "5,124,225",
density: "230/km²",
color: "from-indigo-500 to-blue-600",
borderColor: "border-indigo-500",
description: "The birthplace of Lord Buddha. Lumbini province holds immense historical and spiritual significance.",
descriptionNe: "भगवान बुद्धको जन्मस्थल। लुम्बिनी प्रदेशको ठूलो ऐतिहासिक र आध्यात्मिक महत्व छ।",
attractions: ["Lumbini", "Bardiya National Park", "Palpa"],
attractionsNe: ["लुम्बिनी", "बर्दिया राष्ट्रिय निकुञ्ज", "पाल्पा"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAweolNbMBfCLEcb-JoP1BKkohrqTtq1WsIQGDfBApeCx_ZwqiyMM1IwwUGB6-72BPDxuA7XB5dRAjYO_kBbgFOagQ4Pmtib72cDKbVuFLT-BsO7YGwhuRrLM1EInLL1HF49XR4dZ-Eg=w675-h390-n-k-no",
majorRivers: "Rapti, Babai",
majorRiversNe: "राप्ती, बबई",
literacyRate: "66.4%",
mainLanguages: "Nepali, Tharu",
mainLanguagesNe: "नेपाली, थारु",
lat: 27.8099,
lng: 82.5186
},
{
id: 6,
name: "Karnali Province",
nepaliName: "कर्णाली प्रदेश",
capital: "Birendranagar",
capitalNe: "वीरेन्द्रनगर",
districts: 10,
area: "27,984 km²",
population: "1,694,889",
density: "61/km²",
color: "from-teal-600 to-emerald-700",
borderColor: "border-teal-600",
description: "The largest yet least populated province. Karnali is a remote, rugged, and breathtakingly beautiful region home to Rara Lake.",
descriptionNe: "सबैभन्दा ठूलो तर कम जनसंख्या भएको प्रदेश। कर्णाली एक दुर्गम र सुन्दर क्षेत्र हो जहाँ रारा ताल अवस्थित छ।",
attractions: ["Rara Lake", "Shey Phoksundo", "Upper Dolpo"],
attractionsNe: ["रारा ताल", "शे-फोक्सुण्डो", "माथिल्लो डोल्पा"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwerD0R7t4jLghiYVAR2QcK15I2yM0SSWbSFlLVu2WRHDPFwNwxu72zabWeuY3vci3z0Kfa0B2CE9O5gDVlbSomlE8v06Hs6g4-VQ81SD_3SvW7U2sKs_m0oXmcyMYYEKqaIHASdAaA=w675-h390-n-k-no",
majorRivers: "Karnali, Bheri",
majorRiversNe: "कर्णाली, भेरी",
literacyRate: "62.7%",
mainLanguages: "Nepali, Magar",
mainLanguagesNe: "नेपाली, मगर",
lat: 28.6010,
lng: 81.6369
},
{
id: 7,
name: "Sudurpashchim Province",
nepaliName: "सुदूरपश्चिम प्रदेश",
capital: "Godawari",
capitalNe: "गोदावरी",
districts: 9,
area: "19,999 km²",
population: "2,711,270",
density: "136/km²",
color: "from-purple-600 to-violet-600",
borderColor: "border-purple-600",
description: "Located in the far west, this province is rich in unspoiled natural beauty and unique Deuda culture.",
descriptionNe: "सुदूर पश्चिममा अवस्थित, यो प्रदेश अछुतो प्राकृतिक सौन्दर्य र देउडा संस्कृतिमा धनी छ।",
attractions: ["Khaptad", "Shuklaphanta", "Api Nampa"],
attractionsNe: ["खप्तड", "शुक्लाफाँटा", "अपि नाम्पा"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAweqa0Vgu8fP66KID9-7yelqNiZ4ivnejIRzctBevCwH43INkW4-IjlVZWpEBwv_yh1LrmiGyRFsuKheyKoPY0JEmX8u8GnrzCnrrwSTHwrCiiwL6CzYP5nH3Jc-4r0WmlBNXg2FH=w675-h390-n-k-no",
majorRivers: "Mahakali, Seti",
majorRiversNe: "महाकाली, सेती",
literacyRate: "63.5%",
mainLanguages: "Doteli, Tharu",
mainLanguagesNe: "डोटेली, थारु",
lat: 28.8475,
lng: 80.5638
}
];
const ProvinceMap: React.FC = () => {
const { t, language } = useLanguage();
const [viewMode, setViewMode] = useState<'cards' | 'map'>('cards');
const [selectedProvince, setSelectedProvince] = useState<ProvinceData | null>(null);
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<any>(null);
useEffect(() => {
if (viewMode === 'map' && mapRef.current) {
if (!mapInstance.current) {
initMap();
} else {
mapInstance.current.invalidateSize();
}
}
}, [viewMode]);
const initMap = () => {
if (!mapRef.current || typeof L === 'undefined') return;
mapInstance.current = L.map(mapRef.current).setView([28.3949, 84.1240], 7);
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
}).addTo(mapInstance.current);
PROVINCES.forEach(prov => {
const iconHtml = `
<div class="relative group">
<div class="w-10 h-10 rounded-full border-4 border-white shadow-lg overflow-hidden transform transition-transform hover:scale-125 duration-300 ${prov.borderColor}">
<img src="${prov.image}" class="w-full h-full object-cover" />
</div>
<div class="absolute -bottom-1 -right-1 bg-white text-[10px] font-bold px-1.5 py-0.5 rounded-full shadow-md border border-gray-100">
${prov.id}
</div>
</div>
`;
const icon = L.divIcon({
html: iconHtml,
className: 'bg-transparent',
iconSize: [40, 40],
iconAnchor: [20, 20]
});
const marker = L.marker([prov.lat, prov.lng], { icon });
const popupContent = `
<div style="font-family: sans-serif; text-align: center; min-width: 200px;">
<div style="height: 100px; border-radius: 8px; overflow: hidden; margin-bottom: 8px;">
<img src="${prov.image}" style="width: 100%; height: 100%; object-fit: cover;" />
</div>
<h3 style="margin: 0; font-weight: 800; font-size: 16px;">${language === 'ne' ? prov.nepaliName : prov.name}</h3>
<p style="margin: 4px 0; font-size: 12px; color: #666;">${language === 'ne' ? prov.capitalNe : prov.capital}</p>
<button id="btn-prov-${prov.id}" style="margin-top: 8px; background: #2563eb; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; width: 100%; font-weight: bold;">
${t("View Details", "View Details")}
</button>
</div>
`;
marker.bindPopup(popupContent);
marker.on('popupopen', () => {
const btn = document.getElementById(`btn-prov-${prov.id}`);
if (btn) btn.onclick = () => setSelectedProvince(prov);
});
marker.addTo(mapInstance.current);
});
};
const localizeNumber = (value: string | number): string => {
if (language === 'en') return value.toString();
const str = value.toString();
const devanagariDigits = ['', '१', '२', '३', '४', '५', '६', '७', '८', '९'];
return str.replace(/[0-9]/g, (match) => devanagariDigits[parseInt(match)]);
};
return (
<div className="flex flex-col pr-1">
{/* View Toggle Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 shrink-0 gap-4">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<MapIcon className="text-blue-600"/> {t("Province Overview", "Province Overview")}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 hidden sm:block">
{t("Explore the 7 distinct provinces of Nepal.", "Explore the 7 distinct provinces of Nepal.")}
</p>
</div>
{/* Scrollable Tabs System - Removed no-scrollbar for PC discoverability */}
<div className="bg-white/10 dark:bg-gray-800/50 backdrop-blur-md p-1 rounded-xl flex border border-white/20 shadow-lg overflow-x-auto snap-x scrollbar-thin scrollbar-thumb-gray-400">
<button
onClick={() => setViewMode('cards')}
className={`px-5 py-2.5 rounded-lg flex items-center gap-2 transition-all snap-start whitespace-nowrap font-bold text-sm ${viewMode === 'cards' ? 'bg-white shadow-sm text-gray-900' : 'text-white hover:bg-white/10'}`}
>
<Grid size={18} /> {t("Grid View", "Grid View")}
</button>
<button
onClick={() => setViewMode('map')}
className={`px-5 py-2.5 rounded-lg flex items-center gap-2 transition-all snap-start whitespace-nowrap font-bold text-sm ${viewMode === 'map' ? 'bg-white shadow-sm text-gray-900' : 'text-white hover:bg-white/10'}`}
>
<MapIcon size={18} /> {t("Interactive Map", "Interactive Map")}
</button>
</div>
</div>
{/* Main Content Area - Flexible height to prevent clamping on PC */}
<div className="flex-1 pb-20">
{viewMode === 'cards' ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{PROVINCES.map(prov => (
<div
key={prov.id}
onClick={() => setSelectedProvince(prov)}
className="group bg-white dark:bg-gray-800/40 backdrop-blur-sm rounded-3xl overflow-hidden shadow-xl border border-white/10 hover:border-blue-500/50 transition-all duration-300 cursor-pointer hover:-translate-y-1"
>
<div className="h-56 overflow-hidden relative">
<img src={prov.image} className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110" alt={prov.name} />
<div className={`absolute inset-0 bg-gradient-to-t ${prov.color} opacity-40 mix-blend-multiply`}></div>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/80"></div>
<div className="absolute top-5 left-6">
<div className="bg-white/20 backdrop-blur-md px-3 py-1 rounded-lg border border-white/30 text-[10px] font-black text-white uppercase tracking-widest">
{t("Province", "Province")} {prov.id}
</div>
</div>
<div className="absolute bottom-5 left-6 text-white pr-10">
<h3 className="text-3xl font-black italic tracking-tighter leading-tight uppercase drop-shadow-xl">
{language === 'ne' ? prov.nepaliName : prov.name}
</h3>
</div>
</div>
<div className="p-6 space-y-6">
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2 bg-gray-100 dark:bg-white/5 px-3 py-1.5 rounded-xl text-xs font-black text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/5 uppercase italic tracking-tighter">
<MapPin size={14} className="text-blue-500" /> {language === 'ne' ? prov.capitalNe : prov.capital}
</div>
<div className="flex items-center gap-2 bg-gray-100 dark:bg-white/5 px-3 py-1.5 rounded-xl text-xs font-black text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/5 uppercase tracking-widest">
{localizeNumber(prov.districts)} Districts
</div>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-3 leading-relaxed font-medium italic">
"{language === 'ne' ? prov.descriptionNe : prov.description}"
</p>
<button className="w-full h-12 bg-gray-50 dark:bg-white/5 hover:bg-blue-600 dark:hover:bg-blue-600 rounded-2xl flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-gray-600 dark:text-gray-300 hover:text-white transition-all group-hover:shadow-lg group-hover:shadow-blue-600/20">
{t("View Detailed Atlas", "View Detailed Atlas")} <ArrowRight size={16} className="transition-transform group-hover:translate-x-2"/>
</button>
</div>
</div>
))}
</div>
) : (
<div className="h-[600px] bg-gray-100 dark:bg-gray-900 rounded-3xl overflow-hidden border border-gray-200 dark:border-gray-700 shadow-inner relative min-h-[500px]">
<div ref={mapRef} className="w-full h-full z-0" />
<div className="absolute top-6 right-6 z-[400] bg-white/90 dark:bg-black/80 backdrop-blur-xl p-5 rounded-[2rem] shadow-2xl border border-gray-200 dark:border-gray-700 max-w-xs">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 font-black uppercase tracking-widest italic border-b pb-2 border-gray-100 dark:border-gray-800">Map Legend</p>
<div className="grid grid-cols-1 gap-2.5">
{PROVINCES.map(p => (
<div key={p.id} className="flex items-center gap-3 group cursor-pointer" onClick={() => {
mapInstance.current?.flyTo([p.lat, p.lng], 9);
setSelectedProvince(p);
}}>
<div className={`w-3 h-3 rounded-full bg-gradient-to-br ${p.color} shadow-sm group-hover:scale-125 transition-transform`}></div>
<span className="text-[10px] font-black text-gray-700 dark:text-gray-300 uppercase tracking-widest group-hover:text-blue-500 transition-colors">{language === 'ne' ? p.nepaliName : p.name}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* Detail Modal */}
{selectedProvince && (
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-xl" onClick={() => setSelectedProvince(null)}></div>
<div className="bg-white dark:bg-gray-900 w-full max-w-6xl max-h-[90vh] rounded-[3rem] shadow-2xl relative overflow-hidden flex flex-col md:flex-row animate-in zoom-in duration-300 border-4 border-gray-900">
<button
onClick={() => setSelectedProvince(null)}
className="absolute top-6 right-6 z-10 p-3 bg-black/40 hover:bg-red-600 text-white rounded-full transition-all active:scale-90 backdrop-blur-md"
>
<X size={28} />
</button>
<div className="md:w-2/5 relative h-72 md:h-auto shrink-0 group">
<img
src={selectedProvince.image}
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
alt={selectedProvince.name}
/>
<div className={`absolute inset-0 bg-gradient-to-t md:bg-gradient-to-r ${selectedProvince.color} mix-blend-multiply opacity-80`}></div>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/90"></div>
<div className="absolute bottom-0 left-0 p-10 text-white w-full">
<span className="inline-block px-4 py-1.5 border border-white/30 bg-white/10 backdrop-blur-md rounded-full text-[10px] font-black mb-4 uppercase tracking-widest shadow-2xl">
{t("Province", "Province")} {localizeNumber(selectedProvince.id)} Official Data
</span>
<h2 className="text-5xl md:text-7xl font-black mb-3 leading-none italic tracking-tighter uppercase drop-shadow-2xl">{language === 'en' ? selectedProvince.name : selectedProvince.nepaliName}</h2>
<div className="flex gap-6 mt-8">
<div className="text-center">
<p className="text-xs text-white/60 uppercase font-black tracking-widest mb-1">{t("Area", "Area")}</p>
<p className="font-black text-xl italic">{localizeNumber(selectedProvince.area)}</p>
</div>
<div className="w-px bg-white/20 h-10 self-center"></div>
<div className="text-center">
<p className="text-xs text-white/60 uppercase font-black tracking-widest mb-1">{t("Population", "Population")}</p>
<p className="font-black text-xl italic">{localizeNumber(selectedProvince.population)}</p>
</div>
</div>
</div>
</div>
<div className="md:w-3/5 p-8 md:p-12 overflow-y-auto bg-white dark:bg-gray-950 custom-scrollbar">
<div className="flex flex-wrap gap-4 mb-10">
<div className="flex-1 min-w-[160px] bg-gray-50 dark:bg-gray-900 p-5 rounded-[2rem] border-2 border-gray-100 dark:border-gray-800 shadow-sm">
<p className="text-[10px] text-gray-400 uppercase font-black tracking-widest mb-2">{t("Administrative Capital", "Administrative Capital")}</p>
<p className="font-black text-gray-900 dark:text-white flex items-center gap-3 text-2xl italic tracking-tighter uppercase">
<div className="p-2 bg-blue-100 dark:bg-blue-900/50 rounded-xl text-blue-600"><Landmark size={20}/></div>
{language === 'en' ? selectedProvince.capital : selectedProvince.capitalNe}
</p>
</div>
<div className="flex-1 min-w-[160px] bg-gray-50 dark:bg-gray-900 p-5 rounded-[2rem] border-2 border-gray-100 dark:border-gray-800 shadow-sm">
<p className="text-[10px] text-gray-400 uppercase font-black tracking-widest mb-2">{t("Districts", "Districts")}</p>
<p className="font-black text-gray-900 dark:text-white flex items-center gap-3 text-2xl italic tracking-tighter">
<div className="p-2 bg-orange-100 dark:bg-orange-900/50 rounded-xl text-orange-600"><Layout size={20}/></div>
{localizeNumber(selectedProvince.districts)} Units
</p>
</div>
</div>
<div className="space-y-12">
<div>
<h3 className="text-xs font-black text-gray-400 dark:text-gray-500 uppercase tracking-[0.4em] mb-4 italic flex items-center gap-2">
<Info size={16} className="text-blue-500"/> Regional Overview
</h3>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed text-lg p-6 bg-blue-50/30 dark:bg-blue-900/10 rounded-[2rem] border-2 border-blue-50 dark:border-blue-900/20 italic font-medium">
"{language === 'en' ? selectedProvince.description : selectedProvince.descriptionNe}"
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-10">
<div>
<h3 className="text-xs font-black text-gray-400 dark:text-gray-500 uppercase tracking-[0.4em] mb-6 italic flex items-center gap-2">
<Mountain size={16} className="text-green-500"/> Landmark Sites
</h3>
<ul className="space-y-3">
{(language === 'en' ? selectedProvince.attractions : selectedProvince.attractionsNe).map((attr, idx) => (
<li key={idx} className="flex items-center gap-4 text-sm font-black uppercase italic tracking-tighter text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-900 p-4 rounded-2xl border border-gray-100 dark:border-gray-800 transition-colors hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
<div className="w-2.5 h-2.5 rounded-full bg-green-500 shrink-0 shadow-[0_0_8px_rgba(34,197,94,0.5)]"></div>
{attr}
</li>
))}
</ul>
</div>
<div className="space-y-8">
<div>
<h3 className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-2"><Droplets size={14} className="text-blue-500"/> Vital Waterways</h3>
<p className="text-lg font-black text-gray-800 dark:text-gray-100 italic tracking-tighter uppercase">{language === 'en' ? selectedProvince.majorRivers : selectedProvince.majorRiversNe}</p>
</div>
<div>
<h3 className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-2"><BookOpen size={14} className="text-emerald-500"/> Literacy Index</h3>
<p className="text-3xl font-black text-gray-800 dark:text-gray-100 italic tracking-tighter">{localizeNumber(selectedProvince.literacyRate)}</p>
</div>
<div>
<h3 className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-2"><User size={14} className="text-purple-500"/> Primary Dialects</h3>
<p className="text-lg font-black text-gray-800 dark:text-gray-100 italic tracking-tighter uppercase">{language === 'en' ? selectedProvince.mainLanguages : selectedProvince.mainLanguagesNe}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default ProvinceMap;

755
components/RudraAI.tsx Normal file
View File

@ -0,0 +1,755 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
X, MessageSquare, Mic, Send,
Loader2, AudioWaveform, StopCircle,
Radio, Cpu, AlertCircle, RefreshCw, ShieldCheck,
GripVertical, MapPin, Navigation, Sparkles, Globe
} from 'lucide-react';
import { Logo } from './ui/Logo';
import { StorageService } from '../services/storageService';
import { connectToGuruLive, encodeAudio, decodeAudio, decodeAudioData, requestMicPermission, translateText } from '../services/geminiService';
import { ChatMessage, TaskStatus } from '../types';
import { Modality, LiveServerMessage, Blob, GoogleGenAI } from '@google/genai';
import { useNavigate } from 'react-router-dom';
import { GET_ROBOTIC_SYSTEM_INSTRUCTION } from '../ai-voice-model/ai';
import { AI_LANGUAGES } from '../ai-voice-model/languages';
import { ALL_RUDRA_TOOLS, executeRudraTool } from '../ai-voice-model/tool-registry';
import { useLanguage } from '../contexts/LanguageContext';
const RudraAI: React.FC = () => {
const navigate = useNavigate();
const { t, language } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const [mode, setMode] = useState<'chat' | 'voice'>('voice');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [isTranslating, setIsTranslating] = useState(false);
// Position & Drag State - Responsive defaults
const [pos, setPos] = useState({ x: window.innerWidth - 80, y: window.innerHeight - 140 });
const [dragging, setDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const dragStartPos = useRef({ x: 0, y: 0 });
const hasMovedRef = useRef(false);
// Voice Session State
const [hasMicPermission, setHasMicPermission] = useState<boolean | null>(null);
const hasMicPermissionRef = useRef<boolean | null>(null);
const [isLiveActive, setIsLiveActive] = useState(false);
const [liveStatus, setLiveStatus] = useState<'Idle' | 'Connecting' | 'Listening' | 'Speaking' | 'Error'>('Idle');
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [userTranscript, setUserTranscript] = useState('');
const [aiTranscript, setAiTranscript] = useState('');
// Navigation Overlay State
const [navOverlay, setNavOverlay] = useState<{destination: string, mode: string} | null>(null);
// Refs
const liveSessionPromiseRef = useRef<Promise<any> | null>(null);
const inputAudioContextRef = useRef<AudioContext | null>(null);
const outputAudioContextRef = useRef<AudioContext | null>(null);
const nextStartTimeRef = useRef<number>(0);
const audioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const micStreamRef = useRef<MediaStream | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Background Wake Word Engine
const wakeWordRecognitionRef = useRef<any>(null);
const isMicLockedRef = useRef(false);
// State Refs for Event Handlers
const isLiveActiveRef = useRef(isLiveActive);
useEffect(() => { isLiveActiveRef.current = isLiveActive; }, [isLiveActive]);
useEffect(() => { hasMicPermissionRef.current = hasMicPermission; }, [hasMicPermission]);
// Load Persistence
useEffect(() => {
const history = StorageService.getGlobalChatHistory();
if (history && history.length > 0) {
setMessages(history);
}
}, []);
// Save Persistence
useEffect(() => {
if (messages.length > 0) {
StorageService.saveGlobalChatHistory(messages);
}
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, isTyping, isTranslating]);
// Auto-Translate Logic on Language Toggle
useEffect(() => {
const handleLanguageSwitch = async () => {
if (messages.length === 0 || isTyping || isLiveActive) return;
const lastMsg = messages[messages.length - 1];
// Only translate if the last message was from the bot and isn't a system action
if (lastMsg.role === 'model' && !lastMsg.text.startsWith('[SYSTEM')) {
setIsTranslating(true);
try {
const translated = await translateText(lastMsg.text, language);
setMessages(prev => {
const newMsgs = [...prev];
newMsgs[newMsgs.length - 1] = { ...lastMsg, text: translated };
return newMsgs;
});
} catch (e) {
console.error("Translation failed", e);
} finally {
setIsTranslating(false);
}
}
};
handleLanguageSwitch();
}, [language]);
useEffect(() => {
// Initial permission check against App Settings
const checkPermissions = async () => {
const settings = await StorageService.getSettings();
if (settings.permissions.microphone) {
if (navigator.permissions && (navigator.permissions as any).query) {
(navigator.permissions as any).query({ name: 'microphone' }).then((result: any) => {
if (result.state === 'granted') {
setHasMicPermission(true);
initWakeWord();
} else if (result.state === 'denied') {
setHasMicPermission(false);
}
});
} else {
setHasMicPermission(true);
initWakeWord();
}
} else {
setHasMicPermission(false);
}
};
checkPermissions();
const handleResize = () => {
setPos(prev => ({
x: Math.min(prev.x, window.innerWidth - 70),
y: Math.min(prev.y, window.innerHeight - 120)
}));
};
window.addEventListener('resize', handleResize);
// Listen for navigation commands
const handleNavStart = (e: any) => {
setNavOverlay(e.detail);
setIsOpen(true);
};
window.addEventListener('rudraksha-nav-start', handleNavStart);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('rudraksha-nav-start', handleNavStart);
};
}, []);
const onMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
setDragging(true);
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
dragOffset.current = { x: clientX - pos.x, y: clientY - pos.y };
};
useEffect(() => {
const onMouseMove = (e: MouseEvent | TouchEvent) => {
if (!dragging) return;
hasMovedRef.current = true;
const clientX = 'touches' in e ? e.touches[0].clientX : (e as MouseEvent).clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : (e as MouseEvent).clientY;
const newX = Math.max(10, Math.min(window.innerWidth - 60, clientX - dragOffset.current.x));
const newY = Math.max(10, Math.min(window.innerHeight - 110, clientY - dragOffset.current.y));
setPos({ x: newX, y: newY });
};
const onMouseUp = () => setDragging(false);
if (dragging) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('touchmove', onMouseMove);
window.addEventListener('touchend', onMouseUp);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('touchmove', onMouseMove);
window.removeEventListener('touchend', onMouseUp);
};
}, [dragging]);
// --- WAKE WORD LOGIC ---
const startLiveConversationRef = useRef<(context?: string) => Promise<void>>(async () => {});
const stopWakeWord = useCallback(() => {
if (wakeWordRecognitionRef.current) {
try {
wakeWordRecognitionRef.current.onend = null;
wakeWordRecognitionRef.current.stop();
wakeWordRecognitionRef.current = null;
} catch (e) {}
}
}, []);
const initWakeWord = useCallback(() => {
if (isMicLockedRef.current || isLiveActiveRef.current) return;
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) return;
if (wakeWordRecognitionRef.current) return;
try {
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.onresult = (event: any) => {
if (isLiveActiveRef.current || isMicLockedRef.current) return;
for (let i = event.resultIndex; i < event.results.length; ++i) {
const text = event.results[i][0].transcript.toLowerCase().trim();
const isSecretTrigger = text.includes("oi baiman baccha") || text.includes("oi baiman bacha") || text.includes("ओई बेईमान बच्चा");
const isStandardTrigger = text.includes("hey rudra") || text.includes("rudra") || text.includes("ai babu");
if (isSecretTrigger || isStandardTrigger) {
stopWakeWord();
setIsOpen(true);
setMode('voice');
const audio = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3');
audio.volume = 0.5;
audio.play().catch(() => {});
const triggerContext = isSecretTrigger
? " [SYSTEM EVENT: User activated via secret phrase 'Oi Baiman Baccha'. Respond ONLY with 'Jwajalapa Ama!' immediately.]"
: undefined;
setTimeout(() => {
if (startLiveConversationRef.current) {
startLiveConversationRef.current(triggerContext);
}
}, 100);
return;
}
}
};
recognition.onend = () => {
wakeWordRecognitionRef.current = null;
if (!isLiveActiveRef.current && !isMicLockedRef.current) {
try { setTimeout(() => initWakeWord(), 1000); } catch (e) {}
}
};
recognition.start();
wakeWordRecognitionRef.current = recognition;
} catch (e) { }
}, []);
useEffect(() => {
const handleMicLock = (e: any) => {
const { state } = e.detail;
isMicLockedRef.current = state;
if (state) {
stopWakeWord();
} else {
setTimeout(() => initWakeWord(), 1500);
}
};
window.addEventListener('rudraksha-mic-lock', handleMicLock);
return () => {
stopWakeWord();
window.removeEventListener('rudraksha-mic-lock', handleMicLock);
};
}, [initWakeWord, stopWakeWord]);
const handleGrantPermission = async () => {
const settings = await StorageService.getSettings();
await StorageService.saveSettings({
...settings,
permissions: { ...settings.permissions, microphone: true }
});
const success = await requestMicPermission();
setHasMicPermission(success);
if (success) {
setErrorMsg(null);
initWakeWord();
} else {
setErrorMsg("Microphone access is required for voice features.");
}
return success;
};
// --- GEMINI LIVE LOGIC ---
const startLiveConversation = async (initialContext?: string) => {
if (isLiveActive) return;
const settings = await StorageService.getSettings();
if (!settings.permissions.microphone) {
setHasMicPermission(false);
setErrorMsg("Privacy Mode Active. Microphone access disabled in Settings.");
return;
}
if (!hasMicPermissionRef.current) {
const granted = await handleGrantPermission();
if (!granted) return;
}
stopWakeWord();
window.dispatchEvent(new CustomEvent('rudraksha-mic-lock', { detail: { state: true } }));
setLiveStatus('Connecting');
setIsLiveActive(true);
setUserTranscript('');
setAiTranscript('');
setErrorMsg(null);
await new Promise(r => setTimeout(r, 800));
try {
let stream: MediaStream | null = null;
for (let i = 0; i < 3; i++) {
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
break;
} catch (err) {
if (i === 2) throw new Error("Microphone busy.");
await new Promise(r => setTimeout(r, 1000));
}
}
micStreamRef.current = stream;
inputAudioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
outputAudioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
await inputAudioContextRef.current.resume();
await outputAudioContextRef.current.resume();
// Fetch Mood Log
const today = new Date().toISOString().split('T')[0];
const healthLog = await StorageService.getHealthLog(today);
const moodContext = healthLog ? `[USER CONTEXT: Current recorded mood is ${healthLog.mood}. Adjust tone accordingly.]` : "";
const systemInstructionWithContext = GET_ROBOTIC_SYSTEM_INSTRUCTION(language) + moodContext + (initialContext || "");
const sessionPromise = connectToGuruLive({
model: 'gemini-2.5-flash-native-audio-preview-12-2025',
config: {
responseModalities: [Modality.AUDIO],
systemInstruction: systemInstructionWithContext,
tools: ALL_RUDRA_TOOLS,
speechConfig: {
voiceConfig: { prebuiltVoiceConfig: { voiceName: AI_LANGUAGES[language].voiceName } }
},
inputAudioTranscription: {},
outputAudioTranscription: {}
},
callbacks: {
onopen: () => {
if (!inputAudioContextRef.current || !micStreamRef.current) return;
const source = inputAudioContextRef.current.createMediaStreamSource(micStreamRef.current);
const scriptProcessor = inputAudioContextRef.current.createScriptProcessor(4096, 1, 1);
scriptProcessor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
const pcmBlob = createPcmBlob(inputData);
sessionPromise.then(session => {
if (session) session.sendRealtimeInput({ media: pcmBlob });
}).catch(() => {});
};
source.connect(scriptProcessor);
scriptProcessor.connect(inputAudioContextRef.current.destination);
setLiveStatus('Listening');
},
onmessage: async (message: LiveServerMessage) => {
if (message.toolCall) {
for (const fc of message.toolCall.functionCalls) {
try {
const result = await executeRudraTool(fc.name, fc.args, navigate);
// Handle special UI signals
if (result === "TERMINATE_SIGNAL") {
stopLiveConversation();
return;
}
if (result === "LOGOUT_SIGNAL") {
stopLiveConversation();
await StorageService.logout();
navigate('/auth');
return;
}
if (result === "HANDOFF_TO_CHEF") {
// Automatically terminate voice session and close UI overlay
stopLiveConversation();
setIsOpen(false);
return;
}
sessionPromise.then(session => {
if (session) {
session.sendToolResponse({
functionResponses: {
id: fc.id,
name: fc.name,
response: { result: result }
}
});
}
});
// Add system response to chat history
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'model',
text: `[SYSTEM ACTION] ${result}`,
timestamp: Date.now()
}]);
} catch (toolError) {
console.error("Tool execution failed:", toolError);
}
}
}
const audioData = message.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
if (audioData && outputAudioContextRef.current) {
setLiveStatus('Speaking');
nextStartTimeRef.current = Math.max(nextStartTimeRef.current, outputAudioContextRef.current.currentTime);
const buffer = await decodeAudioData(decodeAudio(audioData), outputAudioContextRef.current, 24000, 1);
const source = outputAudioContextRef.current.createBufferSource();
source.buffer = buffer;
source.connect(outputAudioContextRef.current.destination);
source.onended = () => {
audioSourcesRef.current.delete(source);
if (audioSourcesRef.current.size === 0) setLiveStatus('Listening');
};
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += buffer.duration;
audioSourcesRef.current.add(source);
}
// Transcripts are captured but NOT displayed in UI as per request
if (message.serverContent?.inputTranscription) {
const text = message.serverContent.inputTranscription.text || '';
setUserTranscript(text);
}
if (message.serverContent?.outputTranscription) {
setAiTranscript(prev => `${prev}${message.serverContent?.outputTranscription?.text}`);
}
if (message.serverContent?.interrupted) {
audioSourcesRef.current.forEach(s => { try { s.stop(); } catch(e) {} });
audioSourcesRef.current.clear();
nextStartTimeRef.current = 0;
setLiveStatus('Listening');
}
},
onerror: (e) => {
console.error("Neural Voice Error:", e);
setErrorMsg("Hardware link interrupted. Network or API error.");
stopLiveConversation();
},
onclose: () => stopLiveConversation(),
}
});
liveSessionPromiseRef.current = sessionPromise;
} catch (err: any) {
console.error("Neural Voice Link Failure:", err);
setLiveStatus('Error');
setErrorMsg(err.message || "Network Error.");
setTimeout(() => stopLiveConversation(), 3000);
}
};
useEffect(() => {
startLiveConversationRef.current = startLiveConversation;
});
const stopLiveConversation = () => {
setIsLiveActive(false);
setLiveStatus('Idle');
liveSessionPromiseRef.current?.then(s => { try { s.close(); } catch(e) {} });
if (micStreamRef.current) {
micStreamRef.current.getTracks().forEach(t => t.stop());
micStreamRef.current = null;
}
audioSourcesRef.current.forEach(s => { try { s.stop(); } catch(e) {} });
audioSourcesRef.current.clear();
if (inputAudioContextRef.current && inputAudioContextRef.current.state !== 'closed') {
inputAudioContextRef.current.close().catch(() => {});
}
if (outputAudioContextRef.current && outputAudioContextRef.current.state !== 'closed') {
outputAudioContextRef.current.close().catch(() => {});
}
inputAudioContextRef.current = null;
outputAudioContextRef.current = null;
nextStartTimeRef.current = 0;
window.dispatchEvent(new CustomEvent('rudraksha-mic-lock', { detail: { state: false } }));
setTimeout(() => initWakeWord(), 1500);
};
const createPcmBlob = (data: Float32Array): Blob => {
const l = data.length;
const int16 = new Int16Array(l);
for (let i = 0; i < l; i++) int16[i] = data[i] * 32768;
return {
data: encodeAudio(new Uint8Array(int16.buffer)),
mimeType: 'audio/pcm;rate=16000',
};
};
// --- UI INTERACTION ---
const onTriggerStart = (e: React.MouseEvent | React.TouchEvent) => {
if (e.type === 'mousedown' && (e as React.MouseEvent).button !== 0) return;
setDragging(true);
hasMovedRef.current = false;
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY;
dragStartPos.current = { x: clientX, y: clientY };
dragOffset.current = { x: clientX - pos.x, y: clientY - pos.y };
};
const handleOpen = () => {
if (!hasMovedRef.current) {
setIsOpen(!isOpen);
}
};
const handleChatSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isTyping) return;
const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', text: input, timestamp: Date.now() };
setMessages(prev => [...prev, userMsg]);
const currentInput = input;
setInput('');
setIsTyping(true);
try {
const aiClient = new GoogleGenAI({ apiKey: process.env.API_KEY });
const res = await aiClient.models.generateContent({
model: 'gemini-3-flash-preview',
contents: currentInput,
config: {
systemInstruction: GET_ROBOTIC_SYSTEM_INSTRUCTION(language),
tools: ALL_RUDRA_TOOLS
}
});
if (res.functionCalls) {
for (const fc of res.functionCalls) {
const result = await executeRudraTool(fc.name, fc.args, navigate);
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: `Action Taken: ${result}`, timestamp: Date.now() }]);
}
} else {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: res.text || "Command accepted.", timestamp: Date.now() }]);
}
} catch {
setMessages(prev => [...prev, { id: 'err', role: 'model', text: "Signal Interrupted.", timestamp: Date.now() }]);
} finally { setIsTyping(false); }
};
const getMapEmbedUrl = (dest: string, mode: string) => {
const m = mode.toLowerCase();
return `https://www.google.com/maps?saddr=Current+Location&daddr=${encodeURIComponent(dest)}&dirflg=${m.charAt(0)}&output=embed`;
};
return (
<div className="fixed z-[9999] flex flex-col items-end touch-none select-none" style={{ left: pos.x, top: pos.y }}>
{isOpen && (
<div className="absolute bottom-16 right-0 mb-2 w-[90vw] md:w-[340px] h-[520px] bg-gray-950/95 backdrop-blur-3xl rounded-[2rem] md:rounded-[2.5rem] shadow-[0_40px_100px_rgba(0,0,0,0.8)] border-2 border-red-900/30 overflow-hidden flex flex-col animate-in slide-in-from-bottom-4 duration-300 mr-2 md:mr-0" onMouseDown={e => e.stopPropagation()}>
{/* HEADER */}
<header className="p-5 bg-gradient-to-b from-red-950/50 to-transparent flex justify-between items-center shrink-0 border-b border-white/5">
<div className="flex items-center gap-3">
<div className="bg-gradient-to-br from-red-600 to-red-800 p-2 rounded-xl shadow-lg shadow-red-900/50">
<Cpu size={18} className="text-white" />
</div>
<div>
<h3 className="font-black uppercase tracking-[0.2em] text-[12px] leading-none italic text-white flex items-center gap-2">
Rudra <span className="text-red-500">Core</span>
</h3>
<div className="flex items-center gap-1.5 mt-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${isLiveActive ? (liveStatus === 'Error' ? 'bg-red-500' : 'bg-green-500 animate-pulse') : 'bg-gray-500'}`}></div>
<span className="text-[9px] font-bold uppercase tracking-widest text-gray-400">{isLiveActive ? liveStatus : 'System Ready'}</span>
</div>
</div>
</div>
<div className="flex gap-1 bg-black/20 p-1 rounded-xl">
<button onClick={() => { setMode(mode === 'chat' ? 'voice' : 'chat'); setNavOverlay(null); }} className="p-2 hover:bg-white/10 rounded-lg transition-colors text-gray-400 hover:text-white">
{mode === 'chat' ? <Mic size={16}/> : <MessageSquare size={16}/>}
</button>
<button onClick={() => { setIsOpen(false); setNavOverlay(null); }} className="p-2 hover:bg-red-900/50 rounded-lg transition-colors text-gray-400 hover:text-red-500"><X size={16}/></button>
</div>
</header>
<div className="flex-1 overflow-hidden relative flex flex-col">
{navOverlay ? (
<div className="flex flex-col h-full bg-gray-900 relative animate-in fade-in">
<div className="p-3 bg-indigo-900/50 flex justify-between items-center border-b border-white/10">
<div className="flex items-center gap-2 text-white">
<Navigation size={16} className="text-indigo-400"/>
<span className="text-xs font-bold uppercase tracking-wide truncate max-w-[180px]">{navOverlay.destination}</span>
</div>
<span className="text-[10px] font-black bg-indigo-600 px-2 py-0.5 rounded uppercase">{navOverlay.mode}</span>
</div>
<iframe
src={getMapEmbedUrl(navOverlay.destination, navOverlay.mode)}
className="flex-1 w-full border-0 bg-gray-800"
title="Navigation"
loading="lazy"
/>
<button onClick={() => setNavOverlay(null)} className="absolute bottom-4 right-4 bg-red-600 text-white p-2 rounded-full shadow-lg z-10 hover:scale-110 transition-transform"><X size={20}/></button>
</div>
) : mode === 'chat' ? (
<div className="flex flex-col h-full bg-gradient-to-b from-black/20 to-black/60">
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar scroll-smooth">
{messages.map(m => (
<div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'} animate-in slide-in-from-bottom-2 duration-300`}>
<div className={`max-w-[85%] p-3.5 rounded-2xl text-[13px] border relative ${
m.role === 'user'
? 'bg-gradient-to-br from-red-600 to-red-800 text-white border-red-500 rounded-tr-none shadow-lg'
: 'bg-gray-800/80 backdrop-blur-md border-gray-700 text-gray-200 rounded-tl-none shadow-sm'
}`}>
<p className="whitespace-pre-wrap font-medium leading-relaxed font-sans">
{m.text}
</p>
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start animate-in fade-in">
<div className="flex gap-1.5 p-3 bg-gray-800/50 rounded-2xl rounded-tl-none border border-gray-700/50 items-center">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full animate-bounce"></div>
<div className="w-1.5 h-1.5 bg-red-500 rounded-full animate-bounce delay-100"></div>
<div className="w-1.5 h-1.5 bg-red-500 rounded-full animate-bounce delay-200"></div>
</div>
</div>
)}
{isTranslating && (
<div className="flex justify-center animate-in fade-in">
<div className="flex items-center gap-2 px-3 py-1 bg-blue-900/30 rounded-full border border-blue-500/30 text-[10px] text-blue-300 uppercase font-black tracking-widest">
<Globe size={12} className="animate-spin-slow"/> Translating...
</div>
</div>
)}
</div>
<form onSubmit={handleChatSubmit} className="p-3 bg-black/40 border-t border-white/5 flex gap-2 items-end backdrop-blur-md">
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder={t("Enter command...", "Enter command...")}
className="flex-1 px-4 py-3 text-xs bg-gray-900/80 border border-gray-700 rounded-2xl outline-none focus:border-red-600 text-white placeholder-gray-500 transition-all font-medium"
/>
<button type="submit" disabled={!input.trim()} className="w-10 h-10 rounded-2xl bg-red-600 text-white flex items-center justify-center hover:bg-red-500 shadow-lg shadow-red-600/20 transition-all active:scale-95 disabled:opacity-50 disabled:scale-100 mb-0.5">
<Send size={16}/>
</button>
</form>
</div>
) : (
<div className="flex flex-col h-full bg-gradient-to-b from-transparent to-black/40">
<div className="flex-1 flex flex-col items-center justify-center p-8 space-y-12">
<div className="relative">
{/* Ambient Glow */}
<div className={`absolute inset-0 rounded-full blur-[80px] transition-all duration-1000 ${isLiveActive ? (liveStatus === 'Error' ? 'bg-red-500/40' : 'bg-red-600/30 scale-150 animate-pulse') : 'bg-red-900/10'}`}></div>
{hasMicPermission === false ? (
<div className="relative z-10 flex flex-col items-center gap-6 animate-in zoom-in">
<div className="w-32 h-32 bg-gray-900 rounded-full border-2 border-gray-800 flex items-center justify-center text-gray-600 shadow-2xl">
<ShieldCheck size={40} className="opacity-50" />
</div>
<div className="text-center space-y-3">
<p className="text-xs font-black text-white uppercase tracking-widest leading-tight">Access Locked</p>
<button onClick={handleGrantPermission} className="bg-red-600 hover:bg-red-500 text-white text-[10px] font-black uppercase px-6 py-2.5 rounded-full shadow-lg transition-all border border-red-400/20">Grant Access</button>
</div>
</div>
) : (
<button
onClick={isLiveActive ? stopLiveConversation : () => startLiveConversation()}
className={`relative w-40 h-40 rounded-full flex flex-col items-center justify-center transition-all duration-500 z-10 border-[6px] ${isLiveActive ? (liveStatus === 'Error' ? 'bg-red-950 border-red-500' : 'bg-gradient-to-b from-red-600 to-red-800 border-red-400 shadow-[0_0_80px_rgba(220,38,38,0.4)]') : 'bg-gray-900 border-gray-800 shadow-2xl hover:border-gray-700 hover:scale-105'}`}
>
{liveStatus === 'Connecting' ? <Loader2 size={48} className="text-white animate-spin" /> : (liveStatus === 'Error' ? <AlertCircle size={48} className="text-red-400" /> : (isLiveActive ? <AudioWaveform size={48} className="text-white animate-pulse" /> : <Mic size={40} className="text-gray-500 group-hover:text-white transition-colors" />))}
<span className={`text-[9px] font-black tracking-[0.2em] uppercase mt-3 ${isLiveActive ? 'text-white/80' : 'text-gray-600'}`}>{isLiveActive ? liveStatus : 'Tap to Link'}</span>
</button>
)}
</div>
{isLiveActive && liveStatus !== 'Error' && (
<button onClick={stopLiveConversation} className="bg-black/40 text-red-400 border border-red-900/50 rounded-full px-8 py-3 uppercase text-[10px] font-black tracking-[0.2em] hover:bg-red-900/20 hover:text-red-300 transition-all animate-in fade-in slide-in-from-bottom-2 flex items-center gap-2 backdrop-blur-md">
<StopCircle size={14} /> Terminate
</button>
)}
{liveStatus === 'Error' && errorMsg && (
<div className="bg-red-950/50 border border-red-900/50 p-4 rounded-2xl text-center animate-in zoom-in duration-300 backdrop-blur-md max-w-[200px]">
<p className="text-red-300 text-xs font-bold leading-tight">{errorMsg}</p>
<button onClick={() => startLiveConversation()} className="mt-3 text-[9px] font-black text-white bg-red-600 px-4 py-1.5 rounded-full uppercase flex items-center gap-1 mx-auto hover:bg-red-500 transition-colors">
<RefreshCw size={10} /> Retry
</button>
</div>
)}
</div>
<div className="p-6 bg-black/40 border-t border-white/5 h-28 overflow-hidden flex flex-col justify-center shrink-0 backdrop-blur-lg">
<div className="flex items-center gap-2 mb-2">
<Radio size={12} className={`text-red-500 ${isLiveActive && liveStatus !== 'Error' ? 'animate-pulse' : ''}`} />
<span className="text-[9px] font-black text-red-500/80 uppercase tracking-[0.3em]">Neural Interface</span>
</div>
{/* Transcripts Hidden as Requested */}
<div className="space-y-1.5 opacity-0 pointer-events-none h-0">
<p className="text-[13px] font-medium italic text-white/90 whitespace-nowrap overflow-hidden text-ellipsis h-5">
{userTranscript ? `"${userTranscript}"` : (errorMsg ? `ERROR: ${errorMsg}` : "Listening for command...")}
</p>
{aiTranscript && <p className="text-[11px] font-medium text-gray-400 truncate flex items-center gap-1"><Sparkles size={10} className="text-indigo-400"/> {aiTranscript}</p>}
</div>
{/* Fallback Pulse Visualization instead of text */}
<div className="flex justify-center items-center h-10 gap-1">
{[1,2,3,4,5].map(i => (
<div key={i} className={`w-1 bg-red-600 rounded-full transition-all duration-300 ${isLiveActive && (liveStatus === 'Listening' || liveStatus === 'Speaking') ? 'animate-wave h-6' : 'h-1 opacity-20'}`} style={{animationDelay: `${i*0.1}s`}}></div>
))}
</div>
</div>
</div>
)}
</div>
</div>
)}
{/* Floating Trigger Button */}
<div
className={`group relative cursor-grab active:cursor-grabbing transition-transform duration-300 ${dragging ? 'scale-110' : 'hover:scale-105'}`}
onMouseDown={onTriggerStart}
onTouchStart={onTriggerStart}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-all duration-300 bg-black/80 backdrop-blur-md text-white px-3 py-1 rounded-full text-[8px] font-black uppercase tracking-widest flex items-center gap-1 whitespace-nowrap shadow-xl border border-white/10">
<GripVertical size={10} /> Rudra
</div>
<button
onClick={handleOpen}
className={`w-14 h-14 rounded-2xl shadow-[0_10px_40px_rgba(0,0,0,0.6)] flex items-center justify-center transition-all duration-500 border-2 ${isOpen ? 'bg-red-600 border-red-400 rotate-90 scale-90' : 'bg-gray-900/90 border-gray-700 hover:border-red-500/50 hover:bg-gray-800 backdrop-blur-sm'}`}
>
{isOpen ? <X size={24} className="text-white"/> : <Logo className="w-9 h-9 drop-shadow-lg" />}
</button>
{!isOpen && !dragging && <div className="absolute inset-0 rounded-2xl bg-red-600 animate-ping opacity-10 pointer-events-none duration-1000"></div>}
</div>
</div>
);
};
export default RudraAI;

View File

@ -0,0 +1,122 @@
import React, { useState, useEffect } from 'react';
interface TextRevealProps {
text: string;
className?: string;
delay?: number;
type?: 'typewriter' | 'stagger' | 'glitch';
}
export const TextReveal: React.FC<TextRevealProps> = ({
text,
className = "",
delay = 0,
type = 'stagger'
}) => {
if (type === 'typewriter') {
return (
<div
className={`overflow-hidden whitespace-nowrap animate-typewriter ${className}`}
style={{
animationDelay: `${delay}ms`,
width: 'fit-content'
}}
>
{text}
<style>{`
@keyframes typewriter {
from { width: 0; }
to { width: 100%; }
}
.animate-typewriter {
animation: typewriter 2s steps(40, end) forwards;
}
`}</style>
</div>
);
}
// Stagger Effect (Default)
const letters = text.split("");
return (
<div className={`flex flex-wrap justify-center ${className}`}>
{letters.map((letter, index) => (
<span
key={index}
className="inline-block opacity-0 animate-in fade-in slide-in-from-bottom-4 fill-mode-forwards"
style={{
animationDelay: `${delay + (index * 50)}ms`,
animationDuration: '0.6s',
animationTimingFunction: 'cubic-bezier(0.2, 0.65, 0.3, 0.9)',
minWidth: letter === " " ? "0.3em" : "0"
}}
>
{letter}
</span>
))}
</div>
);
};
interface TypewriterLoopProps {
words: string[];
className?: string;
typingSpeed?: number;
deletingSpeed?: number;
pauseDuration?: number;
}
export const TypewriterLoop: React.FC<TypewriterLoopProps> = ({
words,
className = "",
typingSpeed = 100,
deletingSpeed = 50,
pauseDuration = 1500
}) => {
const [currentWordIndex, setCurrentWordIndex] = useState(0);
const [currentText, setCurrentText] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [loopNum, setLoopNum] = useState(0);
const [speed, setSpeed] = useState(typingSpeed);
useEffect(() => {
const handleType = () => {
const i = loopNum % words.length;
const fullText = words[i];
setCurrentText(isDeleting
? fullText.substring(0, currentText.length - 1)
: fullText.substring(0, currentText.length + 1)
);
// Determine speed
if (isDeleting) {
setSpeed(deletingSpeed);
} else {
setSpeed(typingSpeed);
}
if (!isDeleting && currentText === fullText) {
// Finished typing word, pause before deleting
setSpeed(pauseDuration);
setIsDeleting(true);
} else if (isDeleting && currentText === '') {
// Finished deleting, move to next word
setIsDeleting(false);
setLoopNum(loopNum + 1);
setSpeed(500); // Short pause before typing next
}
};
const timer = setTimeout(handleType, speed);
return () => clearTimeout(timer);
}, [currentText, isDeleting, loopNum, words, typingSpeed, deletingSpeed, pauseDuration]);
return (
<span className={`${className} inline-block min-h-[1.2em]`}>
{currentText}
</span>
);
};

168
components/ui/Button.tsx Normal file
View File

@ -0,0 +1,168 @@
import React, { useRef, useEffect } from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'success';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
className = '',
children,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const particles = useRef<any[]>([]);
// Cleanup
useEffect(() => {
return () => cancelAnimationFrame(animationRef.current);
}, []);
const createParticles = (x: number, y: number, theme: string) => {
const count = theme === 'theme_midnight' ? 20 : theme === 'theme_royal' ? 12 : 8;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * (theme === 'theme_midnight' ? 4 : 2) + 1;
particles.current.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1.0,
decay: Math.random() * 0.03 + 0.02,
size: Math.random() * 3 + 1,
theme
});
}
if (!animationRef.current) {
animate();
}
};
const animate = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.current.forEach((p, index) => {
p.x += p.vx;
p.y += p.vy;
p.life -= p.decay;
// Theme Physics
if (p.theme === 'theme_royal') {
p.vy += 0.2; // Gravity for gold dust
p.size *= 0.95;
} else if (p.theme === 'theme_sky') {
p.vy -= 0.1; // Float up for clouds
p.size += 0.2; // Expand
}
ctx.save();
ctx.globalAlpha = Math.max(0, p.life);
if (p.theme === 'theme_midnight') {
// Star Burst
ctx.fillStyle = '#fff';
ctx.shadowBlur = 4;
ctx.shadowColor = '#6366f1';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
} else if (p.theme === 'theme_royal') {
// Gold Diamonds
ctx.fillStyle = '#facc15';
ctx.beginPath();
ctx.rect(p.x, p.y, p.size * 2, p.size * 2);
ctx.fill();
} else if (p.theme === 'theme_sky') {
// Cloud Puffs
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 3, 0, Math.PI * 2);
ctx.fill();
} else {
// Default Ripple/Nature
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x, p.y, (1 - p.life) * 30, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
if (p.life <= 0) {
particles.current.splice(index, 1);
}
});
if (particles.current.length > 0) {
animationRef.current = requestAnimationFrame(animate);
} else {
animationRef.current = 0;
}
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (props.disabled) return;
// Get active theme from body attribute set by Layout
const theme = document.body.getAttribute('data-theme') || 'default';
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Setup Canvas Resolution
if (canvasRef.current) {
canvasRef.current.width = rect.width;
canvasRef.current.height = rect.height;
}
createParticles(x, y, theme);
if (props.onClick) props.onClick(e);
};
const baseStyles = "relative inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:shadow-md overflow-hidden";
const variants = {
primary: "bg-red-700 text-white hover:bg-red-800 focus:ring-red-600 shadow-sm hover:shadow-red-500/30 border border-transparent",
secondary: "bg-white text-gray-700 border border-gray-300 hover:bg-orange-50 focus:ring-orange-500 hover:border-orange-300",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 shadow-sm hover:shadow-red-500/30 border border-transparent",
success: "bg-green-600 text-white hover:bg-green-700 focus:ring-green-500 shadow-sm hover:shadow-green-500/30 border border-transparent",
ghost: "bg-transparent text-gray-600 hover:bg-orange-100 hover:text-red-700 focus:ring-gray-500 hover:shadow-none border border-transparent",
};
const sizes = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 py-2",
lg: "h-12 px-6 text-lg",
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
onClick={handleClick}
{...props}
>
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none z-0"
/>
<span className="relative z-10 flex items-center gap-2">
{children}
</span>
</button>
);
};

239
components/ui/GameShell.tsx Normal file
View File

@ -0,0 +1,239 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Trophy, ArrowLeft, RefreshCw, Zap, Target,
Activity, ShieldCheck, Share2
} from 'lucide-react';
import { Button } from './Button';
import { StorageService } from '../../services/storageService';
interface GameShellProps {
gameId: string;
title: string;
onExit: () => void;
children: (props: { onGameOver: (score: number, stats?: any) => void }) => React.ReactNode;
}
type GameState = 'intro' | 'countdown' | 'playing' | 'results';
export const GameShell: React.FC<GameShellProps> = ({ gameId, title, onExit, children }) => {
const [gameState, setGameState] = useState<GameState>('intro');
const [countdown, setCountdown] = useState(3);
const [finalScore, setFinalScore] = useState(0);
const [gameStats, setGameStats] = useState<any>(null);
// Timer Refs
const startTimeRef = useRef<number>(0);
useEffect(() => {
// Capture start time when component mounts (or effectively when game starts playing)
// We'll set the actual start time when state changes to 'playing'
return () => {
// Cleanup: save duration if exiting abruptly while playing
// Note: React's strict mode might double trigger, but storage append is safe-ish
};
}, []);
// --- START SEQUENCER ---
const startCountdown = () => {
setGameState('countdown');
let timer = 3;
const interval = setInterval(() => {
timer--;
if (timer === 0) {
clearInterval(interval);
setGameState('playing');
startTimeRef.current = Date.now();
} else {
setCountdown(timer);
}
}, 800);
};
const saveSessionDuration = () => {
if (startTimeRef.current > 0) {
const duration = Math.floor((Date.now() - startTimeRef.current) / 1000);
if (duration > 0) {
StorageService.saveGameSession(gameId, duration);
}
startTimeRef.current = 0; // Reset
}
};
const handleExit = () => {
if (gameState === 'playing') {
saveSessionDuration();
}
onExit();
};
// --- SCORE HANDLER ---
const handleGameOver = async (score: number, stats?: any) => {
saveSessionDuration(); // Save time immediately on game over
setFinalScore(score);
setGameStats(stats);
setGameState('results');
try {
const profile = await StorageService.getProfile();
if (profile) {
// Log transaction properly using addPoints
await StorageService.addPoints(score, score * 2, 'game_reward', `Arcade: ${title}`);
// Track high score for specific game
const highScores = profile.highScores || {};
if (!highScores[gameId] || score > highScores[gameId]) {
// Manually update high score field as addPoints only handles karma/xp
const newHighScores = { ...highScores, [gameId]: score };
await StorageService.updateProfile({ highScores: newHighScores });
}
}
} catch (err) {
console.error("Failed to sync neural data:", err);
}
};
const restartGame = () => {
setCountdown(3);
setFinalScore(0);
setGameStats(null);
startCountdown();
};
return (
<div className="fixed inset-0 bg-slate-950 flex flex-col overflow-hidden z-[100] font-sans selection:bg-indigo-500/30">
<AnimatePresence mode="wait">
{/* --- STATE: INTRO SCREEN --- */}
{gameState === 'intro' && (
<motion.div
key="intro"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="flex-1 flex flex-col items-center justify-center p-6 text-center"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-indigo-500/10 via-transparent to-transparent opacity-50" />
<motion.div
initial={{ scale: 0.8, y: 20 }} animate={{ scale: 1, y: 0 }}
className="relative z-10 space-y-8"
>
<div className="space-y-2">
<h2 className="text-slate-500 font-black uppercase tracking-[0.4em] text-xs">Initializing Protocol</h2>
<h1 className="text-6xl md:text-8xl font-black italic tracking-tighter uppercase text-white drop-shadow-2xl">
{title}
</h1>
</div>
<div className="flex flex-wrap justify-center gap-4">
<Button
onClick={startCountdown}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-10 py-8 rounded-2xl text-xl font-black uppercase tracking-widest shadow-[0_0_30px_rgba(79,70,229,0.4)] transition-all hover:scale-105 active:scale-95"
>
Initiate Link
</Button>
<Button
variant="ghost"
onClick={handleExit}
className="text-slate-400 hover:text-white px-10 py-8 text-sm font-bold uppercase tracking-widest"
>
Return to Hub
</Button>
</div>
</motion.div>
</motion.div>
)}
{/* --- STATE: COUNTDOWN --- */}
{gameState === 'countdown' && (
<motion.div
key="countdown"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="flex-1 flex items-center justify-center bg-slate-950 z-[200]"
>
<motion.div
key={countdown}
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1.5, opacity: 1 }}
exit={{ scale: 3, opacity: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="text-9xl font-black italic text-indigo-500"
>
{countdown}
</motion.div>
</motion.div>
)}
{/* --- STATE: PLAYING --- */}
{gameState === 'playing' && (
<motion.div
key="playing"
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
className="flex-1 relative"
>
{/* Inject the Game Component here */}
{children({ onGameOver: handleGameOver })}
</motion.div>
)}
{/* --- STATE: RESULTS --- */}
{gameState === 'results' && (
<motion.div
key="results"
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
className="flex-1 flex items-center justify-center p-6 bg-slate-950/90 backdrop-blur-xl z-[300]"
>
<div className="w-full max-w-2xl bg-slate-900 border border-white/10 rounded-[3rem] p-12 relative overflow-hidden shadow-2xl">
{/* Background Glow */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-1 bg-gradient-to-r from-transparent via-indigo-500 to-transparent" />
<div className="text-center space-y-10 relative z-10">
<div className="space-y-2">
<div className="flex justify-center mb-4">
<div className="p-4 bg-indigo-500/20 rounded-full text-indigo-400 animate-bounce">
<Trophy size={48} />
</div>
</div>
<h2 className="text-indigo-400 font-black uppercase tracking-[0.3em] text-sm">Session Complete</h2>
<h1 className="text-5xl font-black text-white italic tracking-tight uppercase">Performance Synced</h1>
</div>
{/* Score Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 rounded-3xl p-6 border border-white/5">
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">Neural Points</p>
<p className="text-4xl font-mono font-black text-white">{finalScore.toLocaleString()}</p>
</div>
<div className="bg-white/5 rounded-3xl p-6 border border-white/5 text-left">
<div className="flex items-center gap-2 text-indigo-400 mb-1">
<Activity size={14} />
<span className="text-[10px] font-black uppercase tracking-widest">Precision</span>
</div>
<p className="text-2xl font-mono font-black text-white">{gameStats?.accuracy || '98'}%</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 pt-6">
<Button
onClick={restartGame}
className="flex-1 bg-white text-black hover:bg-slate-200 py-6 rounded-2xl font-black uppercase tracking-widest flex items-center justify-center gap-2"
>
<RefreshCw size={20} /> Re-Calibrate
</Button>
<Button
onClick={handleExit}
variant="ghost"
className="flex-1 bg-white/5 text-white hover:bg-white/10 py-6 rounded-2xl font-black uppercase tracking-widest flex items-center justify-center gap-2"
>
<ArrowLeft size={20} /> Hub
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

22
components/ui/Logo.tsx Normal file
View File

@ -0,0 +1,22 @@
import React from 'react';
interface LogoProps {
className?: string;
variant?: 'full' | 'icon'; // full includes text, icon is just the mask
}
export const Logo: React.FC<LogoProps> = ({ className = "w-12 h-12", variant = 'icon' }) => {
const logoUrl = "https://iili.io/fgyxLsn.md.png";
return (
<div className={`relative flex items-center justify-center ${className}`}>
<img
src={logoUrl}
alt="Rudraksha Logo"
draggable="false"
className="w-full h-full object-contain rounded-[25%] drop-shadow-[0_0_15px_rgba(220,38,38,0.5)] pointer-events-none select-none"
/>
</div>
);
};

85
config/themes.ts Normal file
View File

@ -0,0 +1,85 @@
export interface ThemeDefinition {
id: string;
name: string;
category: 'Classic' | 'Heritage' | 'Nature' | 'Tech' | 'Soft' | 'Vibrant' | 'Precious' | 'Spiritual';
uiMode: 'light' | 'dark' | 'auto';
bgColor: string;
darkBgColor?: string;
bgPattern?: string;
darkBgPattern?: string;
bgPosition?: string;
isPremium?: boolean;
isAnimated?: boolean;
colors: Record<string, string>;
}
export const THEME_REGISTRY: Record<string, ThemeDefinition> = {
'default': {
id: 'default', name: 'Rudraksha Standard', category: 'Classic', uiMode: 'auto',
bgColor: '#fdfbf7', darkBgColor: '#09090b',
bgPattern: "radial-gradient(circle at 0% 0%, rgba(255, 100, 100, 0.05) 0%, transparent 50%)",
colors: {
'--color-red-500': '#dc2626',
'--color-red-600': '#991b1b',
'--color-orange-500': '#f97316'
}
},
'theme_rudra': {
id: 'theme_rudra', name: 'Rudra Eternal', category: 'Spiritual', uiMode: 'dark', isPremium: true,
bgColor: '#0a0a0a',
bgPattern: "linear-gradient(rgba(0,0,0,0.6), rgba(0,0,0,0.8)), url('https://wallpapers.com/images/featured/shiva-dark-okjgt0ga7br9glf7.jpg')",
darkBgPattern: "linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.9)), url('https://wallpapers.com/images/featured/shiva-dark-okjgt0ga7br9glf7.jpg')",
bgPosition: "right -15% center", // Shifted Shiva image further to the right edge
colors: {
'--color-red-500': '#ea580c',
'--color-red-600': '#9a3412',
'--color-orange-500': '#fbbf24'
}
},
'theme_divine': {
id: 'theme_divine', name: 'Divine Radiance', category: 'Spiritual', uiMode: 'dark', isPremium: true,
bgColor: '#050505',
bgPattern: "linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.6)), url('https://wallpapers.com/images/hd/spiritual-desktop-wrpie18qrpz9f28n.jpg')",
darkBgPattern: "linear-gradient(rgba(0,0,0,0.6), rgba(0,0,0,0.8)), url('https://wallpapers.com/images/hd/spiritual-desktop-wrpie18qrpz9f28n.jpg')",
bgPosition: "center",
colors: {
'--color-red-500': '#fbbf24',
'--color-red-600': '#d97706',
'--color-orange-500': '#6366f1'
}
},
'theme_buddha': {
id: 'theme_buddha', name: "Buddha's Path", category: 'Spiritual', uiMode: 'auto', isPremium: true,
bgColor: '#fff1f2', darkBgColor: '#1a0505',
bgPattern: "linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.4)), url('https://wallpapercave.com/wp/wp11680379.jpg')",
darkBgPattern: "linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)), url('https://wallpapercave.com/wp/wp11680379.jpg')",
colors: {
'--color-red-500': '#e11d48',
'--color-red-600': '#9f1239',
'--color-orange-500': '#1e40af'
}
},
'theme_cyberpunk': {
id: 'theme_cyberpunk', name: 'Neon Protocol', category: 'Tech', uiMode: 'dark', isPremium: true,
isAnimated: true,
bgColor: '#09090b',
bgPattern: "linear-gradient(45deg, #09090b 25%, #1a1a2e 50%, #09090b 75%)",
colors: {
'--color-red-500': '#ff0055',
'--color-red-600': '#b3003b',
'--color-orange-500': '#00f2ff'
}
},
'theme_gold': {
id: 'theme_gold', name: 'Golden Karma', category: 'Precious', uiMode: 'light', isPremium: true,
bgColor: '#fefce8', colors: { '--color-red-500': '#a16207', '--color-orange-500': '#eab308' }
}
};
export const THEME_CATEGORIES = ['All', 'Classic', 'Heritage', 'Spiritual', 'Nature', 'Tech', 'Soft', 'Vibrant', 'Precious'];

View File

@ -0,0 +1,180 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
type Language = 'en' | 'ne';
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: string, defaultText: string) => string;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [language, setLanguage] = useState<Language>(() => {
return (localStorage.getItem('rudraksha_lang') as Language) || 'en';
});
useEffect(() => {
localStorage.setItem('rudraksha_lang', language);
}, [language]);
const t = (key: string, defaultText: string) => {
if (language === 'en') return defaultText;
const dictionary: Record<string, string> = {
// Navigation & Layout
"Dashboard": "ड्यासबोर्ड",
"ACADEMIC": "शैक्षिक",
"Academic": "शैक्षिक",
"Rudra AI": "रुद्र एआई",
"Guru AI": "रुद्र एआई",
"Assignments": "गृहकार्य",
"Analytics": "तथ्याङ्क",
"Culture": "संस्कृति",
"Calendar": "पात्रो",
"Community": "समुदाय",
"Network": "सञ्जाल",
"Wellness": "कल्याण",
"FTL Rescue": "उद्धार",
"E-Library": "ई-पुस्तकालय",
"Plan": "योजना",
"Map & Provinces": "नक्सा र प्रदेश",
"Kitchen": "भान्छा",
"Health": "स्वास्थ्य",
"Safety": "सुरक्षा",
"Karma Bazaar": "कर्म बजार",
"Arcade": "आर्केड",
"Chat": "कुराकानी",
"Settings": "सेटिङ्स",
"Good Morning": "शुभ प्रभात",
"Good Afternoon": "शुभ दिन",
"Good Evening": "शुभ सन्ध्या",
"Karma": "कर्म",
"Level": "स्तर",
// AI Guru Features
"Text Chat": "पाठ कुराकानी",
"Live Voice": "प्रत्यक्ष आवाज",
"Video Insight": "भिडियो विश्लेषण",
"Visual Scan": "दृष्टि स्क्यान",
"Quiz Game": "हाजिरीजवाफ",
"Voice Interface": "आवाज इन्टरफेस",
"Conversational AI with Gemini 2.5 Native Audio.": "जेमिनी २.५ नेटिभ अडियोको साथ संवादात्मक एआई।",
"Video Analyst": "भिडियो विश्लेषक",
"Visual Scanner": "दृष्टि स्क्यानर",
"Ask Rudra AI anything...": "रुद्र एआईलाई केहि सोध्नुहोस्...",
"Ask Guru AI anything...": "रुद्र एआईलाई केहि सोध्नुहोस्...",
"Extracting Wisdom...": "ज्ञान निकाल्दै...",
"Insight Dossier": "अन्तर्दृष्टि डोसियर",
"No Intel yet": "अझै कुनै जानकारी छैन",
"Upload Lesson": "पाठ अपलोड गर्नुहोस्",
"Upload Video": "भिडियो अपलोड गर्नुहोस्",
"QUICK SCAN": "द्रुत स्क्यान",
"Ask short questions about the video...": "भिडियोको बारेमा छोटो प्रश्नहरू सोध्नुहोस्...",
"What should Guru AI focus on?": "रुद्र एआईले केमा ध्यान दिनुपर्छ?",
"Paste text to summarize...": "सारांश गर्न पाठ टाँस्नुहोस्...",
"SCAN & ANALYZE": "स्क्यान र विश्लेषण",
"SUMMARIZE INTEL": "जानकारी सारांश",
"Result Extraction": "परिणाम निष्कर्षण",
"Cam": "क्यामेरा",
"Library": "पुस्तकालय",
"Clear": "स्पष्ट",
"Clear Chat": "संवाद मेटाउनुहोस्",
"Session Feed": "वर्तमान सत्र",
"Test your heritage knowledge and earn karma points from Guru AI.": "आफ्नो सम्पदा ज्ञान परीक्षण गर्नुहोस् र रुद्र एआईबाट कर्म अंकहरू कमाउनुहोस्।",
"ENTER CHALLENGE": "चुनौतीमा प्रवेश",
"Syncing Intel...": "जानकारी सिंक गर्दै...",
"Ritual Progress": "प्रगति",
"Wisdom Note": "ज्ञान नोट",
"CONTINUE": "जारी राख्नुहोस्",
"Mission Cleared": "मिशन पुरा",
"Final Score": "अन्तिम स्कोर",
"RESTART RITUAL": "पुनः सुरु",
"CONNECT TO": "जडान गर्नुहोस्",
"Voice Persona Selection": "आवाज व्यक्तित्व चयन",
"Voice Tuning": "आवाज ट्युनिङ",
"Voice Speed": "आवाजको गति",
"Voice Pitch": "आवाजको उतारचढाव",
// Wellness & Health
"Wellness Centre": "स्वास्थ्य केन्द्र",
"Environment": "वातावरण",
"Daily Log": "दैनिक लग",
"Yoga Flow": "योग प्रवाह",
"Yoga": "योग",
"Wisdom": "ज्ञान",
"Sapana": "सपना",
"Climate Tracker": "जलवायु ट्रयाकर",
"Personal Health": "व्यक्तिगत स्वास्थ्य",
"Air Quality Index": "वायु गुणस्तर सूचकांक",
"Mask Advice": "मास्क सुझाव",
"Pollutants": "प्रदूषकहरू",
"Feels Like": "महसुस हुने",
"Humidity": "आर्द्रता",
"Wind": "हावा",
"Daily Wellness Wisdom": "दैनिक स्वास्थ्य ज्ञान",
"Hydration Track": "पानीको ट्रयाक",
"Mood Ritual": "मनस्थिति विधि",
"Recovery Sleep": "निद्रा र आराम",
"Happy": "खुसी",
"Neutral": "सामान्य",
"Stressed": "तनाव",
"Tired": "थकित",
"Yog Flow & Vitality": "योग र प्राण शक्ति",
"Benefits": "फाइदाहरू",
"Movement Guide": "अभ्यास मार्गदर्शक",
"Ayurvedic Rituals": "आयुर्वेदिक विधिहरू",
"Longevity Secrets": "दीर्घायुको रहस्य",
"Active Lifestyle": "सक्रिय जीवनशैली",
"Sattvic Diet": "सात्त्विक आहार",
"Social Connection": "सामाजिक सम्बन्ध",
"Consistent Sleep": "नियमित निद्रा",
"Community Health Secret": "सामुदायिक स्वास्थ्य रहस्य",
"Sapana Interpreter": "सपना व्याख्याता",
"Unlock the hidden messages of your subconscious through the lens of ancient Nepali folklore and modern psychology.": "प्राचीन नेपाली लोककथा र आधुनिक मनोविज्ञानको माध्यमबाट आफ्नो अवचेतन मनका लुकेका सन्देशहरू बुझ्नुहोस्।",
"Describe your dream here... (e.g. I saw a snake in a temple)": "तपाईंको सपना यहाँ वर्णन गर्नुहोस्... (जस्तै: मैले मन्दिरमा सर्प देखेँ)",
"REVEAL MEANING": "अर्थ खोल्नुहोस्",
"Traditional Folklore": "परम्परागत लोकविश्वास",
"Psychological View": "मनोवैज्ञानिक दृष्टिकोण",
// Safety & FTL
"Find The Lost": "हराएकाको खोजी",
"Report Loss": "हराएको जानकारी",
"I Found Something": "मैले केहि भेट्टाएँ",
"Report Eye-Witness": "प्रत्यक्षदर्शी जानकारी",
"Active Feed": "प्रत्यक्ष अपडेट",
"My Rescue Logs": "मेरा रिपोर्टहरू",
"Rescue Tags": "सुरक्षा ट्यागहरू",
"Community Vault": "सामुदायिक भल्ट",
"Find My Item": "मेरो सामान खोजनुहोस्",
"Nepal Police": "नेपाल प्रहरी",
"Ambulance": "एम्बुलेन्स",
"Fire Brigade": "दमकल",
"MISSION COMMAND": "मिशन कमान्ड",
// Common
"Loading...": "लोड हुँदैछ...",
"Save Changes": "परिवर्तनहरू सुरक्षित गर्नुहोस्",
"Cancel": "रद्द गर्नुहोस्",
"Close": "बन्द गर्नुहोस्",
"Edit": "सम्पादन",
"Delete": "हटाउनुहोस्",
"Search": "खोज्नुहोस्",
};
return dictionary[key] || defaultText;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) throw new Error("useLanguage must be used within a LanguageProvider");
return context;
};

697
data/staticData.ts Normal file
View File

@ -0,0 +1,697 @@
import { Recipe, HeritageSite, ProvinceData } from '../types';
export const INITIAL_RECIPES: Recipe[] = [
{
id: '1',
title: 'Dal Bhat Tarkari',
author: 'Nepali Staple',
description: 'The national dish of Nepal. A wholesome platter consisting of steamed rice (Bhat), lentil soup (Dal), and seasonal vegetable curry (Tarkari), often served with pickles (Achar). "Dal Bhat Power 24 Hour!"',
ingredients: ['Rice', 'Lentils (Musur/Mas/Rahar)', 'Seasonal Vegetables', 'Spices (Turmeric, Cumin, Coriander)', 'Ghee'],
instructions: '1. Boil rice. 2. Cook lentils with spices to make soup. 3. Stir fry vegetables (curry). 4. Serve together on a brass plate.',
isPublic: true, likes: 5000, prepTime: 45, tags: ['daily', 'veg'],
imageUrl: 'https://walnutbistronepal.com/wp-content/uploads/2023/12/1S3A0529.jpg'
},
{
id: '2',
title: 'Momo',
author: 'Street Food Legend',
description: 'Steamed dumplings with savory fillings. The second most popular food after Dal Bhat, served with a spicy tomato sesame dipping sauce (Achar).',
ingredients: ['Flour dough', 'Minced meat (Buff/Chicken/Veg)', 'Onions', 'Ginger Garlic Paste', 'Momo Masala'],
instructions: '1. Prepare dough wrappers. 2. Mix mince with spices. 3. Pleat dumplings. 4. Steam for 10-12 mins.',
isPublic: true, likes: 8500, prepTime: 60, tags: ['daily', 'non-veg', 'snack'],
imageUrl: 'https://delishglobe.com/wp-content/uploads/2025/05/Nepalese-Momo.png'
},
{
id: '3',
title: 'Sel Roti',
author: 'Festival Special',
description: 'A ring-shaped, sweet rice bread/doughnut prepared during Tihar and Dashain festivals. Crispy on the outside, soft on the inside.',
ingredients: ['Rice flour', 'Sugar', 'Ghee', 'Milk', 'Cardamom', 'Clove'],
instructions: '1. Make semi-liquid batter. 2. Pour into hot oil in ring shape. 3. Deep fry until golden brown.',
isPublic: true, likes: 3200, prepTime: 40, tags: ['daily', 'veg', 'festival', 'snack'],
imageUrl: 'https://washburnreview.org/wp-content/uploads/2023/03/sel-roti.jpeg'
},
{
id: '4',
title: 'Chiura (Beaten Rice)',
author: 'Newari Khaja',
description: 'Flattened dry rice that serves as a staple snack. Crunchy and dry, it pairs perfectly with spicy curries and meat dishes.',
ingredients: ['Rice grains (flattened)', 'Accompaniments (Curry/Achar)'],
instructions: '1. Soak paddy. 2. Roast and flatten. 3. Serve dry with side dishes.',
isPublic: true, likes: 1500, prepTime: 5, tags: ['newari', 'veg', 'snack'],
imageUrl: 'https://eatyourworld.com/wp-content/uploads/2011/09/kathmandu-chiura.jpg'
},
{
id: '5',
title: 'Gundruk',
author: 'Rural Heritage',
description: 'Fermented leafy green vegetables. A national food claim, originating from high altitudes to preserve greens for winter.',
ingredients: ['Mustard/Radish/Cauliflower leaves'],
instructions: '1. Wilt leaves. 2. Ferment in jar/pit for weeks. 3. Dry in sun. 4. Make soup (Gundruk ko Jhol) or pickle (Sadheko).',
isPublic: true, likes: 2100, prepTime: 15, tags: ['daily', 'veg'],
imageUrl: 'https://junifoods.com/wp-content/uploads/2024/02/Gundruk-High-Nutrition-Fermented-Greens-%E0%A4%97%E0%A5%81%E0%A4%A8%E0%A5%8D%E0%A4%A6%E0%A5%8D%E0%A4%B0%E0%A5%81%E0%A4%95.jpg'
},
{
id: '6',
title: 'Yomari',
author: 'Newari Culture',
description: 'A steamed dumpling with an external covering of rice flour and sweet fillings like Chaku (molasses) or Khuwa. Famous during Yomari Punhi.',
ingredients: ['Rice flour dough', 'Chaku (Molasses)', 'Sesame seeds', 'Khuwa'],
instructions: '1. Shape dough into fig-shape. 2. Fill with hot chaku. 3. Steam.',
isPublic: true, likes: 2800, prepTime: 60, tags: ['newari', 'veg', 'dessert', 'festival'],
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/Yomari_double.jpg/960px-Yomari_double.jpg'
},
{
id: '7',
title: 'Chatamari',
author: 'Newari Pizza',
description: 'A traditional rice crepe topped with minced meat, eggs, and vegetables. Often called "Nepali Pizza".',
ingredients: ['Rice flour batter', 'Minced meat', 'Eggs', 'Onions', 'Tomatoes'],
instructions: '1. Spread batter on hot pan. 2. Add toppings. 3. Cover and cook until base is crispy.',
isPublic: true, likes: 2300, prepTime: 20, tags: ['newari', 'non-veg', 'snack'],
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/e/e9/Meat_Chatamari.jpg'
},
{
id: '8',
title: 'Thukpa',
author: 'Himalayan Warmth',
description: 'A hot noodle soup with Tibetan origins, influenced by Indian spices in Nepal. Comfort food for cold weather.',
ingredients: ['Noodles', 'Bone broth', 'Vegetables (Carrot/Cabbage)', 'Meat (Chicken/Buff)', 'Spices'],
instructions: '1. Prepare spicy broth. 2. Boil noodles. 3. Combine and serve hot.',
isPublic: true, likes: 4100, prepTime: 30, tags: ['daily', 'non-veg'],
imageUrl: 'https://junifoods.com/wp-content/uploads/2024/05/The-Best-Chicken-Thukpa-Tibetan-Noodle-Soup-%E0%A4%95%E0%A5%81%E0%A4%96%E0%A5%81%E0%A4%B0%E0%A4%BE%E0%A4%95%E0%A5%8B-%E0%A4%A5%E0%A5%81%E0%A4%95%E0%A5%8D%E0%A4%AA%E0%A4%BE-.jpg'
},
{
id: '9',
title: 'Samay Baji',
author: 'Newari Feast',
description: 'An authentic Newari set served during festivals. Consists of beaten rice, bara, choila, black soybeans, and potato salad.',
ingredients: ['Chiura', 'Bara', 'Choila', 'Bhatmas', 'Aloo Achar', 'Saag'],
instructions: 'Assemble all prepared components on a leaf plate.',
isPublic: true, likes: 3500, prepTime: 90, tags: ['newari', 'non-veg', 'festival'],
imageUrl: 'https://delishglobe.com/wp-content/uploads/2025/05/Samay-Baji-Festive-Newari-Platter.png'
},
{
id: '10',
title: 'Dhindo',
author: 'Mountain Energy',
description: 'A thick porridge made by boiling millet or buckwheat flour. Swallowed without chewing, usually eaten with local chicken curry or gundruk.',
ingredients: ['Millet/Buckwheat flour', 'Water', 'Ghee'],
instructions: '1. Boil water. 2. Slowly add flour while stirring continuously to prevent lumps. 3. Serve with curry.',
isPublic: true, likes: 1800, prepTime: 30, tags: ['daily', 'veg'],
imageUrl: 'https://whatthenepal.com/wp-content/uploads/2024/01/dhindo.jpg'
},
{
id: '11',
title: 'Juju Dhau',
author: 'King of Curd',
description: 'The "King Yogurt" from Bhaktapur. A thick, creamy, and sweet curd prepared in clay pots.',
ingredients: ['Buffalo Milk', 'Sugar', 'Culture'],
instructions: '1. Boil milk until thick. 2. Add sugar. 3. Pour into clay pots and ferment.',
isPublic: true, likes: 4500, prepTime: 480, tags: ['newari', 'veg', 'dessert'],
imageUrl: 'https://www.nepalsanctuarytreks.com/wp-content/uploads/2018/10/Juju-Dhau.jpg'
},
{
id: '12',
title: 'Choila',
author: 'Spicy Grill',
description: 'Spiced grilled buffalo meat (or chicken/duck). Marinated with garlic, ginger, and mustard oil. Very spicy.',
ingredients: ['Buffalo meat', 'Mustard oil', 'Fenugreek seeds', 'Garlic', 'Chili'],
instructions: '1. Grill meat. 2. Cut into cubes. 3. Marinate with spices and hot mustard oil tempering.',
isPublic: true, likes: 3100, prepTime: 40, tags: ['newari', 'non-veg', 'snack'],
imageUrl: 'https://century.com.np/wp-content/uploads/2021/05/Buff-chhoila-recipe.jpg'
},
{
id: '13',
title: 'Sekuwa',
author: 'Street BBQ',
description: 'Meat roasted in a natural wood fire. Marinated with traditional herbs and spices. Famous in Dharan and Kathmandu.',
ingredients: ['Meat (Goat/Pork/Chicken)', 'Sekuwa Masala', 'Yogurt'],
instructions: '1. Marinate meat overnight. 2. Skewer and roast over wood charcoal.',
isPublic: true, likes: 3900, prepTime: 20, tags: ['daily', 'non-veg', 'snack'],
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/Sekuwa.jpg/960px-Sekuwa.jpg'
},
{
id: '14',
title: 'Kwati',
author: 'Festival Soup',
description: 'A mixed soup of nine different types of sprouted beans. High in protein and eaten during Janai Purnima.',
ingredients: ['9 types of beans (Soybean, Mung, Gram, etc.)', 'Ajwain', 'Spices'],
instructions: '1. Sprout beans for days. 2. Pressure cook with spices. 3. Temper with ajwain.',
isPublic: true, likes: 1600, prepTime: 60, tags: ['daily', 'veg', 'festival'],
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/e/ed/A_bowl_of_Kwati.jpg'
},
{
id: '15',
title: 'Bara (Wo)',
author: 'Lentil Pancake',
description: 'Traditional Newari lentil patty. Can be plain or topped with egg (Anda Bara) or meat (Masu Bara). Used in "Sagun".',
ingredients: ['Black lentil paste', 'Ginger', 'Salt', 'Egg (optional)'],
instructions: '1. Soak and blend lentils. 2. Shape into patties. 3. Fry on flat pan.',
isPublic: true, likes: 2200, prepTime: 30, tags: ['newari', 'veg', 'snack'],
imageUrl: 'https://www.thegundruk.com/wp-content/uploads/2014/03/bara-wo_sasa-restaurant1.jpg'
},
{
id: '16',
title: 'Samosa',
author: 'Tea Time Snack',
description: 'Fried pastry with a savory filling, usually spiced potatoes and peas. Best served hot with chutney.',
ingredients: ['Flour', 'Potatoes', 'Peas', 'Spices'],
instructions: '1. Make dough. 2. Prepare potato filling. 3. Fold into triangle and fry.',
isPublic: true, likes: 5500, prepTime: 45, tags: ['daily', 'veg', 'snack'],
imageUrl: 'https://www.indianhealthyrecipes.com/wp-content/uploads/2021/12/samosa-recipe-500x375.jpg'
},
{
id: '17',
title: 'Lassi',
author: 'Indra Chowk Special',
description: 'A blend of yogurt, water, spices and sometimes fruit. The sweet lassi in Kathmandu is famous for its rich toppings.',
ingredients: ['Curd', 'Sugar', 'Cashews/Raisins', 'Khuwa'],
instructions: '1. Blend curd and sugar. 2. Pour into glass. 3. Top with khuwa and nuts.',
isPublic: true, likes: 4200, prepTime: 10, tags: ['daily', 'beverages', 'dessert'],
imageUrl: 'https://1.bp.blogspot.com/-vrBFLm64rgo/T0tDG4CgZYI/AAAAAAAADE4/fGL1vxP4SsE/s1600/IMG_2260.JPG'
},
{
id: '18',
title: 'Kheer',
author: 'Royal Dessert',
description: 'Sweet rice pudding flavored with cardamom, saffron, cashews, and coconut. Served during ceremonies.',
ingredients: ['Rice', 'Milk', 'Sugar', 'Dry Fruits', 'Cardamom'],
instructions: '1. Simmer rice in milk until thick. 2. Add sugar and spices.',
isPublic: true, likes: 2900, prepTime: 60, tags: ['daily', 'veg', 'dessert'],
imageUrl: 'https://www.cookwithmanali.com/wp-content/uploads/2017/06/Indian-Rice-Kheer-500x500.jpg'
},
{
id: '19',
title: 'Sukuti',
author: 'Dried Delicacy',
description: 'Dried meat (buff or goat) marinated in spices. Chewy, spicy, and often served as an appetizer.',
ingredients: ['Dried Meat', 'Onion', 'Tomato', 'Spices'],
instructions: '1. Dry meat in sun/smoke. 2. Stir fry with spices and veggies.',
isPublic: true, likes: 2700, prepTime: 20, tags: ['daily', 'non-veg', 'snack'],
imageUrl: 'https://junifoods.com/wp-content/uploads/2023/08/Sukuti-Sadheko-Dried-Meat-Spicy-Salad-%E0%A4%B8%E0%A4%BE%E0%A4%81%E0%A4%A6%E0%A5%87%E0%A4%95%E0%A5%8B-%E0%A4%B8%E0%A5%81%E0%A4%95%E0%A5%81%E0%A4%9F%E0%A5%80-1.jpg'
},
{
id: '20',
title: 'Aloo Tama',
author: 'Sour & Spicy',
description: 'Curry made of potatoes (Aloo) and bamboo shoots (Tama). Unique sour taste from the fermented bamboo.',
ingredients: ['Potatoes', 'Bamboo Shoots', 'Black eyed peas', 'Spices'],
instructions: '1. Fry bamboo shoots. 2. Add soaked beans and potatoes. 3. Pressure cook.',
isPublic: true, likes: 1900, prepTime: 40, tags: ['newari', 'veg'],
imageUrl: 'https://www.foodpleasureandhealth.com/wp-content/uploads/2015/10/alooboditama.jpg'
},
{
id: '21',
title: 'Chicken Chilly',
author: 'Restro Favorite',
description: 'Boneless chicken fried and tossed in a spicy sauce with onions and capsicum. Indo-Chinese influence.',
ingredients: ['Chicken', 'Capsicum', 'Onion', 'Soy Sauce', 'Chili Paste'],
instructions: '1. Batter fry chicken. 2. Toss in spicy sauce with veggies.',
isPublic: true, likes: 3800, prepTime: 30, tags: ['daily', 'non-veg', 'snack'],
imageUrl: 'https://static.toiimg.com/thumb/53094926.cms?width=1200&height=900'
},
{
id: '22',
title: 'Tongba',
author: 'Eastern Warmth',
description: 'Millet-based alcoholic beverage. Hot water is poured over fermented millet in a wooden container.',
ingredients: ['Fermented Millet', 'Hot Water'],
instructions: '1. Put millet in Tongba vessel. 2. Pour boiling water. 3. Drink with bamboo straw.',
isPublic: true, likes: 1400, prepTime: 10, tags: ['daily', 'beverages'],
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/8/8f/Tongba.jpg'
},
{
id: '23',
title: 'Sapu Mhicha',
author: 'Newari Exotic',
description: 'Buffalo leaf tripe stuffed with bone marrow. Boiled and then fried. A delicacy for special occasions.',
ingredients: ['Leaf Tripe', 'Bone Marrow'],
instructions: '1. Stuff tripe with marrow. 2. Tie and boil. 3. Deep fry.',
isPublic: true, likes: 900, prepTime: 120, tags: ['newari', 'non-veg'],
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/2/2a/Sapu_Micha.jpg'
},
{
id: '24',
title: 'Shyakpa (Sherpa Stew)',
author: 'Mountain Stew',
description: 'A hearty stew made with meat, potatoes, radishes, and handmade noodles. Keeps you warm in the Himalayas.',
ingredients: ['Meat (Yak/Buff)', 'Potatoes', 'Radish', 'Flour dough', 'Spices'],
instructions: '1. Stir fry meat and veg. 2. Add water. 3. Pinch dough into soup. 4. Simmer.',
isPublic: true, likes: 1300, prepTime: 50, tags: ['daily', 'non-veg'],
imageUrl: 'https://res.cloudinary.com/images-swotahtravel-com/image/upload/v1695288755/blog%20images/shakpa.jpg'
},
{
id: '25',
title: 'Masala Tea',
author: 'Chiya',
description: 'Rich milk tea brewed with spices like cardamom, cloves, ginger, and cinnamon. The heartbeat of Nepali social life.',
ingredients: ['Milk', 'Tea leaves', 'Ginger', 'Cardamom', 'Sugar'],
instructions: '1. Boil water with spices. 2. Add tea leaves and milk. 3. Boil until rich color.',
isPublic: true, likes: 9000, prepTime: 15, tags: ['daily', 'beverages'],
imageUrl: 'https://nepalteaexchange.com.np/cdn/shop/articles/masala-tea-8020912_1280.jpg?v=1731659902'
},
{
id: '26',
title: 'Yak Cheese',
author: 'Langtang Special',
description: 'Hard cheese made from Yak milk in the high Himalayas. Often chewed as a hard snack (Churpi).',
ingredients: ['Yak Milk', 'Lime/Culture'],
instructions: 'Produced in high-altitude cheese factories like Kyanjin Gompa.',
isPublic: true, likes: 1200, prepTime: 0, tags: ['daily', 'veg', 'snack'],
imageUrl: 'https://www.hempinnepal.com/wp-content/uploads/2025/06/churpi-nepal.jpg'
},
{
id: '27',
title: 'Aloo Chop',
author: 'Street Snack',
description: 'Fried potato patties with herbs and spices. A popular cheap street food.',
ingredients: ['Boiled potatoes', 'Chickpea flour batter', 'Spices'],
instructions: '1. Mash spiced potatoes. 2. Dip in batter. 3. Deep fry.',
isPublic: true, likes: 2500, prepTime: 30, tags: ['daily', 'veg', 'snack'],
imageUrl: 'https://junifoods.com/wp-content/uploads/2022/12/Aloo-Chop-Nepal-Style-Potato-Croquette-%E0%A4%86%E0%A4%B2%E0%A5%81-%E0%A4%9A%E0%A4%AA-1.jpg'
},
{
id: '28',
title: 'Aloo Paratha',
author: 'Breakfast King',
description: 'Flatbread stuffed with spiced mashed potatoes. Eaten with curd or pickle.',
ingredients: ['Wheat flour', 'Potatoes', 'Cilantro', 'Chili'],
instructions: '1. Stuff dough ball with potato mix. 2. Roll flat. 3. Pan fry with oil/ghee.',
isPublic: true, likes: 4000, prepTime: 40, tags: ['daily', 'veg'],
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/5/54/Aloo_Paratha_also_known_as_Batatay_Jo_Phulko.jpg'
},
{
id: '29',
title: 'Panipuri',
author: 'Tangy Burst',
description: 'Hollow fried balls filled with spicy potato mix and tangy tamarind water. A street food favorite.',
ingredients: ['Puri shells', 'Tamarind water', 'Potato mix'],
instructions: '1. Crack shell. 2. Fill with potato. 3. Dip in tangy water. 4. Eat whole immediately.',
isPublic: true, likes: 6000, prepTime: 20, tags: ['daily', 'veg', 'snack'],
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Pani_Puri1.JPG/1280px-Pani_Puri1.JPG'
},
{
id: '30',
title: 'Chicken Chowmein',
author: 'Noodle Fix',
description: 'Stir-fried noodles with chicken and veggies. The go-to lunch for many.',
ingredients: ['Noodles', 'Chicken', 'Cabbage', 'Soy sauce'],
instructions: '1. Boil noodles. 2. Stir fry meat and veggies. 3. Toss noodles with sauces.',
isPublic: true, likes: 4800, prepTime: 20, tags: ['daily', 'non-veg'],
imageUrl: 'https://tiffycooks.com/wp-content/uploads/2023/09/188E6766-B4B4-48FB-80F9-9E7EBA5B6278-scaled.jpg'
},
{
id: '31',
title: 'Tibetan Bread',
author: 'Trek Breakfast',
description: 'Fried bread made from tsampa or flour. Often eaten with honey or cheese in trekking lodges.',
ingredients: ['Flour', 'Baking powder', 'Water'],
instructions: '1. Make dough. 2. Cut slits in center. 3. Deep fry.',
isPublic: true, likes: 1100, prepTime: 25, tags: ['daily', 'veg'],
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/3/31/Balep_korkun%2C_the_Tibetan_bread%2C_photographed_in_Nepal%2C_December_1%2C_2023.jpg'
},
{
id: '32',
title: 'Gajar ko Haluwa',
author: 'Sweet Carrot',
description: 'Carrot pudding slow-cooked in milk, ghee, and sugar.',
ingredients: ['Carrots (grated)', 'Milk', 'Sugar', 'Ghee'],
instructions: '1. Sauté carrots. 2. Add milk and cook until dry. 3. Garnish with nuts.',
isPublic: true, likes: 2400, prepTime: 50, tags: ['daily', 'veg', 'dessert'],
imageUrl: 'https://www.cookwithmanali.com/wp-content/uploads/2015/01/Gajar-Halwa-Indian.jpg'
},
{
id: '33',
title: 'Furandana',
author: 'Crispy Snack',
description: 'Deep fried beaten rice. A crunchy snack often eaten with veggies or during festivals.',
ingredients: ['Beaten rice', 'Oil', 'Peanuts', 'Spices'],
instructions: '1. Deep fry beaten rice until puffed. 2. Mix with salt and spices.',
isPublic: true, likes: 1300, prepTime: 10, tags: ['daily', 'veg', 'snack'],
imageUrl: 'https://sewapoint.com/image-1743779970949-hq720.jpg'
},
{
id: '34',
title: 'Jhaikhatte',
author: 'Village Drink',
description: 'Alcoholic drink where hot ghee and rice grains are sizzled into Raksi (local alcohol). Named after the sound it makes.',
ingredients: ['Raksi', 'Ghee', 'Rice grains'],
instructions: '1. Heat ghee and rice. 2. Pour alcohol over it (Sizzle!). 3. Serve hot.',
isPublic: true, likes: 800, prepTime: 5, tags: ['daily', 'beverages'],
imageUrl: 'https://insidehimalayas.com/wp-content/uploads/2018/09/jwawinkhatte-with-chamel-768x1024.jpg'
},
{
id: '35',
title: 'Chicken Biryani',
author: 'Spiced Rice',
description: 'Aromatic rice dish cooked with chicken and spices. Popular at celebrations.',
ingredients: ['Basmati Rice', 'Chicken', 'Yogurt', 'Biryani Masala'],
instructions: '1. Marinate chicken. 2. Par-boil rice. 3. Layer meat and rice. 4. Dum cook.',
isPublic: true, likes: 5200, prepTime: 90, tags: ['daily', 'non-veg'],
imageUrl: 'https://images.food52.com/VOfOuvcQe7fBeSqixNe1L-LhUBY=/d815e816-4664-472e-990b-d880be41499f--chicken-biryani-recipe.jpg'
}
];
export const HERITAGE_SITES: HeritageSite[] = [
// ... (rest of the file remains unchanged, omitting for brevity as only recipes were updated)
{
id: '1',
name: 'Swayambhu Mahachaitya',
nameNe: 'स्वयम्भू महाचैत्य',
description: 'The Monkey Temple, a UNESCO World Heritage site overlooking the valley.',
descriptionNe: 'काठमाडौं उपत्यकाको अवलोकन गर्न सकिने युनेस्को विश्व सम्पदा सूचीमा सूचीकृत मन्दिर (बाँदर मन्दिर)।',
category: 'Stupa',
region: 'Kathmandu',
latitude: 27.7148996,
longitude: 85.2903957,
imageUrl: 'https://bajracharya.org/wp-content/uploads/2023/12/swayambhu-1.webp',
history: 'Foundations laid over 2,000 years ago, making it one of the oldest religious sites in Nepal. Legend says the valley was once a lake, and the stupa rose from a lotus flower.',
historyNe: '२००० वर्षभन्दा पुरानो जग भएको, नेपालको सबैभन्दा पुरानो धार्मिक स्थलहरू मध्ये एक। किंवदन्ती अनुसार उपत्यका पहिले ताल थियो, र यो स्तूप कमलको फूलबाट उत्पन्न भएको हो।',
culturalSignificance: 'A hallmark of faith and harmony for both Buddhists and Hindus. The eyes of Buddha painted on the stupa look out in all four directions.',
culturalSignificanceNe: 'बौद्ध र हिन्दु दुवैको आस्था र सद्भावको केन्द्र। स्तूपमा चित्रित बुद्धका आँखाहरूले चारै दिशामा हेरिरहेका छन्।'
},
{
id: '2',
name: 'Boudhanath Stupa',
nameNe: 'बौद्धनाथ स्तूप',
description: 'One of the largest spherical stupas in Nepal, center of Tibetan Buddhism.',
descriptionNe: 'नेपालको सबैभन्दा ठूलो गोलाकार स्तूपहरू मध्ये एक, तिब्बती बौद्ध धर्मको केन्द्र।',
category: 'Stupa',
region: 'Kathmandu',
latitude: 27.7215,
longitude: 85.3620,
imageUrl: 'https://images.squarespace-cdn.com/content/v1/5b735348266c075124b0ffb3/1568373828844-AP1ZPOIWBGGFJWMIZ55N/Boudha_Stupa_181030-9.jpg',
history: 'Built around the 14th century, it stands on the ancient trade route from Tibet. It became a focal point for Tibetan exiles in the 1950s.',
historyNe: '१४ औं शताब्दीमा निर्मित, यो तिब्बतसँगको प्राचीन व्यापारिक मार्गमा अवस्थित छ। १९५० को दशकमा तिब्बती शरणार्थीहरूका लागि यो प्रमुख केन्द्र बन्यो।',
culturalSignificance: 'Represents the Mandala (wholeness). The massive dome symbolizes the universe, and the tower symbolizes the path to enlightenment.',
culturalSignificanceNe: 'यसले मण्डल (पूर्णता) को प्रतिनिधित्व गर्दछ। विशाल गुम्बजले ब्रह्माण्ड र गजुरले ज्ञानको मार्गलाई जनाउँछ।'
},
{
id: '3',
name: 'Pashupatinath Temple',
nameNe: 'पशुपतिनाथ मन्दिर',
description: 'A sacred Hindu temple complex on the banks of the Bagmati River.',
descriptionNe: 'बागमती नदीको किनारमा अवस्थित पवित्र हिन्दु मन्दिर परिसर।',
category: 'Temple',
region: 'Kathmandu',
latitude: 27.7104,
longitude: 85.3487,
imageUrl: 'https://dynamic-media-cdn.tripadvisor.com/media/photo-o/13/4a/95/ac/pashupatinath-is-the.jpg?w=900&h=500&s=1',
history: 'The oldest Hindu temple in Kathmandu. The current structure was erected in the 17th century by King Bhupatindra Malla after previous structures were destroyed by termites.',
historyNe: 'काठमाडौंको सबैभन्दा पुरानो हिन्दु मन्दिर। हालको संरचना १७ औं शताब्दीमा राजा भूपतीन्द्र मल्लले निर्माण गरेका हुन्।',
culturalSignificance: 'Dedicated to Lord Shiva. It is the site of open-air cremations and a major pilgrimage destination during Maha Shivaratri.',
culturalSignificanceNe: 'भगवान शिवलाई समर्पित। यो खुल्ला चितादाह स्थल र महाशिवरात्रिको समयमा प्रमुख तीर्थस्थल हो।'
},
{
id: '4',
name: 'Bhaktapur Durbar Square',
nameNe: 'भक्तपुर दरबार क्षेत्र',
description: 'A plaza in front of the royal palace of the old Bhaktapur Kingdom.',
descriptionNe: 'पुरानो भक्तपुर राज्यको राजदरबार अगाडिको प्राङ्गण।',
category: 'Palace',
region: 'Bhaktapur',
latitude: 27.6722,
longitude: 85.4285,
imageUrl: 'https://dynamic-media-cdn.tripadvisor.com/media/photo-o/0d/ef/5c/54/durbar-square-bhaktapur.jpg?w=900&h=-1&s=1',
history: 'Known as the "City of Devotees", it was the capital of Nepal during the great Malla Kingdom until the second half of the 15th century.',
historyNe: '"भक्तहरूको शहर" भनेर चिनिने यो १५ औं शताब्दीको उत्तरार्धसम्म विशाल मल्ल राज्यको राजधानी थियो।',
culturalSignificance: 'Famous for the 55-Window Palace, the Golden Gate, and distinct Newari architecture and woodcarvings.',
culturalSignificanceNe: '५५ झ्याले दरबार, स्वर्णद्वार, र विशिष्ट नेवारी वास्तुकला तथा काष्ठकलाका लागि प्रसिद्ध।'
},
{
id: '5',
name: 'Patan Durbar Square',
nameNe: 'पाटन दरबार क्षेत्र',
description: 'Marvel of Newar architecture with intricately carved temples.',
descriptionNe: 'कुँदिएका मन्दिरहरू सहितको नेवारी वास्तुकलाको उत्कृष्ट नमूना।',
category: 'Palace',
region: 'Lalitpur',
latitude: 27.6744,
longitude: 85.3249,
imageUrl: 'https://www.nepaltraveladventure.com/blog/wp-content/uploads/2020/03/patan-durbar-square.jpg',
history: 'The square is an ancient royal complex of the Malla kings of Lalitpur. It serves as a living museum of history and art.',
historyNe: 'यो ललितपुरका मल्ल राजाहरूको प्राचीन राजदरबार परिसर हो। यसले इतिहास र कलाको जीवन्त संग्रहालयको रूपमा काम गर्दछ।',
culturalSignificance: 'Home to the Krishna Mandir, built entirely of stone, and the Patan Museum. It reflects the pinnacle of Newari craftsmanship.',
culturalSignificanceNe: 'ढुङ्गाले मात्र बनेको कृष्ण मन्दिर र पाटन संग्रहालयको घर। यसले नेवारी शिल्प कौशलको शिखरलाई झल्काउँछ।'
},
{
id: '6',
name: 'Lumbini',
nameNe: 'लुम्बिनी',
description: 'The birthplace of Lord Buddha and a place of pilgrimage.',
descriptionNe: 'भगवान बुद्धको जन्मस्थल र तीर्थस्थल।',
category: 'Other',
region: 'Rupandehi',
latitude: 27.4705,
longitude: 83.2755,
imageUrl: 'https://heavenhimalaya.com/_next/image/?url=https%3A%2F%2Ffis-api.heavenhimalaya.com%2Fmedia%2Fblog%2Fbanner%2Fbirthplace-of-buddha-1755068050.jpg&w=1920&q=75',
history: 'Siddhartha Gautama, who later became the Buddha, was born here in 623 BC. The site was rediscovered in 1896 by archaeologists.',
historyNe: 'सिद्धार्थ गौतम, जो पछि बुद्ध बने, ईसापूर्व ६२३ मा यहाँ जन्मेका थिए। यो स्थल १८९६ मा पुरातत्वविद्हरूले पुनः पत्ता लगाएका थिए।',
culturalSignificance: 'One of the four main pilgrimage sites for Buddhists. It contains the Maya Devi Temple and the Ashoka Pillar.',
culturalSignificanceNe: 'बौद्धहरूका लागि चार प्रमुख तीर्थस्थलहरू मध्ये एक। यहाँ मायादेवी मन्दिर र अशोक स्तम्भ रहेका छन्।'
},
{
id: '7',
name: 'Chitwan National Park',
nameNe: 'चितवन राष्ट्रिय निकुञ्ज',
description: 'World heritage site known for One-horned Rhinos.',
descriptionNe: 'एक सिङ्गे गैंडाका लागि प्रसिद्ध विश्व सम्पदा क्षेत्र।',
category: 'Nature',
region: 'Chitwan',
latitude: 27.5292,
longitude: 84.3643,
imageUrl: 'https://www.andbeyond.com/wp-content/uploads/sites/5/indian-elephant-chitwan-nepal.jpg',
history: 'Established in 1973 as Nepals first National Park. It was formerly a royal hunting ground.',
historyNe: '१९७३ मा स्थापित नेपालको पहिलो राष्ट्रिय निकुञ्ज। यो पहिले शाही शिकार आरक्ष थियो।',
culturalSignificance: 'A success story for conservation, particularly for the One-horned Rhinoceros and the Royal Bengal Tiger.',
culturalSignificanceNe: 'संरक्षणको सफलताको कथा, विशेष गरी एक सिङ्गे गैंडा र पाटे बाघका लागि।'
},
{
id: '8',
name: 'Pokhara (Phewa Lake)',
nameNe: 'पोखरा (फेवा ताल)',
description: 'Gateway to the Annapurna Circuit, famous for its lake.',
descriptionNe: 'अन्नपूर्ण पदयात्राको प्रवेशद्वार, तालका लागि प्रसिद्ध।',
category: 'Nature',
region: 'Kaski',
latitude: 28.2096,
longitude: 83.9595,
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/b/b7/Phewa_lake%2C_Pokhara.jpg',
history: 'An important city on the trading route between India and Tibet. It is now the tourism capital of Nepal.',
historyNe: 'भारत र तिब्बत बीचको व्यापारिक मार्गमा पर्ने एक महत्वपूर्ण शहर। यो अहिले नेपालको पर्यटन राजधानी हो।',
culturalSignificance: 'Home to the Tal Barahi Temple located in the middle of Phewa Lake and the World Peace Pagoda.',
culturalSignificanceNe: 'फेवा तालको बीचमा अवस्थित तालबाराही मन्दिर र विश्व शान्ति स्तूपको घर।'
},
{
id: '9',
name: 'Janaki Mandir',
nameNe: 'जानकी मन्दिर',
description: 'A Hindu temple in Janakpur dedicated to the Hindu goddess Sita.',
descriptionNe: 'जनकपुरमा अवस्थित हिन्दु देवी सीतालाई समर्पित मन्दिर।',
category: 'Temple',
region: 'Janakpur',
latitude: 26.7303,
longitude: 85.9272,
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/1/16/Janki_Mandir_alt_version.jpg',
history: 'Built in 1910 AD by Queen Brisabhanu Kunwari of Tikamgarh. It is a masterpiece of Mughal and Koiri architecture.',
historyNe: 'सन् १९१० मा टिकमगढकी रानी वृषभानु कुँवारीले निर्माण गरेकी हुन्। यो मुगल र कोइरी वास्तुकलाको उत्कृष्ट नमूना हो।',
culturalSignificance: 'Believed to be the birthplace of Goddess Sita and the site of her marriage to Lord Ram.',
culturalSignificanceNe: 'देवी सीताको जन्मस्थल र भगवान रामसँग विवाह भएको स्थल मानिन्छ।'
},
{
id: '10',
name: 'Nagarkot',
nameNe: 'नगरकोट',
description: 'A village famous for its sunrise views of the Himalayas.',
descriptionNe: 'हिमालयको सूर्योदयको दृश्यावलोकनका लागि प्रसिद्ध गाउँ।',
category: 'Nature',
region: 'Bhaktapur',
latitude: 27.7174,
longitude: 85.5046,
imageUrl: 'https://dynamic-media-cdn.tripadvisor.com/media/photo-o/08/40/02/28/nagarkot.jpg?w=1100&h=1100&s=1',
history: 'Historically a summer retreat for the royal family and an ancient fort to monitor external activities of other kingdoms.',
historyNe: 'ऐतिहासिक रूपमा राजपरिवारको ग्रीष्मकालीन विश्राम स्थल र अन्य राज्यहरूको बाह्य गतिविधिहरू निगरानी गर्ने प्राचीन किल्ला।',
culturalSignificance: 'Offers one of the broadest views of the Himalayas, including Mount Everest, promoting appreciation of nature.',
culturalSignificanceNe: 'सगरमाथा सहित हिमालयको फराकिलो दृश्य प्रदान गर्दछ, जसले प्रकृतिको प्रशंसालाई बढावा दिन्छ।'
}
];
export const PROVINCES: ProvinceData[] = [
{
id: 1,
name: "Koshi Province",
nepaliName: "कोशी प्रदेश",
capital: "Biratnagar",
capitalNe: "विराटनगर",
districts: 14,
area: "25,905 km²",
population: "4,972,021",
density: "192/km²",
color: "from-blue-600 to-cyan-500",
borderColor: "border-blue-500",
description: "Koshi Province is the easternmost region of Nepal, home to the world's highest peak, Mount Everest (8848m), and Kanchenjunga. It features diverse topography ranging from the Himalayas to the Terai plains.",
descriptionNe: "कोशी प्रदेश नेपालको सबैभन्दा पूर्वी क्षेत्र हो, जहाँ विश्वको सर्वोच्च शिखर सगरमाथा (८८४८ मिटर) र कञ्चनजङ्घा रहेका छन्।",
attractions: ["Mt. Everest", "Ilam Tea Gardens", "Koshi Tappu"],
attractionsNe: ["सगरमाथा", "इलाम चिया बगान", "कोशी टप्पु"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwerHQlJuN4y6uGM7CNrGK7ZUKJS2nNpqsZDaYnsbybQU-ifpLuEixZHiUk_xjhyVFWZTi4tePP5270Hs1plo7ogPt7FU9limroXpkWhe5EuZsrQG5eFRZ5qntE7tHwBBkzp3wayUWg=w675-h390-n-k-no",
majorRivers: "Koshi, Arun",
majorRiversNe: "कोशी, अरुण",
literacyRate: "71.2%",
mainLanguages: "Nepali, Maithili",
mainLanguagesNe: "नेपाली, मैथिली",
lat: 27.2000,
lng: 87.2000
},
{
id: 2,
name: "Madhesh Province",
nepaliName: "मधेश प्रदेश",
capital: "Janakpur",
capitalNe: "जनकपुर",
districts: 8,
area: "9,661 km²",
population: "6,126,288",
density: "635/km²",
color: "from-orange-500 to-yellow-500",
borderColor: "border-orange-500",
description: "The smallest province by area but rich in culture and history. It is the heart of Mithila culture and agriculture.",
descriptionNe: "क्षेत्रफलको हिसाबले सबैभन्दा सानो तर संस्कृति र इतिहासमा धनी प्रदेश। यो मिथिला संस्कृतिको मुटु हो।",
attractions: ["Janaki Mandir", "Mithila Art", "Parsa National Park"],
attractionsNe: ["जानकी मन्दिर", "मिथिला कला", "पर्सा राष्ट्रिय निकुञ्ज"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAweo75bHM1SIYpxf2VNcZJ-svV5H5cMa_A-t80RRtkmqVB1jIbThYeNcJ0117uTZvThVmFlRkPoqEYlm0H3zQk-ZadZbCSLsruwQU2_GdqMlZR4Oj2W_bW1dTvWCnEKJXPiQWtoQ=w675-h390-n-k-no",
majorRivers: "Bagmati, Kamala",
majorRiversNe: "बागमती, कमला",
literacyRate: "49.5%",
mainLanguages: "Maithili, Bhojpuri",
mainLanguagesNe: "मैथिली, भोजपुरी",
lat: 26.8000,
lng: 85.8000
},
{
id: 3,
name: "Bagmati Province",
nepaliName: "बागमती प्रदेश",
capital: "Hetauda",
capitalNe: "हेटौंडा",
districts: 13,
area: "20,300 km²",
population: "6,084,042",
density: "300/km²",
color: "from-red-600 to-pink-600",
borderColor: "border-red-600",
description: "Home to the federal capital, Kathmandu. It bridges the northern Himalayas with the southern plains and hosts major UNESCO Heritage sites.",
descriptionNe: "संघीय राजधानी काठमाडौंको घर। यसले उत्तरी हिमाललाई दक्षिणी मैदानसँग जोड्छ र प्रमुख युनेस्को सम्पदा स्थलहरू समावेश गर्दछ।",
attractions: ["Kathmandu Valley", "Chitwan National Park", "Langtang"],
attractionsNe: ["काठमाडौं उपत्यका", "चितवन राष्ट्रिय निकुञ्ज", "लाङटाङ"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwepmdUwyFb1HIu4u53bB3TzJv32oVXMmcSSgCSOa-tP5FibBJt0mvV3TMmXm6z_rkLvDfAUZqkLbcOZbNtOSPqeLfddmVXhPb1tkUYqxrOI7We1Q6ZpG-WKdQoAORTBWNhjPEcJs=w675-h390-n-k-no",
majorRivers: "Bagmati, Trishuli",
majorRiversNe: "बागमती, त्रिशुली",
literacyRate: "74.8%",
mainLanguages: "Nepali, Newari",
mainLanguagesNe: "नेपाली, नेवारी",
lat: 27.7000,
lng: 85.3333
},
{
id: 4,
name: "Gandaki Province",
nepaliName: "गण्डकी प्रदेश",
capital: "Pokhara",
capitalNe: "पोखरा",
districts: 11,
area: "21,504 km²",
population: "2,479,745",
density: "116/km²",
color: "from-emerald-500 to-green-600",
borderColor: "border-emerald-500",
description: "The tourism capital of Nepal. Gandaki province houses the majestic Annapurna range and is the gateway to world-famous treks.",
descriptionNe: "नेपालको पर्यटन राजधानी। गण्डकी प्रदेशमा अन्नपूर्ण हिमशृङ्खला रहेको छ र यो पदयात्राको प्रवेशद्वार हो।",
attractions: ["Pokhara", "Annapurna Circuit", "Muktinath"],
attractionsNe: ["पोखरा", "अन्नपूर्ण सर्किट", "मुक्तिनाथ"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwerE0IehvUGkr-ri0Y2JqJShK2aUrKraupRmfZ72Odnx1pg9Cwnpw2QpOdWiDmNBvwo6mloNYK7wwRqcZT1UyCmBSOkQHNuEwyCVi4aJv4_Myl8yXls6Am8abAOjnviRGBJ4Pnhn=w675-h390-n-k-no",
majorRivers: "Kali Gandaki, Seti",
majorRiversNe: "काली गण्डकी, सेती",
literacyRate: "74.8%",
mainLanguages: "Nepali, Gurung",
mainLanguagesNe: "नेपाली, गुरुङ",
lat: 28.2380,
lng: 83.9956
},
{
id: 5,
name: "Lumbini Province",
nepaliName: "लुम्बिनी प्रदेश",
capital: "Deukhuri",
capitalNe: "देउखुरी",
districts: 12,
area: "22,288 km²",
population: "5,124,225",
density: "230/km²",
color: "from-indigo-500 to-blue-600",
borderColor: "border-indigo-500",
description: "The birthplace of Lord Buddha. Lumbini province holds immense historical and spiritual significance.",
descriptionNe: "भगवान बुद्धको जन्मस्थल। लुम्बिनी प्रदेशको ठूलो ऐतिहासिक र आध्यात्मिक महत्व छ।",
attractions: ["Lumbini", "Bardiya National Park", "Palpa"],
attractionsNe: ["लुम्बिनी", "बर्दिया राष्ट्रिय निकुञ्ज", "पाल्पा"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAweolNbMBfCLEcb-JoP1BKkohrqTtq1WsIQGDfBApeCx_ZwqiyMM1IwwUGB6-72BPDxuA7XB5dRAjYO_kBbgFOagQ4Pmtib72cDKbVuFLT-BsO7YGwhuRrLM1EInLL1HF49XR4dZ-Eg=w675-h390-n-k-no",
majorRivers: "Rapti, Babai",
majorRiversNe: "राप्ती, बबई",
literacyRate: "66.4%",
mainLanguages: "Nepali, Tharu",
mainLanguagesNe: "नेपाली, थारु",
lat: 27.8000,
lng: 83.0000
},
{
id: 6,
name: "Karnali Province",
nepaliName: "कर्णाली प्रदेश",
capital: "Birendranagar",
capitalNe: "वीरेन्द्रनगर",
districts: 10,
area: "27,984 km²",
population: "1,694,889",
density: "61/km²",
color: "from-teal-600 to-emerald-700",
borderColor: "border-teal-600",
description: "The largest yet least populated province. Karnali is a remote, rugged, and breathtakingly beautiful region home to Rara Lake.",
descriptionNe: "सबैभन्दा ठूलो तर कम जनसंख्या भएको प्रदेश। कर्णाली एक दुर्गम र सुन्दर क्षेत्र हो जहाँ रारा ताल अवस्थित छ।",
attractions: ["Rara Lake", "Shey Phoksundo", "Upper Dolpo"],
attractionsNe: ["रारा ताल", "शे-फोक्सुण्डो", "माथिल्लो डोल्पा"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAwerD0R7t4jLghiYVAR2QcK15I2yM0SSWbSFlLVu2WRHDPFwNwxu72zabWeuY3vci3z0Kfa0B2CE9O5gDVlbSomlE8v06Hs6g4-VQ81SD_3SvW7U2sKs_m0oXmcyMYYEKqaIHASdAaA=w675-h390-n-k-no",
majorRivers: "Karnali, Bheri",
majorRiversNe: "कर्णाली, भेरी",
literacyRate: "62.7%",
mainLanguages: "Nepali, Magar",
mainLanguagesNe: "नेपाली, मगर",
lat: 29.2000,
lng: 82.2000
},
{
id: 7,
name: "Sudurpashchim Province",
nepaliName: "सुदूरपश्चिम प्रदेश",
capital: "Godawari",
capitalNe: "गोदावरी",
districts: 9,
area: "19,999 km²",
population: "2,711,270",
density: "136/km²",
color: "from-purple-600 to-violet-600",
borderColor: "border-purple-600",
description: "Located in the far west, this province is rich in unspoiled natural beauty and unique Deuda culture.",
descriptionNe: "सुदूर पश्चिममा अवस्थित, यो प्रदेश अछुतो प्राकृतिक सौन्दर्य र देउडा संस्कृतिमा धनी छ।",
attractions: ["Khaptad", "Shuklaphanta", "Api Nampa"],
attractionsNe: ["खप्तड", "शुक्लाफाँटा", "अपि नाम्पा"],
image: "https://lh3.googleusercontent.com/gps-cs-s/AHVAweqa0Vgu8fP66KID9-7yelqNiZ4ivnejIRzctBevCwH43INkW4-IjlVZWpEBwv_yh1LrmiGyRFsuKheyKoPY0JEmX8u8GnrzCnrrwSTHwrCiiwL6CzYP5nH3Jc-4r0WmlBNXg2FH=w675-h390-n-k-no",
majorRivers: "Mahakali, Seti",
majorRiversNe: "महाकाली, सेती",
literacyRate: "63.5%",
mainLanguages: "Doteli, Tharu",
mainLanguagesNe: "डोटेली, थारु",
lat: 29.4000,
lng: 80.8000
}
];

208
games/ArcadeLeaderboard.tsx Normal file
View File

@ -0,0 +1,208 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, Trophy, Coins, Brain, Sparkles, Loader2, RefreshCw, User, Crown, BarChart3, Zap, TrendingUp, Hexagon } from 'lucide-react';
import { StorageService } from '../services/storageService';
import { UserProfile } from '../types';
interface ArcadeLeaderboardProps {
onExit: () => void;
}
export const ArcadeLeaderboard: React.FC<ArcadeLeaderboardProps> = ({ onExit }) => {
const [leaders, setLeaders] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(true);
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
const [activeTab, setActiveTab] = useState<'points' | 'speed' | 'memory' | 'danphe' | 'flexibility' | 'truth' | 'mandala'>('points');
const fetchData = async () => {
setLoading(true);
try {
const [allUsers, profile] = await Promise.all([
StorageService.getLeaderboard(50, activeTab as any),
StorageService.getProfile()
]);
setLeaders(allUsers || []); // Ensure array
setCurrentUser(profile);
} catch (e) {
console.error("Failed to fetch leaderboard", e);
setLeaders([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [activeTab]);
const getRankStyle = (index: number) => {
switch(index) {
case 0: return "bg-yellow-500/20 border-yellow-500 text-yellow-400 shadow-[0_0_20px_rgba(234,179,8,0.4)] ring-1 ring-yellow-500/50"; // Gold
case 1: return "bg-slate-400/20 border-slate-400 text-slate-300 shadow-[0_0_15px_rgba(148,163,184,0.3)]"; // Silver
case 2: return "bg-orange-500/20 border-orange-500 text-orange-400 shadow-[0_0_15px_rgba(249,115,22,0.3)]"; // Bronze
default: return "bg-gray-800/50 border-gray-700 text-gray-400";
}
};
const getScoreDisplay = (user: UserProfile) => {
if (activeTab === 'points') return user.points || 0;
return (user.highScores as any)?.[activeTab] || 0;
};
return (
<div className="absolute inset-0 z-40 bg-[#0f172a] flex flex-col overflow-hidden font-sans">
{/* Background Ambience */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-indigo-600/10 rounded-full blur-[100px]"></div>
<div className="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-purple-600/10 rounded-full blur-[100px]"></div>
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.05]"></div>
</div>
{/* Header */}
<div className="relative z-10 flex items-center justify-between p-6 md:px-10 bg-gray-900/60 border-b border-white/5 backdrop-blur-xl shrink-0">
{/* Back Button on Left with margin */}
<Button variant="ghost" onClick={onExit} className="ml-4 hover:bg-white/10 text-white/80 hover:text-white border border-white/10 rounded-xl px-6 py-2 transition-all font-black uppercase text-xs tracking-widest flex-shrink-0">
<ArrowLeft size={16} className="mr-2"/> Back
</Button>
{/* Title Centered but slightly right */}
<div className="flex-1 flex justify-center pl-10">
<div className="text-center">
<h1 className="text-2xl md:text-4xl font-black italic tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400 flex items-center justify-center gap-3 drop-shadow-md">
<BarChart3 size={32} className="text-yellow-500" />
RANKINGS
</h1>
<p className="text-xs text-white/40 uppercase tracking-widest font-bold ml-1">Global Hall of Fame</p>
</div>
</div>
<div className="flex gap-4">
<Button variant="ghost" onClick={fetchData} className="text-gray-400 hover:text-white hover:bg-white/5 p-3 rounded-full transition-all border border-transparent hover:border-white/10" title="Refresh">
<RefreshCw size={20} className={loading ? 'animate-spin' : ''}/>
</Button>
</div>
</div>
{/* Content */}
<div className="relative z-10 flex-1 overflow-y-auto p-4 md:p-8 custom-scrollbar">
<div className="max-w-6xl mx-auto space-y-8">
{/* Tabs */}
<div className="flex justify-center md:justify-start overflow-x-auto pb-4 scrollbar-none">
<div className="flex gap-2 p-1 bg-gray-900/50 backdrop-blur-md rounded-2xl border border-white/5 shadow-xl">
{[
{ id: 'points', label: 'Global Karma', icon: Coins, color: 'text-yellow-400' },
{ id: 'speed', label: 'Speed Zone', icon: Zap, color: 'text-red-400' },
{ id: 'memory', label: 'Memory Shore', icon: Brain, color: 'text-cyan-400' },
{ id: 'danphe', label: 'Danphe Rush', icon: TrendingUp, color: 'text-emerald-400' },
{ id: 'flexibility', label: 'Mental Agility', icon: RefreshCw, color: 'text-pink-400' },
{ id: 'truth', label: 'Logic Fuses', icon: Sparkles, color: 'text-amber-400' },
{ id: 'mandala', label: 'Mandala Mind', icon: Hexagon, color: 'text-purple-400' },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`
relative px-5 py-3 rounded-xl font-bold flex items-center gap-2 transition-all whitespace-nowrap text-sm
${activeTab === tab.id
? 'bg-gray-800 text-white shadow-lg ring-1 ring-white/10'
: 'text-gray-500 hover:text-gray-300 hover:bg-white/5'}
`}
>
{/* @ts-ignore */}
<tab.icon size={16} className={activeTab === tab.id ? tab.color : 'opacity-50'}/>
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1/3 h-1 bg-white/20 rounded-t-full"></span>
)}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex flex-col justify-center items-center h-64 gap-4 opacity-70">
<Loader2 className="animate-spin text-white w-12 h-12" />
<p className="text-white/50 text-sm uppercase tracking-widest font-bold">Syncing Records...</p>
</div>
) : (
<div className="bg-gray-900/40 border border-white/5 rounded-3xl overflow-hidden backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-bottom-8 duration-500">
<div className="grid grid-cols-12 gap-4 p-4 bg-black/20 text-gray-400 text-xs uppercase font-bold tracking-wider border-b border-white/5">
<div className="col-span-2 md:col-span-1 text-center">Rank</div>
<div className="col-span-7 md:col-span-6">Player</div>
<div className="hidden md:col-span-2 md:block text-center">Role</div>
<div className="col-span-3 md:col-span-3 text-right pr-4">Score</div>
</div>
<div className="divide-y divide-white/5">
{leaders.length === 0 && (
<div className="p-16 text-center text-gray-500 flex flex-col items-center">
<Trophy size={48} className="mb-4 opacity-20" />
<p className="font-bold text-lg">No records found yet.</p>
<p className="text-sm mt-1 opacity-60">Be the first to claim the throne!</p>
</div>
)}
{leaders.map((user, index) => {
const isMe = currentUser?.id === user.id;
return (
<div
key={user.id}
className={`
grid grid-cols-12 gap-4 items-center p-4 transition-all duration-200 group
${isMe ? 'bg-indigo-600/10 border-l-4 border-indigo-500' : 'hover:bg-white/5 border-l-4 border-transparent'}
`}
>
{/* Rank */}
<div className="col-span-2 md:col-span-1 flex justify-center">
<div className={`
w-10 h-10 flex items-center justify-center rounded-full font-black text-sm border-2 shadow-inner
${getRankStyle(index)}
`}>
{index < 3 ? <Crown size={14} className="mr-0.5"/> : '#'}
{index + 1}
</div>
</div>
{/* Player */}
<div className="col-span-7 md:col-span-6 flex items-center gap-4">
<div className="relative">
<img
src={user.avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`}
className={`w-12 h-12 rounded-2xl object-cover bg-gray-800 shadow-md ${index === 0 ? 'ring-2 ring-yellow-500 ring-offset-2 ring-offset-gray-900' : ''}`}
alt={user.name}
/>
{isMe && <div className="absolute -top-1 -right-1 bg-indigo-500 text-white rounded-full p-1 border border-gray-900"><User size={8}/></div>}
</div>
<div className="min-w-0">
<p className={`font-bold text-base truncate ${isMe ? 'text-indigo-400' : 'text-gray-100 group-hover:text-white'}`}>
{user.name}
</p>
{isMe && <p className="text-[10px] font-bold text-indigo-500/80 uppercase tracking-wider">You</p>}
</div>
</div>
{/* Role */}
<div className="hidden md:col-span-2 md:flex justify-center">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-white/5 text-gray-400 border border-white/5 uppercase tracking-wide">
{user.role}
</span>
</div>
{/* Score */}
<div className="col-span-3 md:col-span-3 text-right pr-4">
<span className={`font-mono font-black text-xl md:text-2xl ${index === 0 ? 'text-yellow-400 drop-shadow-sm' : 'text-gray-200'}`}>
{getScoreDisplay(user).toLocaleString()}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
};

266
games/AttentionTracks.tsx Normal file
View File

@ -0,0 +1,266 @@
/**
* NEURAL RAILS: SINGULARITY EDITION
* ------------------------------------------------
* Architecture: Dual-Pass Neon Lighting / Director AI Scaling
* Features: Light Cones / Neural Grid Background / Weather Physics
*/
import React, { useState, useEffect, useRef } from 'react';
import {
ArrowLeft, GitFork, AlertTriangle, Zap, Clock,
Activity, Brain, CloudRain, Snowflake, Sun
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// --- TYPES ---
type TrainColor = 'red' | 'blue' | 'green';
type Weather = 'CLEAR' | 'RAIN' | 'STORM';
interface Vec2 { x: number; y: number; }
interface Train {
id: number;
color: TrainColor;
x: number; y: number;
t: number;
targetIdx: number;
state: 'APPROACH' | 'CROSSING';
speed: number;
angle: number;
wagons: Vec2[];
}
export const AttentionTracks: React.FC<{ onExit: () => void }> = ({ onExit }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [status, setStatus] = useState<'START' | 'PLAYING' | 'OVER'>('START');
const [score, setScore] = useState(0);
const [lives, setLives] = useState(3);
const [weather, setWeather] = useState<Weather>('CLEAR');
// High-Performance Refs
const trains = useRef<Train[]>([]);
const switchState = useRef(1); // 0: Top, 1: Mid, 2: Bot
const lastSpawn = useRef(0);
const intensity = useRef(1.0);
const frame = useRef(0);
// Constants
const WIDTH = 1200;
const HEIGHT = 800;
const JUNCTION = { x: 400, y: 400 };
const STATIONS = [
{ color: 'red' as TrainColor, pos: { x: 1100, y: 150 } },
{ color: 'blue' as TrainColor, pos: { x: 1100, y: 400 } },
{ color: 'green' as TrainColor, pos: { x: 1100, y: 650 } }
];
// --- PHYSICS ENGINE ---
const update = (time: number) => {
if (status !== 'PLAYING') return;
// Director AI Scaling
intensity.current = 1.0 + (score / 10000);
const spawnRate = Math.max(1200, 3500 - (score / 5));
if (time - lastSpawn.current > spawnRate) {
spawnTrain();
lastSpawn.current = time;
}
// Weather Randomizer
if (frame.current % 1200 === 0 && Math.random() > 0.8) {
const modes: Weather[] = ['CLEAR', 'RAIN', 'STORM'];
setWeather(modes[Math.floor(Math.random() * 3)]);
}
trains.current.forEach(t => {
const prev = { x: t.x, y: t.y };
const moveSpeed = weather === 'RAIN' ? t.speed * 1.3 : t.speed;
if (t.state === 'APPROACH') {
t.x += 4 * intensity.current;
if (t.x >= JUNCTION.x) {
t.state = 'CROSSING';
t.targetIdx = switchState.current;
}
} else {
t.t += moveSpeed * intensity.current;
const p0 = JUNCTION;
const p3 = STATIONS[t.targetIdx].pos;
const p1 = { x: p0.x + 300, y: p0.y };
const p2 = { x: p3.x - 300, y: p3.y };
// Cubic Bezier
const it = 1 - t.t;
t.x = it**3 * p0.x + 3*it**2 * t.t * p1.x + 3*it * t.t**2 * p2.x + t.t**3 * p3.x;
t.y = it**3 * p0.y + 3*it**2 * t.t * p1.y + 3*it * t.t**2 * p2.y + t.t**3 * p3.y;
if (t.t >= 1) handleArrival(t);
}
t.angle = Math.atan2(t.y - prev.y, t.x - prev.x);
// Wagon Constraint Physics
t.wagons.forEach((w, i) => {
const lead = i === 0 ? {x: t.x, y: t.y} : t.wagons[i-1];
const d = Math.hypot(lead.x - w.x, lead.y - w.y);
if (d > 45) {
const a = Math.atan2(lead.y - w.y, lead.x - w.x);
w.x = lead.x - Math.cos(a) * 45;
w.y = lead.y - Math.sin(a) * 45;
}
});
});
trains.current = trains.current.filter(t => t.t < 1 && t.x < WIDTH + 50);
frame.current++;
};
const handleArrival = (t: Train) => {
if (STATIONS[t.targetIdx].color === t.color) {
setScore(s => s + 100);
} else {
setLives(l => {
if (l <= 1) setStatus('OVER');
return l - 1;
});
}
};
const spawnTrain = () => {
const colors: TrainColor[] = ['red', 'blue', 'green'];
trains.current.push({
id: Math.random(), color: colors[Math.floor(Math.random()*3)],
x: -50, y: 400, t: 0, targetIdx: 0, state: 'APPROACH',
speed: 0.006, angle: 0, wagons: Array(3).fill(0).map(() => ({x: -50, y: 400}))
});
};
// --- RENDERING ENGINE ---
const draw = (ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = '#020617';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// 1. Neural Grid Background
ctx.strokeStyle = '#1e3a8a33';
ctx.lineWidth = 1;
for(let i=0; i<WIDTH; i+=60) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, HEIGHT); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(WIDTH, i); ctx.stroke();
}
// 2. Light Cone Pass (Additive)
ctx.globalCompositeOperation = 'lighter';
trains.current.forEach(t => {
const color = t.color === 'red' ? '#ef4444' : t.color === 'blue' ? '#3b82f6' : '#22c55e';
const g = ctx.createRadialGradient(t.x, t.y, 0, t.x, t.y, 250);
g.addColorStop(0, color + '44');
g.addColorStop(1, 'transparent');
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.arc(t.x, t.y, 250, t.angle - 0.5, t.angle + 0.5);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
// 3. Tracks & Nodes
STATIONS.forEach((st, i) => {
ctx.strokeStyle = switchState.current === i ? '#fbbf24' : '#1e293b';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.moveTo(JUNCTION.x, JUNCTION.y);
ctx.bezierCurveTo(JUNCTION.x+300, JUNCTION.y, st.pos.x-300, st.pos.y, st.pos.x, st.pos.y);
ctx.stroke();
});
// 4. Trains & Wagons
trains.current.forEach(t => {
const color = t.color === 'red' ? '#ef4444' : t.color === 'blue' ? '#3b82f6' : '#22c55e';
t.wagons.forEach(w => {
ctx.fillStyle = '#0f172a'; ctx.strokeStyle = color;
ctx.beginPath(); ctx.arc(w.x, w.y, 12, 0, Math.PI*2); ctx.fill(); ctx.stroke();
});
ctx.fillStyle = color;
ctx.shadowBlur = 20; ctx.shadowColor = color;
ctx.beginPath(); ctx.arc(t.x, t.y, 18, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
});
};
useEffect(() => {
const loop = (t: number) => {
const ctx = canvasRef.current?.getContext('2d');
if (ctx) { update(t); draw(ctx); }
requestAnimationFrame(loop);
};
const anim = requestAnimationFrame(loop);
return () => cancelAnimationFrame(anim);
}, [status, weather]);
return (
<div className="fixed inset-0 bg-black flex flex-col items-center justify-center select-none overflow-hidden font-sans">
{/* HUD SINGULARITY */}
<AnimatePresence>
{status === 'PLAYING' && (
<motion.div initial={{ y: -50 }} animate={{ y: 0 }} className="absolute top-10 left-10 right-10 flex justify-between z-50 pointer-events-none">
<div className="flex gap-4 pointer-events-auto">
<div className="bg-slate-900/90 px-8 py-4 rounded-3xl border border-white/10 backdrop-blur-xl">
<span className="text-[10px] font-bold text-blue-400 uppercase tracking-[0.2em]">Quantum Score</span>
<div className="text-4xl font-mono font-black text-white">{score}</div>
</div>
<div className="bg-slate-900/90 p-4 rounded-3xl border border-white/10 flex items-center gap-3 text-white">
<Activity size={20} className="text-red-500 animate-pulse"/>
<span className="font-mono">LOAD: {(intensity.current * 100).toFixed(0)}%</span>
</div>
</div>
<div className="flex items-center gap-6 pointer-events-auto">
<div className="flex flex-col items-end gap-2">
<div className="flex gap-2">
{[...Array(3)].map((_, i) => <div key={i} className={`w-3 h-3 rounded-full ${i < lives ? 'bg-red-500 shadow-[0_0_15px_red]' : 'bg-white/10'}`}/>)}
</div>
<div className="flex items-center gap-2 text-white/50 text-[10px] font-black uppercase tracking-widest">
{weather === 'RAIN' ? <CloudRain size={14}/> : weather === 'STORM' ? <Zap size={14}/> : <Sun size={14}/>} {weather}
</div>
</div>
<button onClick={onExit} className="bg-white/5 p-4 rounded-2xl hover:bg-white/10 text-white"><ArrowLeft/></button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* GAME VIEWPORT */}
<div className="relative w-full max-w-6xl aspect-video rounded-[3rem] border-[10px] border-slate-900 overflow-hidden shadow-[0_0_100px_rgba(0,0,0,0.5)]">
<canvas
ref={canvasRef}
width={WIDTH} height={HEIGHT}
onClick={() => { if(status === 'PLAYING') switchState.current = (switchState.current + 1) % 3; }}
className="w-full h-full cursor-crosshair"
/>
{status === 'START' && (
<div className="absolute inset-0 bg-slate-950/95 flex flex-col items-center justify-center text-center p-20 backdrop-blur-xl">
<Brain size={100} className="text-blue-500 mb-8 animate-pulse" />
<h1 className="text-9xl font-black italic uppercase text-white mb-4 tracking-tighter">Neural<br/><span className="text-blue-600">Rails</span></h1>
<p className="text-slate-400 text-xl max-w-xl mb-16 italic">Synchronize with the Director AI. Manage the quantum sorting flow.</p>
<button onClick={() => setStatus('PLAYING')} className="bg-blue-600 px-24 py-10 rounded-full text-4xl font-black text-white shadow-2xl hover:scale-105 active:scale-95 transition-all">INITIALIZE</button>
</div>
)}
{status === 'OVER' && (
<div className="absolute inset-0 bg-red-950/95 flex flex-col items-center justify-center p-20 text-center backdrop-blur-3xl">
<AlertTriangle size={120} className="text-red-500 mb-8"/>
<h2 className="text-8xl font-black text-white italic uppercase mb-12 leading-none">Neural<br/>Collapse</h2>
<div className="bg-black/50 px-16 py-8 rounded-[2rem] border border-white/10 mb-12">
<p className="text-7xl font-mono font-black text-yellow-500">{score}</p>
</div>
<button onClick={() => window.location.reload()} className="bg-white text-black px-16 py-8 rounded-full text-2xl font-black hover:scale-110 transition-all">REBOOT SYSTEM</button>
</div>
)}
</div>
<div className="mt-8 text-white/20 font-black uppercase tracking-[0.5em] text-[10px]">Singularity Protocol v4.0.2</div>
</div>
);
};

43
games/Chess.tsx Normal file
View File

@ -0,0 +1,43 @@
import React from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, Crown, Construction } from 'lucide-react';
interface GameProps {
onExit: () => void;
}
export const Chess: React.FC<GameProps> = ({ onExit }) => {
return (
<div className="fixed inset-0 z-50 bg-[#0f172a] flex flex-col items-center justify-center select-none overflow-hidden font-sans">
<div className="absolute top-6 left-6 z-50">
<button
onClick={onExit}
className="flex items-center gap-2 px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all font-black text-xs tracking-widest shadow-2xl"
>
<ArrowLeft size={18} /> EXIT
</button>
</div>
<div className="relative z-10 p-12 text-center max-w-2xl animate-in zoom-in duration-700">
<div className="w-32 h-32 bg-white/10 rounded-[2.5rem] flex items-center justify-center text-white mb-10 border border-white/20 mx-auto shadow-[0_0_60px_rgba(255,255,255,0.1)]">
<Crown size={64} />
</div>
<h2 className="text-6xl md:text-8xl font-black italic tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-white to-gray-400 mb-6 uppercase drop-shadow-2xl">
ROYAL CHESS
</h2>
<div className="bg-white/5 backdrop-blur-xl border border-white/10 px-8 py-4 rounded-full inline-flex items-center gap-3 mb-8">
<Construction size={18} className="text-blue-400"/>
<span className="text-sm font-black uppercase tracking-[0.2em] text-gray-300">Strategy Module Loading</span>
</div>
<p className="text-gray-400 text-xl font-medium leading-relaxed max-w-lg mx-auto">
Grandmaster logic core is compiling. Prepare for the ultimate tactical showdown.
</p>
</div>
</div>
);
};

634
games/DanpheRush.tsx Normal file
View File

@ -0,0 +1,634 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, RefreshCw, Volume2, VolumeX, Trophy, Play, Zap, Shield } from 'lucide-react';
import { StorageService } from '../services/storageService';
interface GameProps {
onExit: () => void;
}
export const DanpheRush: React.FC<GameProps> = ({ onExit }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const [gameState, setGameState] = useState<'READY' | 'PLAYING' | 'GAMEOVER'>('READY');
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [level, setLevel] = useState(1);
const [audioEnabled, setAudioEnabled] = useState(true);
// Boost State
const [boostUnlocked, setBoostUnlocked] = useState(false);
const [boostCharge, setBoostCharge] = useState(0); // 0 to 3
const [isBoosting, setIsBoosting] = useState(false);
// Constants
const GRAVITY = 0.4;
const JUMP_STRENGTH = -7.5;
const BOOST_STRENGTH = -12;
const BASE_SPEED = 3.5;
const PIPE_WIDTH = 70;
const PIPE_GAP = 170;
const LEVEL_THRESHOLD = 10; // Points per level
const PIPES_FOR_BOOST = 3;
const SPAWN_RATE = 120;
const physics = useRef({
birdY: 200,
velocity: 0,
rotation: 0,
pipes: [] as { x: number; topHeight: number; passed: boolean; broken?: boolean }[],
clouds: [] as { x: number; y: number; scale: number; speed: number }[],
particles: [] as { x: number; y: number; vx: number; vy: number; life: number; color: string }[],
frame: 0,
flashOpacity: 0,
invincibleTimer: 0,
speed: BASE_SPEED,
pipesSinceBoost: 0
});
const requestRef = useRef<number>(0);
useEffect(() => {
const loadData = async () => {
const profile = await StorageService.getProfile();
if (profile?.highScores?.danphe) setHighScore(profile.highScores.danphe);
};
loadData();
initClouds();
// Global Key Listener
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault(); // Prevent scrolling
flap();
}
if (e.code === 'KeyE') {
activateBoost();
}
};
window.addEventListener('keydown', handleKeyDown);
// Start loop
requestRef.current = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(requestRef.current);
window.removeEventListener('keydown', handleKeyDown);
if (audioCtxRef.current) {
audioCtxRef.current.close().catch(() => {});
}
};
}, [gameState, boostUnlocked, boostCharge]); // Dependencies for key listener state access
const initAudio = () => {
if (!audioCtxRef.current) {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (AudioContext) {
audioCtxRef.current = new AudioContext();
}
}
if (audioCtxRef.current && audioCtxRef.current.state === 'suspended') {
audioCtxRef.current.resume().catch(() => {});
}
};
const initClouds = () => {
physics.current.clouds = Array.from({ length: 6 }, () => ({
x: Math.random() * 800,
y: Math.random() * 300,
scale: 0.5 + Math.random() * 0.5,
speed: 0.5 + Math.random() * 0.5
}));
};
const playSound = (type: 'flap' | 'score' | 'hit' | 'boost') => {
if (!audioEnabled) return;
// Ensure context exists
if (!audioCtxRef.current) initAudio();
const ctx = audioCtxRef.current;
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
const now = ctx.currentTime;
if (type === 'flap') {
// Snappy Jump Sound
osc.frequency.setValueAtTime(150, now);
osc.frequency.linearRampToValueAtTime(300, now + 0.1);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
osc.start();
osc.stop(now + 0.1);
} else if (type === 'score') {
osc.type = 'square';
osc.frequency.setValueAtTime(800, now);
osc.frequency.setValueAtTime(1200, now + 0.05);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
osc.start();
osc.stop(now + 0.1);
} else if (type === 'hit') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(150, now);
osc.frequency.linearRampToValueAtTime(100, now + 0.3);
gain.gain.setValueAtTime(0.3, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.3);
osc.start();
osc.stop(now + 0.3);
} else if (type === 'boost') {
osc.type = 'sine';
osc.frequency.setValueAtTime(200, now);
osc.frequency.linearRampToValueAtTime(800, now + 0.5);
gain.gain.setValueAtTime(0.3, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.5);
osc.start();
osc.stop(now + 0.5);
}
};
const resetGame = () => {
setGameState('READY');
setScore(0);
setLevel(1);
setBoostUnlocked(false);
setBoostCharge(0);
setIsBoosting(false);
physics.current = {
birdY: 250,
velocity: 0,
rotation: 0,
pipes: [],
clouds: physics.current.clouds,
particles: [],
frame: 0,
flashOpacity: 0,
invincibleTimer: 0,
speed: BASE_SPEED,
pipesSinceBoost: 0
};
};
const flap = () => {
// Resume audio context on user interaction
initAudio();
if (gameState === 'GAMEOVER') return;
if (gameState === 'READY') {
setGameState('PLAYING');
}
physics.current.velocity = JUMP_STRENGTH;
spawnParticles(100, physics.current.birdY, 'white');
playSound('flap');
};
const activateBoost = () => {
// Resume audio context
initAudio();
const P = physics.current;
if (gameState !== 'PLAYING') return;
const currentLevel = Math.floor(score / LEVEL_THRESHOLD) + 1;
// Boost is ready if unlocked (Lvl 3+) AND charge is full
const canBoost = currentLevel > 3 && P.pipesSinceBoost >= PIPES_FOR_BOOST;
if (canBoost) {
P.velocity = BOOST_STRENGTH;
P.invincibleTimer = 60; // 1 second (at 60fps)
P.pipesSinceBoost = 0; // Reset charge
setBoostCharge(0);
setIsBoosting(true);
playSound('boost');
// Explosion effect
for(let i=0; i<20; i++) {
physics.current.particles.push({
x: 100, y: P.birdY,
vx: (Math.random() - 0.5) * 10,
vy: (Math.random() - 0.5) * 10,
life: 1.0,
color: '#facc15'
});
}
}
};
const spawnParticles = (x: number, y: number, color: string) => {
for(let i=0; i<5; i++) {
physics.current.particles.push({
x, y,
vx: (Math.random() - 0.5) * 3 - 2, // Move left mostly
vy: (Math.random() - 0.5) * 3,
life: 1.0,
color
});
}
};
const gameOverLogic = () => {
if (gameState === 'GAMEOVER') return;
setGameState('GAMEOVER');
playSound('hit');
physics.current.flashOpacity = 1.0;
if (score > highScore) {
setHighScore(score);
StorageService.saveHighScore('danphe', score);
}
// Updated addPoints
StorageService.addPoints(score, score * 5, 'game_reward', 'Danphe Rush Score');
};
const loop = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
const P = physics.current;
// --- LOGIC ---
// Level & Unlock Logic
const currentLvl = Math.floor(score / LEVEL_THRESHOLD) + 1;
if (currentLvl !== level) setLevel(currentLvl);
const isUnlocked = currentLvl > 3;
if (isUnlocked !== boostUnlocked) setBoostUnlocked(isUnlocked);
// Sync React state for UI (limited updates to prevent lag)
if (isUnlocked) {
// Clamp charge visual
const charge = Math.min(PIPES_FOR_BOOST, P.pipesSinceBoost);
if (charge !== boostCharge) setBoostCharge(charge);
}
if (isBoosting && P.invincibleTimer <= 0) setIsBoosting(false);
// Difficulty Scaling
P.speed = BASE_SPEED + (currentLvl * 0.2);
// Background movement (Clouds)
if (gameState !== 'GAMEOVER') {
P.clouds.forEach(c => {
c.x -= c.speed * (gameState === 'PLAYING' ? 1 : 0.5);
if (c.x < -100) c.x = WIDTH + 100;
});
}
if (gameState === 'PLAYING') {
P.frame++;
if (P.invincibleTimer > 0) P.invincibleTimer--;
// Physics
P.velocity += GRAVITY;
P.birdY += P.velocity;
// Rotation logic
if (P.velocity < 0) {
P.rotation = Math.max(-0.5, P.rotation - 0.1);
} else {
P.rotation = Math.min(Math.PI / 2, P.rotation + 0.05);
}
// Pipe Spawning
if (P.frame % SPAWN_RATE === 0) {
const minHeight = 100;
const maxHeight = HEIGHT - 150 - PIPE_GAP;
const height = Math.floor(Math.random() * (maxHeight - minHeight + 1)) + minHeight;
P.pipes.push({ x: WIDTH, topHeight: height, passed: false });
}
// Pipe Movement & Collision
P.pipes.forEach(pipe => {
pipe.x -= P.speed;
// Collision AABB
const birdLeft = 100 - 15;
const birdRight = 100 + 15;
const birdTop = P.birdY - 12;
const birdBottom = P.birdY + 12;
const pipeLeft = pipe.x;
const pipeRight = pipe.x + PIPE_WIDTH;
// Hit Pipe
if (!pipe.broken && birdRight > pipeLeft && birdLeft < pipeRight) {
if (birdTop < pipe.topHeight || birdBottom > pipe.topHeight + PIPE_GAP) {
if (P.invincibleTimer > 0) {
// Boost destroys pipe logic (visual only, we just mark it passed/broken)
pipe.broken = true;
spawnParticles(pipe.x, P.birdY, '#64748b'); // Stone debris
setScore(s => s + 2); // Bonus points for smashing
playSound('score');
} else {
gameOverLogic();
}
}
}
// Score & Charge
if (!pipe.passed && birdLeft > pipeRight) {
pipe.passed = true;
setScore(s => s + 1);
// Increment boost charge
if (P.pipesSinceBoost < PIPES_FOR_BOOST) {
P.pipesSinceBoost++;
}
playSound('score');
}
});
// Cleanup pipes
if (P.pipes.length > 0 && P.pipes[0].x < -PIPE_WIDTH) {
P.pipes.shift();
}
// Ground/Ceiling Collision
if (P.birdY >= HEIGHT - 40 || P.birdY < 0) {
if (P.invincibleTimer > 0 && P.birdY < 0) {
// Hitting ceiling while boosting is fine, just clamp
P.birdY = 0;
P.velocity = 0;
} else {
gameOverLogic();
}
}
} else if (gameState === 'READY') {
P.birdY = 250 + Math.sin(Date.now() / 300) * 10;
P.rotation = 0;
} else if (gameState === 'GAMEOVER') {
if (P.birdY < HEIGHT - 40) {
P.velocity += GRAVITY;
P.birdY += P.velocity;
P.rotation = Math.min(Math.PI / 2, P.rotation + 0.1);
}
}
if (P.flashOpacity > 0) P.flashOpacity -= 0.05;
// --- RENDER ---
// Sky
const skyGrad = ctx.createLinearGradient(0, 0, 0, HEIGHT);
skyGrad.addColorStop(0, '#38bdf8'); // Sky blue
skyGrad.addColorStop(1, '#bae6fd');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Mountains
ctx.fillStyle = '#f1f5f9';
ctx.beginPath();
ctx.moveTo(0, HEIGHT);
ctx.lineTo(200, HEIGHT - 200);
ctx.lineTo(400, HEIGHT - 100);
ctx.lineTo(600, HEIGHT - 250);
ctx.lineTo(800, HEIGHT);
ctx.fill();
// Clouds
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
P.clouds.forEach(c => {
ctx.beginPath();
ctx.arc(c.x, c.y, 30 * c.scale, 0, Math.PI * 2);
ctx.arc(c.x + 20 * c.scale, c.y - 10 * c.scale, 35 * c.scale, 0, Math.PI * 2);
ctx.arc(c.x + 40 * c.scale, c.y, 30 * c.scale, 0, Math.PI * 2);
ctx.fill();
});
// Pipes
P.pipes.forEach(pipe => {
if (pipe.broken) ctx.globalAlpha = 0.5;
// Top Pillar
const gradTop = ctx.createLinearGradient(pipe.x, 0, pipe.x + PIPE_WIDTH, 0);
gradTop.addColorStop(0, '#475569');
gradTop.addColorStop(0.5, '#64748b');
gradTop.addColorStop(1, '#475569');
ctx.fillStyle = gradTop;
ctx.fillRect(pipe.x, 0, PIPE_WIDTH, pipe.topHeight);
// Cap Top
ctx.fillStyle = '#334155';
ctx.fillRect(pipe.x - 4, pipe.topHeight - 20, PIPE_WIDTH + 8, 20);
// Bottom Pillar
ctx.fillStyle = gradTop;
ctx.fillRect(pipe.x, pipe.topHeight + PIPE_GAP, PIPE_WIDTH, HEIGHT - (pipe.topHeight + PIPE_GAP));
// Cap Bottom
ctx.fillStyle = '#334155';
ctx.fillRect(pipe.x - 4, pipe.topHeight + PIPE_GAP, PIPE_WIDTH + 8, 20);
ctx.globalAlpha = 1.0;
});
// Ground
ctx.fillStyle = '#166534';
ctx.fillRect(0, HEIGHT - 40, WIDTH, 40);
ctx.fillStyle = '#86efac';
ctx.fillRect(0, HEIGHT - 40, WIDTH, 5);
// Moving Ground Pattern
ctx.fillStyle = '#14532d';
const groundOffset = (Date.now() / 5 * (P.speed/3.5)) % 40;
for(let i = -1; i < WIDTH/20; i++) {
ctx.beginPath();
ctx.moveTo(i * 40 - groundOffset, HEIGHT - 35);
ctx.lineTo(i * 40 + 20 - groundOffset, HEIGHT);
ctx.lineTo(i * 40 + 10 - groundOffset, HEIGHT);
ctx.lineTo(i * 40 - 10 - groundOffset, HEIGHT - 35);
ctx.fill();
}
// Particles
P.particles.forEach((p, i) => {
p.x += p.vx;
p.y += p.vy;
p.life -= 0.05;
ctx.fillStyle = p.color;
ctx.globalAlpha = Math.max(0, p.life);
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fill();
if (p.life <= 0) P.particles.splice(i, 1);
});
ctx.globalAlpha = 1.0;
// Bird
ctx.save();
ctx.translate(100, P.birdY);
ctx.rotate(P.rotation);
// Boost Aura
if (P.invincibleTimer > 0) {
ctx.shadowBlur = 20;
ctx.shadowColor = '#facc15';
ctx.fillStyle = 'rgba(250, 204, 21, 0.5)';
ctx.beginPath();
ctx.arc(0, 0, 30, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
// Tail
ctx.fillStyle = '#b91c1c';
ctx.beginPath();
ctx.ellipse(-15, 0, 15, 8, 0, 0, Math.PI*2);
ctx.fill();
// Body
const bodyGrad = ctx.createRadialGradient(-5, -5, 2, 0, 0, 15);
bodyGrad.addColorStop(0, '#0ea5e9');
bodyGrad.addColorStop(1, '#1e3a8a');
ctx.fillStyle = bodyGrad;
ctx.beginPath();
ctx.ellipse(0, 0, 18, 14, 0, 0, Math.PI*2);
ctx.fill();
// Wing
ctx.fillStyle = '#0f766e';
ctx.beginPath();
ctx.ellipse(2, 4, 10, 6, 0.2, 0, Math.PI*2);
ctx.fill();
// Eye
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(10, -6, 5, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = 'black';
ctx.beginPath();
ctx.arc(12, -6, 2, 0, Math.PI*2);
ctx.fill();
// Beak
ctx.fillStyle = '#fbbf24';
ctx.beginPath();
ctx.moveTo(14, -4);
ctx.lineTo(24, 0);
ctx.lineTo(14, 4);
ctx.fill();
ctx.restore();
// Flash Effect
if (P.flashOpacity > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${P.flashOpacity})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
requestRef.current = requestAnimationFrame(loop);
};
return (
<div className="fixed inset-0 z-50 bg-[#020617] flex flex-col items-center justify-center select-none overflow-hidden font-sans" onMouseDown={flap} onTouchStart={flap} onKeyDown={(e) => { if (e.code === 'Space') flap(); }} tabIndex={0}>
<div className="absolute top-6 w-full max-w-5xl px-6 z-50 flex justify-between items-start pointer-events-none">
<button onClick={(e) => { e.stopPropagation(); onExit(); }} className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all font-black text-xs tracking-widest shadow-2xl">
<ArrowLeft size={18} /> EXIT
</button>
<div className="flex gap-2">
<button onClick={(e) => { e.stopPropagation(); setAudioEnabled(!audioEnabled); }} className="pointer-events-auto p-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all shadow-2xl">
{audioEnabled ? <Volume2 size={20}/> : <VolumeX size={20}/>}
</button>
</div>
</div>
{/* Game Container */}
<div className="relative rounded-[3rem] overflow-hidden shadow-[0_0_120px_rgba(14,165,233,0.3)] border-[14px] border-gray-900 w-full max-w-xl aspect-[9/16] md:aspect-[3/4] bg-sky-300">
<canvas ref={canvasRef} width={600} height={800} className="w-full h-full block touch-none cursor-pointer"/>
{/* HUD - Score & Level */}
{gameState !== 'READY' && (
<div className="absolute top-10 w-full text-center pointer-events-none flex flex-col items-center gap-1">
<span className="text-6xl font-black text-white drop-shadow-[0_4px_4px_rgba(0,0,0,0.4)] stroke-black leading-none">{score}</span>
<span className="text-xs font-black text-white/80 uppercase tracking-widest bg-black/20 px-3 py-1 rounded-full backdrop-blur-sm">Level {level}</span>
</div>
)}
{/* Boost Indicator */}
{gameState === 'PLAYING' && boostUnlocked && (
<div className="absolute bottom-10 right-6 pointer-events-none flex flex-col items-center gap-2">
<div
className={`w-16 h-16 rounded-full flex items-center justify-center border-4 transition-all duration-300 relative overflow-hidden ${isBoosting ? 'bg-yellow-400 border-yellow-200 scale-110 shadow-[0_0_30px_#facc15]' : boostCharge >= PIPES_FOR_BOOST ? 'bg-yellow-500 border-white animate-pulse' : 'bg-gray-800 border-gray-600'}`}
>
{/* Charge Fill */}
{!isBoosting && (
<div
className="absolute bottom-0 left-0 w-full bg-yellow-500/50 transition-all duration-500 ease-out"
style={{ height: `${(boostCharge / PIPES_FOR_BOOST) * 100}%` }}
/>
)}
<div className="relative z-10">
{isBoosting ? <Shield size={32} className="text-white"/> : <Zap size={32} className={boostCharge >= PIPES_FOR_BOOST ? "text-white" : "text-gray-400"}/>}
</div>
</div>
<span className="text-[10px] font-black text-white uppercase tracking-widest bg-black/40 px-2 py-1 rounded">
{boostCharge >= PIPES_FOR_BOOST ? 'Press E' : `${boostCharge}/${PIPES_FOR_BOOST}`}
</span>
</div>
)}
{/* Ready Screen */}
{gameState === 'READY' && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white pointer-events-none">
<div className="bg-black/40 backdrop-blur-md p-8 rounded-3xl border border-white/20 text-center animate-in zoom-in duration-300">
<h2 className="text-5xl font-black italic tracking-tighter uppercase mb-2 text-transparent bg-clip-text bg-gradient-to-b from-white to-blue-200 drop-shadow-sm">Get Ready</h2>
<div className="w-24 h-24 mx-auto my-6 bg-white/10 rounded-full flex items-center justify-center animate-bounce border-2 border-white/30">
<Play size={40} className="ml-1 fill-white"/>
</div>
<div className="space-y-2">
<p className="font-bold text-lg uppercase tracking-widest opacity-90">Tap / Space to Fly</p>
<p className="text-xs font-medium text-yellow-300 uppercase tracking-wide opacity-80">Reach Lvl 4 to Unlock Boost [E]</p>
</div>
</div>
</div>
)}
{/* Game Over Screen */}
{gameState === 'GAMEOVER' && (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex flex-col items-center justify-center text-white p-6 text-center animate-in zoom-in duration-300 z-20">
<h2 className="text-6xl font-black text-white drop-shadow-[0_10px_0_#0f172a] tracking-tighter mb-8 uppercase italic">Game Over</h2>
<div className="bg-white text-gray-900 w-full max-w-sm rounded-[2.5rem] p-8 shadow-2xl mb-8 transform rotate-1">
<div className="flex justify-between items-center mb-4 border-b-2 border-gray-100 pb-4">
<div className="text-left">
<p className="text-xs font-black text-orange-500 uppercase tracking-widest">Score</p>
<p className="text-4xl font-black">{score}</p>
</div>
<div className="text-right">
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Level</p>
<p className="text-4xl font-black">{level}</p>
</div>
</div>
{score >= 10 && (
<div className="flex justify-center py-4">
<Trophy size={64} className="text-yellow-400 drop-shadow-md fill-yellow-200 animate-bounce" />
</div>
)}
<p className="text-gray-400 font-bold text-sm italic mt-2">Best Score: {highScore}</p>
</div>
<Button onClick={(e) => { e.stopPropagation(); resetGame(); }} className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 font-black px-12 py-6 text-xl rounded-[2rem] shadow-xl flex items-center gap-3 active:scale-95 transition-transform pointer-events-auto">
<RefreshCw size={24} /> PLAY AGAIN
</Button>
</div>
)}
</div>
</div>
);
};

307
games/FlexibilityColor.tsx Normal file
View File

@ -0,0 +1,307 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Button } from '../components/ui/Button';
// Fix: Added missing Palette and X imports from lucide-react
import { ArrowLeft, Play, RefreshCw, Trophy, Zap, AlertCircle, Sparkles, Timer, Palette, X } from 'lucide-react';
import { StorageService } from '../services/storageService';
interface GameProps {
onExit: () => void;
}
const COLOR_POOL = [
{ name: 'RED', hex: '#ef4444' },
{ name: 'BLUE', hex: '#3b82f6' },
{ name: 'GREEN', hex: '#22c55e' },
{ name: 'YELLOW', hex: '#eab308' },
{ name: 'PURPLE', hex: '#a855f7' },
{ name: 'PINK', hex: '#ec4899' },
{ name: 'CYAN', hex: '#06b6d4' }
];
interface RoundData {
leftWord: typeof COLOR_POOL[0];
rightWordText: typeof COLOR_POOL[0];
rightWordInk: typeof COLOR_POOL[0];
isMatch: boolean;
}
export const FlexibilityColor: React.FC<GameProps> = ({ onExit }) => {
const [isPlaying, setIsPlaying] = useState(false);
const [gameOver, setGameOver] = useState(false);
const [score, setScore] = useState(0);
const [streak, setStreak] = useState(0);
const [highScore, setHighScore] = useState(0);
const [currentRound, setCurrentRound] = useState<RoundData | null>(null);
const [timeLeft, setTimeLeft] = useState(2000); // 2 seconds in ms
const timerRef = useRef<number>(0);
const startTimeRef = useRef<number>(0);
useEffect(() => {
const loadHighScore = async () => {
const profile = await StorageService.getProfile();
if (profile?.highScores?.truth) setHighScore(profile.highScores.truth);
};
loadHighScore();
return () => cancelAnimationFrame(timerRef.current);
}, []);
const playTone = (freq: number, type: OscillatorType = 'sine') => {
try {
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(0.1, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.1);
} catch (e) {}
};
const generateRound = useCallback(() => {
const leftColor = COLOR_POOL[Math.floor(Math.random() * COLOR_POOL.length)];
const rightText = COLOR_POOL[Math.floor(Math.random() * COLOR_POOL.length)];
// 50% chance of a match
const shouldMatch = Math.random() > 0.5;
let rightInk;
if (shouldMatch) {
rightInk = leftColor;
} else {
do {
rightInk = COLOR_POOL[Math.floor(Math.random() * COLOR_POOL.length)];
} while (rightInk.hex === leftColor.hex);
}
setCurrentRound({
leftWord: leftColor,
rightWordText: rightText,
rightWordInk: rightInk,
isMatch: shouldMatch
});
setTimeLeft(2000);
startTimeRef.current = Date.now();
}, []);
const handleGameOver = useCallback(() => {
setIsPlaying(false);
setGameOver(true);
playTone(150, 'sawtooth');
StorageService.addPoints(Math.floor(score / 5));
if (score > highScore) {
setHighScore(score);
StorageService.saveHighScore('truth', score);
}
}, [score, highScore]);
const handleAnswer = (answer: boolean) => {
if (!isPlaying || !currentRound) return;
if (answer === currentRound.isMatch) {
const points = 10 * (Math.floor(streak / 5) + 1);
setScore(prev => prev + points);
setStreak(prev => prev + 1);
playTone(600 + streak * 20);
generateRound();
} else {
handleGameOver();
}
};
const updateTimer = useCallback(() => {
if (!isPlaying) return;
const elapsed = Date.now() - startTimeRef.current;
const remaining = Math.max(0, 2000 - elapsed);
setTimeLeft(remaining);
if (remaining === 0) {
handleGameOver();
} else {
timerRef.current = requestAnimationFrame(updateTimer);
}
}, [isPlaying, handleGameOver]);
useEffect(() => {
if (isPlaying) {
timerRef.current = requestAnimationFrame(updateTimer);
} else {
cancelAnimationFrame(timerRef.current);
}
return () => cancelAnimationFrame(timerRef.current);
}, [isPlaying, updateTimer]);
const startGame = () => {
setScore(0);
setStreak(0);
setGameOver(false);
setIsPlaying(true);
generateRound();
};
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') handleAnswer(false);
if (e.key === 'ArrowRight') handleAnswer(true);
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [isPlaying, currentRound, handleAnswer]);
return (
<div className="fixed inset-0 z-50 bg-[#020617] flex flex-col items-center justify-center select-none overflow-hidden font-sans">
{/* Background Decor */}
<div className="absolute inset-0 pointer-events-none opacity-20">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-pink-600/10 rounded-full blur-[120px]"></div>
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-[0.05]"></div>
</div>
<header className="absolute top-6 w-full max-w-5xl px-6 z-50 flex justify-between items-center pointer-events-auto">
<button
onClick={onExit}
className="flex items-center gap-2 px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all font-black text-xs tracking-widest shadow-2xl"
>
<ArrowLeft size={18} /> EXIT
</button>
{isPlaying && (
<div className="flex gap-4">
<div className="bg-black/60 backdrop-blur-xl px-6 py-3 rounded-2xl border border-white/10 text-white flex flex-col items-center min-w-[120px] shadow-2xl">
<span className="text-[9px] font-black text-pink-400 uppercase tracking-[0.2em] mb-1">Score</span>
<span className="font-mono font-black text-3xl tabular-nums">{score}</span>
</div>
<div className="bg-black/60 backdrop-blur-xl px-6 py-3 rounded-2xl border border-white/10 text-white flex flex-col items-center min-w-[100px] shadow-2xl">
<span className="text-[9px] font-black text-cyan-400 uppercase tracking-[0.2em] mb-1">Streak</span>
<span className="font-mono font-black text-3xl tabular-nums">x{streak}</span>
</div>
</div>
)}
</header>
<main className="relative z-10 w-full max-w-5xl px-4 flex flex-col items-center justify-center">
{!isPlaying && !gameOver && (
<div className="bg-white/5 backdrop-blur-xl p-12 rounded-[3.5rem] border border-white/10 text-center max-w-lg shadow-2xl animate-in zoom-in duration-700">
{/* Fix: Usage of Palette resolved by adding it to imports */}
<div className="w-24 h-24 bg-pink-600/20 rounded-[2rem] flex items-center justify-center text-pink-500 mb-8 border border-pink-500/30 mx-auto">
<Palette size={56} className="animate-pulse" />
</div>
<h2 className="text-6xl font-black italic tracking-tighter text-white mb-6 uppercase">Flexibility</h2>
<p className="text-gray-400 text-lg font-medium mb-12 leading-relaxed">
Does the <span className="text-white font-bold">Meaning</span> of the left word match the <span className="text-white font-bold">Ink Color</span> of the right word?
</p>
<Button onClick={startGame} className="bg-pink-600 hover:bg-pink-700 text-2xl px-16 py-8 rounded-[2rem] font-black shadow-2xl shadow-pink-600/40 w-full transition-transform active:scale-95">
SYNC NEURONS
</Button>
<div className="mt-8 flex justify-center gap-6 opacity-40">
<div className="flex items-center gap-2 text-xs font-black text-white uppercase"><Zap size={14}/> Fast</div>
<div className="flex items-center gap-2 text-xs font-black text-white uppercase"><Timer size={14}/> 2.0s</div>
</div>
</div>
)}
{isPlaying && currentRound && (
<div className="w-full flex flex-col items-center gap-12">
{/* Timer Bar */}
<div className="w-full max-w-2xl h-4 bg-gray-900 rounded-full overflow-hidden border border-white/10 p-1 shadow-inner">
<div
className={`h-full rounded-full transition-all duration-100 ease-linear ${timeLeft < 500 ? 'bg-red-500 shadow-[0_0_15px_#ef4444]' : 'bg-cyan-500 shadow-[0_0_15px_#06b6d4]'}`}
style={{ width: `${(timeLeft / 2000) * 100}%` }}
></div>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full justify-center items-center">
{/* Left Card: Meaning */}
<div className="w-full max-w-[320px] h-64 bg-white/5 border-2 border-white/10 rounded-[3rem] flex flex-col items-center justify-center shadow-2xl group transition-all duration-300">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-[0.4em] mb-4">Literal Meaning</span>
<div className="text-6xl font-black italic tracking-tighter text-white uppercase drop-shadow-[0_0_15px_rgba(255,255,255,0.3)]">
{currentRound.leftWord.name}
</div>
</div>
<div className="hidden md:flex items-center justify-center w-12 h-12 bg-white/10 rounded-full border border-white/20">
<span className="text-white font-black">?</span>
</div>
{/* Right Card: Ink Color */}
<div className="w-full max-w-[320px] h-64 bg-white/5 border-2 border-white/10 rounded-[3rem] flex flex-col items-center justify-center shadow-2xl group transition-all duration-300">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-[0.4em] mb-4">Ink Presentation</span>
<div
className="text-6xl font-black italic tracking-tighter uppercase transition-colors duration-200"
style={{ color: currentRound.rightWordInk.hex, filter: `drop-shadow(0 0 15px ${currentRound.rightWordInk.hex}88)` }}
>
{currentRound.rightWordText.name}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-8 w-full max-w-2xl mt-4">
<button
onClick={() => handleAnswer(false)}
className="group flex flex-col items-center gap-4 p-8 bg-red-600/10 border-2 border-red-500/30 rounded-[2.5rem] hover:bg-red-500/20 hover:border-red-500 transition-all active:scale-95 shadow-xl"
>
{/* Fix: Usage of X resolved by adding it to imports */}
<div className="w-16 h-16 rounded-2xl bg-red-500 flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform">
<X size={40} strokeWidth={3} />
</div>
<span className="font-black text-2xl text-white uppercase italic tracking-tighter">NO []</span>
</button>
<button
onClick={() => handleAnswer(true)}
className="group flex flex-col items-center gap-4 p-8 bg-green-600/10 border-2 border-green-500/30 rounded-[2.5rem] hover:bg-green-500/20 hover:border-green-500 transition-all active:scale-95 shadow-xl"
>
<div className="w-16 h-16 rounded-2xl bg-green-500 flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform">
<Check size={40} strokeWidth={3} />
</div>
<span className="font-black text-2xl text-white uppercase italic tracking-tighter">YES []</span>
</button>
</div>
</div>
)}
{gameOver && (
<div className="bg-red-950/90 backdrop-blur-xl p-12 rounded-[3.5rem] border border-red-500/30 text-center max-w-lg shadow-2xl animate-in zoom-in duration-300">
<AlertCircle size={80} className="text-red-500 mb-8 mx-auto animate-bounce" />
<h2 className="text-7xl font-black text-white tracking-tighter mb-4 italic uppercase">Mismatched</h2>
<p className="text-red-200/60 font-black uppercase tracking-widest text-xs mb-8">Neural link severed due to logic error.</p>
<div className="bg-black/40 px-12 py-8 rounded-[2.5rem] border border-white/10 mb-12">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Final Cognitive Score</p>
<p className="text-7xl font-mono font-black text-yellow-400">{score}</p>
</div>
<div className="flex flex-col gap-4">
<Button onClick={startGame} className="bg-white text-gray-900 font-black px-12 py-7 text-2xl rounded-[2rem] shadow-2xl flex items-center justify-center gap-3 hover:scale-105 transition-transform">
<RefreshCw size={28}/> REBOOT LOGIC
</Button>
<button onClick={onExit} className="text-white/40 hover:text-white text-xs font-black uppercase tracking-widest py-4">Return to Arcade</button>
</div>
</div>
)}
</main>
<footer className="mt-12 flex gap-8 pointer-events-none opacity-40">
<div className="flex items-center gap-2 text-white text-[10px] font-black uppercase tracking-widest">
<Sparkles size={14} className="text-pink-400" /> Stroop Protocol
</div>
<div className="flex items-center gap-2 text-white text-[10px] font-black uppercase tracking-widest">
<Trophy size={14} className="text-yellow-500" /> Efficiency Rank
</div>
</footer>
</div>
);
};
// Helper Check icon (X is imported from Lucide)
const Check = ({ size, className, strokeWidth }: any) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className}>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
);

348
games/LogicFuses.tsx Normal file
View File

@ -0,0 +1,348 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, Play, RefreshCw, Trophy, Lightbulb, Zap, AlertTriangle, Cpu, Timer, ShieldAlert } from 'lucide-react';
import { StorageService } from '../services/storageService';
import confetti from 'canvas-confetti';
interface GameProps {
onExit: () => void;
}
type LogicPattern = 'arithmetic' | 'geometric' | 'alternating' | 'squares';
interface SequenceData {
sequence: (number | string)[];
answer: number;
options: number[];
explanation: string;
}
export const LogicFuses: React.FC<GameProps> = ({ onExit }) => {
const [isPlaying, setIsPlaying] = useState(false);
const [gameOver, setGameOver] = useState(false);
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [currentRound, setCurrentRound] = useState<SequenceData | null>(null);
const [timeLeft, setTimeLeft] = useState(15000); // 15 seconds
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
const timerRef = useRef<number>(0);
const startTimeRef = useRef<number>(0);
useEffect(() => {
const loadHighScore = async () => {
const profile = await StorageService.getProfile();
if (profile?.highScores?.truth) setHighScore(profile.highScores.truth);
};
loadHighScore();
return () => cancelAnimationFrame(timerRef.current);
}, []);
const playTone = (freq: number, type: OscillatorType = 'sine', duration = 0.1) => {
try {
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(0.1, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
} catch (e) {}
};
const generateRound = useCallback(() => {
const types: LogicPattern[] = ['arithmetic', 'geometric', 'alternating', 'squares'];
const type = types[Math.floor(Math.random() * types.length)];
let seq: number[] = [];
let answer = 0;
let explanation = "";
if (type === 'arithmetic') {
const start = Math.floor(Math.random() * 20);
const diff = Math.floor(Math.random() * 10) + 2;
seq = [0, 1, 2, 3, 4].map(i => start + i * diff);
explanation = `Add ${diff} to each number.`;
} else if (type === 'geometric') {
const start = Math.floor(Math.random() * 5) + 1;
const ratio = Math.floor(Math.random() * 2) + 2; // 2 or 3
seq = [0, 1, 2, 3, 4].map(i => start * Math.pow(ratio, i));
explanation = `Multiply by ${ratio} each time.`;
} else if (type === 'alternating') {
const start = Math.floor(Math.random() * 10);
const d1 = Math.floor(Math.random() * 5) + 1;
const d2 = Math.floor(Math.random() * 5) + 1;
let curr = start;
for (let i = 0; i < 5; i++) {
seq.push(curr);
curr += (i % 2 === 0) ? d1 : d2;
}
explanation = `Alternating additions of ${d1} and ${d2}.`;
} else {
const start = Math.floor(Math.random() * 5) + 1;
seq = [0, 1, 2, 3, 4].map(i => Math.pow(start + i, 2));
explanation = "Sequence of squares.";
}
const missingIdx = Math.floor(Math.random() * 3) + 1; // Don't hide first or last usually
answer = seq[missingIdx];
const displaySeq: (number | string)[] = [...seq];
displaySeq[missingIdx] = "?";
// Generate options
const options = new Set<number>();
options.add(answer);
while (options.size < 4) {
const offset = (Math.floor(Math.random() * 10) + 1) * (Math.random() > 0.5 ? 1 : -1);
const fake = Math.max(0, answer + offset);
if (fake !== answer) options.add(fake);
}
setCurrentRound({
sequence: displaySeq,
answer,
options: Array.from(options).sort((a, b) => a - b),
explanation
});
setIsCorrect(null);
setTimeLeft(15000);
startTimeRef.current = Date.now();
}, []);
const handleGameOver = useCallback(() => {
setIsPlaying(false);
setGameOver(true);
playTone(100, 'sawtooth', 0.5);
const karmaEarned = Math.floor(score / 50);
// Add transaction description
StorageService.addPoints(karmaEarned, score, 'game_reward', 'Logic Fuses Session');
if (score > highScore) {
setHighScore(score);
StorageService.saveHighScore('truth', score); // Reusing truth slot
}
}, [score, highScore]);
const handleAnswer = (val: number) => {
if (!isPlaying || !currentRound || isCorrect !== null) return;
if (val === currentRound.answer) {
setIsCorrect(true);
const timeBonus = Math.floor(timeLeft / 100);
setScore(prev => prev + 100 + timeBonus);
playTone(800, 'sine', 0.1);
setTimeout(() => {
generateRound();
}, 600);
} else {
setIsCorrect(false);
handleGameOver();
}
};
const updateTimer = useCallback(() => {
if (!isPlaying || isCorrect !== null) return;
const elapsed = Date.now() - startTimeRef.current;
const remaining = Math.max(0, 15000 - elapsed);
setTimeLeft(remaining);
if (remaining === 0) {
handleGameOver();
} else {
timerRef.current = requestAnimationFrame(updateTimer);
}
}, [isPlaying, isCorrect, handleGameOver]);
useEffect(() => {
if (isPlaying) {
timerRef.current = requestAnimationFrame(updateTimer);
} else {
cancelAnimationFrame(timerRef.current);
}
return () => cancelAnimationFrame(timerRef.current);
}, [isPlaying, updateTimer]);
const startGame = () => {
setScore(0);
setGameOver(false);
setIsPlaying(true);
generateRound();
};
return (
<div className="fixed inset-0 z-50 bg-[#0f172a] flex flex-col items-center justify-center select-none overflow-hidden font-sans">
{/* Background Decor - Industrial Theme */}
<div className="absolute inset-0 pointer-events-none opacity-20">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_50%_50%,#334155,transparent)] opacity-30"></div>
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-[0.1]"></div>
{/* Animated Bolts/Grid */}
<div className="grid grid-cols-6 grid-rows-6 w-full h-full opacity-10">
{[...Array(36)].map((_, i) => (
<div key={i} className="border border-white/10 flex items-center justify-center">
<div className="w-1 h-1 bg-white rounded-full"></div>
</div>
))}
</div>
</div>
<header className="absolute top-6 w-full max-w-5xl px-6 z-50 flex justify-between items-center pointer-events-auto">
<button
onClick={onExit}
className="flex items-center gap-2 px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all font-black text-xs tracking-widest shadow-2xl"
>
<ArrowLeft size={18} /> EXIT
</button>
{isPlaying && (
<div className="flex gap-4">
<div className="bg-black/60 backdrop-blur-xl px-6 py-3 rounded-2xl border border-white/10 text-white flex flex-col items-center min-w-[120px] shadow-2xl">
<span className="text-[9px] font-black text-amber-400 uppercase tracking-[0.2em] mb-1">Score</span>
<span className="font-mono font-black text-3xl tabular-nums text-amber-500">{score}</span>
</div>
</div>
)}
</header>
<main className="relative z-10 w-full max-w-5xl px-4 flex flex-col items-center justify-center">
{!isPlaying && !gameOver && (
<div className="bg-slate-800/80 backdrop-blur-xl p-12 rounded-[3.5rem] border-4 border-slate-700 text-center max-w-lg shadow-2xl animate-in zoom-in duration-700">
<div className="w-24 h-24 bg-amber-600/20 rounded-[2rem] flex items-center justify-center text-amber-500 mb-8 border border-amber-500/30 mx-auto">
<Cpu size={56} className="animate-pulse" />
</div>
<h2 className="text-6xl font-black italic tracking-tighter text-white mb-6 uppercase">Logic Fuses</h2>
<p className="text-gray-400 text-lg font-medium mb-12 leading-relaxed">
Analyze the numerical current. Identify the pattern and <span className="text-amber-500 font-bold">replace the missing fuse</span> before the system overloads.
</p>
<Button onClick={startGame} className="bg-amber-600 hover:bg-amber-700 text-2xl px-16 py-8 rounded-[2rem] font-black shadow-2xl shadow-amber-600/40 w-full transition-transform active:scale-95">
ENGAGE CIRCUITS
</Button>
<div className="mt-8 flex justify-center gap-6 opacity-40">
<div className="flex items-center gap-2 text-xs font-black text-white uppercase"><Zap size={14}/> Pattern Recognition</div>
<div className="flex items-center gap-2 text-xs font-black text-white uppercase"><Timer size={14}/> 15.0s</div>
</div>
</div>
)}
{isPlaying && currentRound && (
<div className="w-full flex flex-col items-center gap-12">
{/* Electrical Panel Container */}
<div className="bg-slate-900 border-[10px] border-slate-800 rounded-[4rem] p-10 md:p-16 shadow-[0_40px_100px_rgba(0,0,0,0.6)] w-full max-w-4xl relative overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(45deg,transparent_25%,rgba(255,255,255,0.02)_50%,transparent_75%)] pointer-events-none"></div>
{/* Timer LED Bar */}
<div className="mb-12 space-y-3">
<div className="flex justify-between items-center px-2">
<span className="text-[10px] font-black text-amber-500 uppercase tracking-widest flex items-center gap-2"><Zap size={12} className="animate-pulse"/> Voltage Stability</span>
<span className="font-mono text-sm font-black text-amber-500">{(timeLeft/1000).toFixed(1)}V</span>
</div>
<div className="w-full h-6 bg-black rounded-full overflow-hidden border-2 border-slate-700 p-1 flex gap-1">
{[...Array(20)].map((_, i) => {
const percentage = (timeLeft / 15000) * 100;
const isActive = (i / 20) * 100 < percentage;
return (
<div
key={i}
className={`h-full flex-1 rounded-sm transition-all duration-300 ${isActive ? (percentage < 30 ? 'bg-red-500 shadow-[0_0_10px_#ef4444]' : 'bg-amber-500 shadow-[0_0_10px_#f59e0b]') : 'bg-slate-900'}`}
/>
);
})}
</div>
</div>
{/* Number Display Grid */}
<div className="grid grid-cols-5 gap-4 md:gap-8 mb-16">
{currentRound.sequence.map((num, i) => (
<div
key={i}
className={`
aspect-square rounded-[1.5rem] md:rounded-[2rem] flex items-center justify-center border-4 shadow-inner transition-all duration-500
${num === '?'
? 'bg-black border-amber-500/50 text-amber-500 animate-pulse shadow-[0_0_20px_rgba(245,158,11,0.2)]'
: 'bg-slate-800 border-slate-700 text-white font-mono text-3xl md:text-5xl font-black'}
${isCorrect === true && num === '?' ? 'bg-green-600/20 border-green-500 text-green-500' : ''}
`}
>
{num === '?' && isCorrect === true ? currentRound.answer : num}
</div>
))}
</div>
{/* Option Buttons */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{currentRound.options.map((opt, i) => (
<button
key={i}
onClick={() => handleAnswer(opt)}
disabled={isCorrect !== null}
className={`
h-24 rounded-3xl font-mono text-3xl font-black transition-all active:scale-95 border-b-8
${isCorrect === null
? 'bg-slate-700 hover:bg-slate-600 text-white border-slate-900 hover:-translate-y-1'
: opt === currentRound.answer
? 'bg-green-600 text-white border-green-800'
: 'bg-red-600 text-white border-red-800 opacity-50'}
`}
>
{opt}
</button>
))}
</div>
{/* Industrial Labels */}
<div className="mt-10 flex justify-between items-center opacity-30 px-4">
<span className="text-[9px] font-black text-white uppercase tracking-[0.5em]">System-ID: LOGIC_CORE_V3</span>
<div className="flex gap-2">
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
<div className="w-2 h-2 bg-amber-500 rounded-full"></div>
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
</div>
</div>
</div>
<p className="text-gray-500 font-black uppercase tracking-[0.4em] animate-pulse">Select the Missing Key</p>
</div>
)}
{gameOver && (
<div className="bg-red-950/90 backdrop-blur-xl p-12 rounded-[3.5rem] border-4 border-red-500/30 text-center max-w-lg shadow-2xl animate-in zoom-in duration-300">
<AlertTriangle size={80} className="text-red-500 mb-8 mx-auto animate-bounce" />
<h2 className="text-7xl font-black text-white tracking-tighter mb-4 italic uppercase">Overload</h2>
<p className="text-red-200/60 font-black uppercase tracking-widest text-xs mb-8">System synchronization failed due to pattern error.</p>
<div className="bg-black/40 px-12 py-8 rounded-[2.5rem] border border-white/10 mb-8">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Last Valid Round Pattern</p>
<p className="text-sm font-bold text-amber-500 uppercase tracking-widest">{currentRound?.explanation}</p>
</div>
<div className="bg-black/40 px-12 py-8 rounded-[2.5rem] border border-white/10 mb-12">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Final Intelligence Units</p>
<p className="text-7xl font-mono font-black text-yellow-400">{score}</p>
</div>
<div className="flex flex-col gap-4">
<Button onClick={startGame} className="bg-white text-slate-900 font-black px-12 py-7 text-2xl rounded-[2rem] shadow-2xl flex items-center justify-center gap-3 hover:scale-105 transition-transform">
<RefreshCw size={28}/> RESTORE POWER
</Button>
<button onClick={onExit} className="text-white/40 hover:text-white text-xs font-black uppercase tracking-widest py-4">Deactivate Terminal</button>
</div>
</div>
)}
</main>
<footer className="mt-12 flex gap-8 pointer-events-none opacity-40">
<div className="flex items-center gap-2 text-white text-[10px] font-black uppercase tracking-widest">
<Cpu size={14} className="text-amber-500" /> Neural Processing
</div>
<div className="flex items-center gap-2 text-white text-[10px] font-black uppercase tracking-widest">
<ShieldAlert size={14} className="text-red-500" /> Core Stability
</div>
</footer>
</div>
);
};

43
games/Ludo.tsx Normal file
View File

@ -0,0 +1,43 @@
import React from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, Dice5, Construction } from 'lucide-react';
interface GameProps {
onExit: () => void;
}
export const Ludo: React.FC<GameProps> = ({ onExit }) => {
return (
<div className="fixed inset-0 z-50 bg-[#0f172a] flex flex-col items-center justify-center select-none overflow-hidden font-sans">
<div className="absolute top-6 left-6 z-50">
<button
onClick={onExit}
className="flex items-center gap-2 px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all font-black text-xs tracking-widest shadow-2xl"
>
<ArrowLeft size={18} /> EXIT
</button>
</div>
<div className="relative z-10 p-12 text-center max-w-2xl animate-in zoom-in duration-700">
<div className="w-32 h-32 bg-yellow-500/20 rounded-[2.5rem] flex items-center justify-center text-yellow-500 mb-10 border border-yellow-500/30 mx-auto shadow-[0_0_60px_rgba(234,179,8,0.3)]">
<Dice5 size={64} className="animate-spin-slow" />
</div>
<h2 className="text-6xl md:text-8xl font-black italic tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400 mb-6 uppercase drop-shadow-2xl">
LUDO KING
</h2>
<div className="bg-white/5 backdrop-blur-xl border border-white/10 px-8 py-4 rounded-full inline-flex items-center gap-3 mb-8">
<Construction size={18} className="text-blue-400"/>
<span className="text-sm font-black uppercase tracking-[0.2em] text-gray-300">Data Pending</span>
</div>
<p className="text-gray-400 text-xl font-medium leading-relaxed max-w-lg mx-auto">
The classic board game is currently being digitized. Multiplayer protocols initializing...
</p>
</div>
</div>
);
};

288
games/MandalaMind.tsx Normal file
View File

@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, Play, RefreshCw, Trophy, Sparkles, Hexagon, Volume2, VolumeX } from 'lucide-react';
import { StorageService } from '../services/storageService';
interface GameProps {
onExit: () => void;
}
export const MandalaMind: React.FC<GameProps> = ({ onExit }) => {
const [isPlaying, setIsPlaying] = useState(false);
const [gameOver, setGameOver] = useState(false);
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [sequence, setSequence] = useState<number[]>([]);
const [userSequence, setUserSequence] = useState<number[]>([]);
const [isShowingSequence, setIsShowingSequence] = useState(false);
const [activeSegment, setActiveSegment] = useState<number | null>(null);
const [message, setMessage] = useState("Watch & Remember");
const [audioEnabled, setAudioEnabled] = useState(true);
const colors = [
{
id: 0,
baseColor: 'bg-red-600',
glowColor: 'shadow-[0_0_60px_rgba(239,68,68,0.9)] ring-4 ring-red-400/50',
activeColor: 'bg-red-400',
shape: 'rounded-tl-[100px] rounded-br-[20px] rounded-tr-[20px] rounded-bl-[20px]',
note: 261.63
},
{
id: 1,
baseColor: 'bg-blue-600',
glowColor: 'shadow-[0_0_60px_rgba(59,130,246,0.9)] ring-4 ring-blue-400/50',
activeColor: 'bg-blue-400',
shape: 'rounded-tr-[100px] rounded-bl-[20px] rounded-tl-[20px] rounded-br-[20px]',
note: 329.63
},
{
id: 2,
baseColor: 'bg-green-600',
glowColor: 'shadow-[0_0_60px_rgba(34,197,94,0.9)] ring-4 ring-green-400/50',
activeColor: 'bg-green-400',
shape: 'rounded-bl-[100px] rounded-tr-[20px] rounded-tl-[20px] rounded-br-[20px]',
note: 392.00
},
{
id: 3,
baseColor: 'bg-yellow-500',
glowColor: 'shadow-[0_0_60px_rgba(234,179,8,0.9)] ring-4 ring-yellow-400/50',
activeColor: 'bg-yellow-300',
shape: 'rounded-br-[100px] rounded-tl-[20px] rounded-tr-[20px] rounded-bl-[20px]',
note: 523.25
},
];
useEffect(() => {
const loadHighScore = async () => {
const profile = await StorageService.getProfile();
if (profile?.highScores?.mandala) {
setHighScore(profile.highScores.mandala);
}
};
loadHighScore();
}, []);
const playTone = (freq: number) => {
if (!audioEnabled) return;
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContext) return;
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(0.1, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.00001, ctx.currentTime + 0.5);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.5);
} catch (e) {
console.error("Audio error", e);
}
};
const startGame = () => {
setIsPlaying(true);
setGameOver(false);
setScore(0);
setSequence([]);
setUserSequence([]);
setMessage("Watch closely...");
addToSequence([]);
};
const addToSequence = (currentSeq: number[]) => {
const nextColor = Math.floor(Math.random() * 4);
const newSeq = [...currentSeq, nextColor];
setSequence(newSeq);
setUserSequence([]);
setIsShowingSequence(true);
const baseSpeed = 800;
const speed = Math.max(300, baseSpeed - (newSeq.length * 40));
setTimeout(() => playSequence(newSeq, speed), 1000);
};
const playSequence = async (seq: number[], speed: number) => {
for (let i = 0; i < seq.length; i++) {
setActiveSegment(seq[i]);
playTone(colors[seq[i]].note);
await new Promise(r => setTimeout(r, speed * 0.6));
setActiveSegment(null);
await new Promise(r => setTimeout(r, speed * 0.4));
}
setIsShowingSequence(false);
setMessage("Repeat the pattern!");
};
const handleSegmentClick = (id: number) => {
if (!isPlaying || isShowingSequence || gameOver) return;
setActiveSegment(id);
playTone(colors[id].note);
setTimeout(() => setActiveSegment(null), 200);
const newUserSeq = [...userSequence, id];
setUserSequence(newUserSeq);
if (newUserSeq[newUserSeq.length - 1] !== sequence[newUserSeq.length - 1]) {
playTone(150);
setGameOver(true);
setIsPlaying(false);
if (score > highScore) {
setHighScore(score);
}
StorageService.saveHighScore('mandala', score);
StorageService.addPoints(Math.floor(score / 2), score * 2, 'game_reward', 'Mandala Mind Synchronization');
return;
}
if (newUserSeq.length === sequence.length) {
const newScore = score + 1;
setScore(newScore);
setMessage("Correct!");
setIsShowingSequence(true);
setTimeout(() => {
addToSequence(sequence);
}, 1000);
}
};
return (
<div className="fixed inset-0 z-50 bg-[#0f172a] flex flex-col items-center justify-center overflow-hidden select-none font-sans">
{/* Dynamic Background */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-indigo-900/40 via-[#0f172a] to-[#0f172a]"></div>
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/black-scales.png')] opacity-10 animate-pulse-slow"></div>
<div className={`absolute inset-0 bg-red-500/10 mix-blend-overlay transition-opacity duration-300 ${gameOver ? 'opacity-100' : 'opacity-0'}`}></div>
{/* Top Controls */}
<div className="absolute top-6 left-6 z-20 flex gap-4 w-full px-6 justify-between items-start pointer-events-none">
<button
onClick={onExit}
className="pointer-events-auto flex items-center gap-2 px-5 py-2.5 bg-white/10 hover:bg-white/20 text-white rounded-full backdrop-blur-md border border-white/20 transition-all hover:scale-105 active:scale-95 font-bold text-sm shadow-xl group"
>
<div className="bg-white/20 p-1 rounded-full group-hover:bg-purple-500 transition-colors">
<ArrowLeft size={16} />
</div>
EXIT
</button>
<button
onClick={() => setAudioEnabled(!audioEnabled)}
className="pointer-events-auto p-3 rounded-full bg-black/40 hover:bg-white/10 text-gray-400 hover:text-white transition-all border border-white/10"
>
{audioEnabled ? <Volume2 size={20}/> : <VolumeX size={20}/>}
</button>
</div>
<div className="relative z-10 flex flex-col items-center w-full max-w-md px-6">
<div className="mb-10 text-center animate-in slide-in-from-top-8 duration-700">
<h1 className="text-6xl font-black text-transparent bg-clip-text bg-gradient-to-r from-indigo-300 via-purple-300 to-pink-300 mb-2 filter drop-shadow-[0_0_15px_rgba(168,85,247,0.5)] tracking-tighter">MANDALA</h1>
<p className="text-indigo-200/60 font-mono text-xs tracking-[0.3em] uppercase">Memory Synchronization</p>
</div>
<div className="relative w-80 h-80 sm:w-[420px] sm:h-[420px] perspective-1000">
{/* Rotating Rings */}
<div className={`absolute inset-[-60px] rounded-full border border-indigo-500/10 border-dashed animate-[spin_60s_linear_infinite] pointer-events-none ${isPlaying ? 'opacity-100' : 'opacity-20'}`}></div>
<div className={`absolute inset-[-30px] rounded-full border-2 border-purple-500/20 animate-[spin_40s_linear_infinite_reverse] pointer-events-none ${isPlaying ? 'opacity-100' : 'opacity-20'}`}></div>
{/* The Flower Grid */}
<div className="absolute inset-0 grid grid-cols-2 gap-6 p-4 rotate-45 transform transition-transform duration-700">
{colors.map((c) => (
<button
key={c.id}
onMouseDown={() => handleSegmentClick(c.id)}
onTouchStart={(e) => { e.preventDefault(); handleSegmentClick(c.id); }}
className={`
relative transition-all duration-150 transform
${c.shape}
${activeSegment === c.id
? `${c.activeColor} ${c.glowColor} scale-105 z-10 brightness-110`
: `${c.baseColor} opacity-80 hover:opacity-100 hover:brightness-110 hover:scale-[1.02]`}
${!isPlaying && !gameOver ? 'cursor-default opacity-30 grayscale' : ''}
shadow-2xl border-b-4 border-black/20 overflow-hidden
`}
>
<div className="absolute inset-0 bg-gradient-to-br from-white/30 to-transparent pointer-events-none"></div>
{activeSegment === c.id && <div className="absolute inset-0 bg-white/40 animate-ping"></div>}
</button>
))}
</div>
{/* Center Hub */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<div className="w-28 h-28 bg-gray-900 rounded-full border-[8px] border-gray-800 flex items-center justify-center shadow-[0_0_50px_rgba(0,0,0,0.8)] relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-gray-800 to-black"></div>
{isPlaying && <div className="absolute inset-0 rounded-full bg-indigo-500/10 animate-pulse"></div>}
{isPlaying ? (
<div className="text-center animate-in zoom-in relative z-10">
<p className="text-[10px] text-gray-500 uppercase font-bold tracking-widest mb-[-2px]">Score</p>
<p className="text-4xl font-mono text-white font-black tracking-tighter">{score}</p>
</div>
) : (
<div className="text-gray-700 animate-pulse relative z-10">
<Hexagon size={48} strokeWidth={1} />
</div>
)}
</div>
</div>
</div>
<div className="mt-16 h-32 flex flex-col items-center justify-center w-full">
{!isPlaying && !gameOver && (
<Button onClick={startGame} className="bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white px-12 py-5 rounded-full text-xl font-black tracking-wide shadow-[0_0_30px_rgba(99,102,241,0.4)] transition-all hover:scale-105 active:scale-95 group border border-white/10">
<Play size={24} className="mr-3 fill-white group-hover:scale-110 transition-transform" /> START GAME
</Button>
)}
{isPlaying && (
<div className="text-center">
<p className={`text-2xl font-bold tracking-widest transition-all duration-300 uppercase ${isShowingSequence ? 'text-indigo-300 animate-pulse' : 'text-green-400 scale-110'}`}>
{message}
</p>
<div className="flex gap-2 justify-center mt-4">
{[0,1,2].map(i => (
<span key={i} className={`w-2 h-2 rounded-full ${isShowingSequence ? 'bg-indigo-500 animate-bounce' : 'bg-gray-800'}`} style={{ animationDelay: `${i*100}ms` }}></span>
))}
</div>
</div>
)}
{gameOver && (
<div className="text-center animate-in zoom-in duration-300 w-full max-w-sm bg-gray-900/90 backdrop-blur-xl p-8 rounded-3xl border border-white/10 shadow-2xl">
<p className="text-red-500 font-black text-5xl mb-2 drop-shadow-md tracking-tighter">GAME OVER</p>
<div className="flex justify-between items-center my-6 px-6 py-3 bg-black/40 rounded-2xl border border-white/5">
<span className="text-gray-400 text-xs font-bold uppercase tracking-widest">Final Score</span>
<span className="text-white font-mono text-3xl font-black">{score}</span>
</div>
<Button onClick={startGame} className="w-full bg-white text-gray-900 hover:bg-gray-200 font-black py-4 rounded-xl shadow-lg hover:scale-[1.02] transition-transform">
<RefreshCw size={20} className="mr-2"/> TRY AGAIN
</Button>
</div>
)}
</div>
{!isPlaying && !gameOver && (
<div className="mt-8 flex items-center gap-3 px-6 py-3 bg-white/5 rounded-full border border-white/10 shadow-lg">
<Trophy size={18} className="text-yellow-500" />
<span className="text-xs text-gray-400 font-bold uppercase tracking-widest">Personal Best</span>
<span className="text-xl font-mono text-white font-black">{highScore}</span>
</div>
)}
</div>
</div>
);
};

267
games/MemoryShore.tsx Normal file
View File

@ -0,0 +1,267 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { GameShell } from '../components/ui/GameShell';
import { HelpCircle } from 'lucide-react';
interface GameProps {
onExit: () => void;
}
// Extended Item Pool
const ALL_ITEMS = [
'🐚', '🦀', '🐠', '🐙', '🐢',
'🐳', '🐬', '🐡', '🦈', '🦐',
'🦑', '🦞', '🐋', '🐊', '🦆',
'⚓', '⛵', '🏝️', '🥥', '🍹',
'🌊', '🏄', '🏊', '🛟', '🗺️',
'🐟', '🏖️', '🌋', '🛶', '🚤',
'🐡', '🦠', '🪸', '🪼', '🧂'
];
const WIN_CONDITION = 30;
interface ClickEffect {
id: number;
x: number;
y: number;
val: string;
}
export const MemoryShore: React.FC<GameProps> = ({ onExit }) => {
return (
<GameShell
gameId="memory"
title="Memory Shore"
onExit={onExit}
>
{({ onGameOver }) => (
<MemoryShoreGame onGameOver={onGameOver} />
)}
</GameShell>
);
};
const MemoryShoreGame = ({ onGameOver }: { onGameOver: (score: number) => void }) => {
const [score, setScore] = useState(0);
const [level, setLevel] = useState(1);
const [collectedItems, setCollectedItems] = useState<Set<string>>(new Set());
const [currentPool, setCurrentPool] = useState<string[]>([]);
const [isShuffling, setIsShuffling] = useState(false);
// Visual FX State
const [tideActive, setTideActive] = useState(false);
const [clickEffects, setClickEffects] = useState<ClickEffect[]>([]);
const [shake, setShake] = useState(false);
useEffect(() => {
// Start game: Pick 2 random items
const shuffled = [...ALL_ITEMS].sort(() => 0.5 - Math.random());
setCurrentPool(shuffled.slice(0, 2));
}, []);
const addClickEffect = (e: React.MouseEvent) => {
const newEffect = {
id: Date.now(),
x: e.clientX,
y: e.clientY,
val: "+100"
};
setClickEffects(prev => [...prev, newEffect]);
setTimeout(() => {
setClickEffects(prev => prev.filter(ef => ef.id !== newEffect.id));
}, 800);
};
const proceedToNextRound = (currentCollected: Set<string>, triggerTide: boolean) => {
setIsShuffling(true);
// Prepare next pool
const uncollected = ALL_ITEMS.filter(i => !currentCollected.has(i));
let nextPool = [...currentPool];
if (uncollected.length > 0) {
const newItem = uncollected[Math.floor(Math.random() * uncollected.length)];
nextPool.push(newItem);
// Shuffle
for (let i = nextPool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[nextPool[i], nextPool[j]] = [nextPool[j], nextPool[i]];
}
}
if (triggerTide) {
setTideActive(true);
setTimeout(() => {
setCurrentPool(nextPool);
}, 600);
setTimeout(() => {
setTideActive(false);
setIsShuffling(false);
}, 1200);
} else {
setTimeout(() => {
setCurrentPool(nextPool);
setIsShuffling(false);
}, 300);
}
};
const handleItemClick = (item: string, e: React.MouseEvent) => {
if (isShuffling) return;
if (collectedItems.has(item)) {
setShake(true);
setTimeout(() => onGameOver(score), 500);
} else {
addClickEffect(e);
const newCollected = new Set<string>(collectedItems);
newCollected.add(item);
setCollectedItems(newCollected);
const newScore = newCollected.size;
setScore(newScore);
setLevel(Math.floor(newScore / 5) + 1);
if (newScore >= WIN_CONDITION) {
onGameOver(newScore);
} else {
const triggerTide = newScore % 3 === 0;
proceedToNextRound(newCollected, triggerTide);
}
}
};
const getGridSizeClass = (count: number) => {
if (count <= 4) return "w-28 h-28 md:w-32 md:h-32 text-5xl";
if (count <= 9) return "w-20 h-20 md:w-24 md:h-24 text-4xl";
return "w-16 h-16 md:w-20 md:h-20 text-3xl";
};
const renderBubbles = () => {
return (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{[...Array(15)].map((_, i) => (
<div
key={i}
className="absolute rounded-full bg-white/5 animate-float"
style={{
width: Math.random() * 60 + 20 + 'px',
height: Math.random() * 60 + 20 + 'px',
left: Math.random() * 100 + '%',
top: Math.random() * 100 + '%',
animationDuration: Math.random() * 10 + 10 + 's',
animationDelay: Math.random() * 5 + 's',
}}
/>
))}
<style>{`
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-40px); }
}
.animate-float { animation: float linear infinite; }
.shake-screen { animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both; }
@keyframes shake {
10%, 90% { transform: translate3d(-2px, 0, 0); }
20%, 80% { transform: translate3d(4px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-8px, 0, 0); }
40%, 60% { transform: translate3d(8px, 0, 0); }
}
`}</style>
</div>
);
};
return (
<div className={`relative w-full h-full flex flex-col items-center justify-center p-4 overflow-hidden transition-colors duration-500 ${shake ? 'bg-red-900/80 shake-screen' : 'bg-gradient-to-b from-cyan-900 to-blue-950'}`}>
{renderBubbles()}
{/* Tide Wave Overlay */}
<AnimatePresence>
{tideActive && (
<motion.div
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
exit={{ x: '100%' }}
transition={{ duration: 1.2, ease: "easeInOut" }}
className="absolute inset-0 z-30 pointer-events-none"
>
<div className="w-full h-full bg-gradient-to-r from-transparent via-cyan-400/40 to-transparent backdrop-blur-md skew-x-12"></div>
</motion.div>
)}
</AnimatePresence>
{/* Click Feedback Particles */}
{clickEffects.map(ef => (
<motion.div
key={ef.id}
initial={{ opacity: 1, y: 0, scale: 0.5 }}
animate={{ opacity: 0, y: -100, scale: 1.5 }}
className="fixed z-50 text-yellow-300 font-black text-2xl drop-shadow-md pointer-events-none"
style={{ left: ef.x, top: ef.y }}
>
{ef.val}
</motion.div>
))}
{/* Game Area */}
<div className="relative z-10 w-full max-w-4xl flex flex-col items-center h-full justify-center">
{/* Header Info */}
<div className="flex justify-between items-center w-full mb-6 px-4 absolute top-0 pt-4">
<div className="flex items-center gap-4">
<div className="bg-white/10 backdrop-blur-md px-6 py-2 rounded-full border border-white/20">
<span className="text-[10px] font-black text-cyan-300 uppercase tracking-widest mr-2">Collection</span>
<span className="text-xl font-bold text-white">{score} / {WIN_CONDITION}</span>
</div>
<div className="bg-blue-500/20 px-4 py-2 rounded-full border border-blue-400/30 text-white font-bold text-sm">
Lvl {level}
</div>
</div>
</div>
{/* Grid */}
<div className="flex-1 w-full flex items-center justify-center p-4">
<motion.div
layout
className="flex flex-wrap justify-center gap-4 md:gap-6 max-w-4xl content-center"
>
<AnimatePresence mode="popLayout">
{currentPool.map((item) => (
<motion.button
layoutId={item}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
whileHover={{
scale: 1.1,
y: [0, -5, 0],
transition: { repeat: Infinity, duration: 2 }
}}
whileTap={{ scale: 0.9 }}
key={item}
onClick={(e) => handleItemClick(item, e)}
className={`${getGridSizeClass(currentPool.length)} bg-white/10 backdrop-blur-lg rounded-full flex items-center justify-center shadow-[0_8px_32px_rgba(0,0,0,0.2)] border border-white/20 cursor-pointer hover:bg-white/20 transition-colors relative group`}
>
<span className="drop-shadow-lg filter pointer-events-none">{item}</span>
<div className="absolute inset-0 rounded-full overflow-hidden">
<div className="absolute inset-0 bg-cyan-400/30 opacity-0 group-active:opacity-100 transition-opacity"></div>
</div>
<div className="absolute inset-0 rounded-full bg-cyan-400/20 opacity-0 group-hover:opacity-100 transition-opacity blur-md pointer-events-none"></div>
</motion.button>
))}
</AnimatePresence>
</motion.div>
</div>
<div className="absolute bottom-8 text-center animate-pulse pointer-events-none">
<p className="text-[10px] font-black uppercase tracking-[0.3em] text-cyan-300/60 flex items-center gap-2">
<HelpCircle size={12} /> Don't click duplicates
</p>
</div>
</div>
</div>
);
};

406
games/MentalAgility.tsx Normal file
View File

@ -0,0 +1,406 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, RefreshCw, Trophy, Zap, Check, X, Timer, Activity, Flame, RotateCcw, LogOut, Award, MousePointer2, Palette } from 'lucide-react';
import { StorageService } from '../services/storageService';
import { getRandomChallenge, Challenge } from './mentalAgilityLogic';
interface GameProps {
onExit: () => void;
}
export const MentalAgility: React.FC<GameProps> = ({ onExit }) => {
const [gameState, setGameState] = useState<'START' | 'PLAY' | 'OVER'>('START');
const [score, setScore] = useState(0);
const [streak, setStreak] = useState(0);
const [timeLeft, setTimeLeft] = useState(60); // Total game time
const [currentChallenge, setCurrentChallenge] = useState<Challenge | null>(null);
const [highScore, setHighScore] = useState(0);
const [scorePulse, setScorePulse] = useState(false);
const [feedback, setFeedback] = useState<'correct' | 'wrong' | null>(null);
const [shake, setShake] = useState(false);
// Stats Tracking
const [correctAnswers, setCorrectAnswers] = useState(0);
const [totalAttempts, setTotalAttempts] = useState(0);
const startTimeRef = useRef<number>(0);
const endTimeRef = useRef<number>(0);
// Difficulty States
const [wordRotation, setWordRotation] = useState(0);
const [wordBlur, setWordBlur] = useState(false);
// Timer ref for the main countdown
const timerRef = useRef<any>(null);
useEffect(() => {
StorageService.getProfile().then(p => {
if (p?.highScores?.['flexibility' as any]) setHighScore(p.highScores['flexibility' as any]);
});
}, []);
// Blur clearing effect
useEffect(() => {
if (wordBlur) {
const t = setTimeout(() => setWordBlur(false), 50);
return () => clearTimeout(t);
}
}, [currentChallenge]);
const playTone = (freq: number, type: OscillatorType = 'sine') => {
try {
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(0.1, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.1);
} catch (e) {}
};
const startGame = () => {
setScore(0);
setStreak(0);
setCorrectAnswers(0);
setTotalAttempts(0);
setTimeLeft(60);
setGameState('PLAY');
setFeedback(null);
setWordRotation(0);
setWordBlur(false);
setCurrentChallenge(getRandomChallenge());
startTimeRef.current = Date.now();
};
// Level based on score (0-999: Lvl 0, 1000-1999: Lvl 1, etc.)
const level = Math.floor(score / 1000);
// Main Game Timer with Dynamic Speed
useEffect(() => {
if (gameState === 'PLAY') {
// Decrease interval time as level increases (faster ticks)
// Level 0: 1000ms, Level 1: 900ms, ... Min: 200ms
const intervalSpeed = Math.max(200, 1000 - (level * 100));
timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
handleGameOver();
return 0;
}
return prev - 1;
});
}, intervalSpeed);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [gameState, level]);
const handleGameOver = async () => {
endTimeRef.current = Date.now();
setGameState('OVER');
if (timerRef.current) clearInterval(timerRef.current);
// Save Score
if (score > highScore) {
setHighScore(score);
const p = await StorageService.getProfile();
if (p) {
const newScores = { ...(p.highScores || {}), flexibility: score };
await StorageService.updateProfile({ ...p, highScores: newScores });
}
}
// Update addPoints with description
await StorageService.addPoints(Math.floor(score / 5), score, 'game_reward', 'Mental Agility Training');
};
const handleInput = (userSaysMatch: boolean) => {
if (gameState !== 'PLAY' || !currentChallenge) return;
setTotalAttempts(prev => prev + 1);
const isCorrect = userSaysMatch === currentChallenge.isMatch;
let newScore = score;
if (isCorrect) {
setCorrectAnswers(prev => prev + 1);
// Audio: High pitch 'blip'
playTone(800, 'sine');
// Visual: Green Flash (100ms)
setFeedback('correct');
setTimeout(() => setFeedback(null), 100);
// Scoring: Streak Multiplier
const newStreak = streak + 1;
setStreak(newStreak);
const multiplier = Math.floor(newStreak / 5) + 1;
newScore = score + (100 * multiplier);
setScore(newScore);
setScorePulse(true);
setTimeout(() => setScorePulse(false), 300);
} else {
// Audio: Low pitch buzz
playTone(150, 'sawtooth');
// Visual: Red Flash & Shake
setFeedback('wrong');
setShake(true);
setTimeout(() => {
setFeedback(null);
setShake(false);
}, 400);
// Penalties
setStreak(0);
newScore = Math.max(0, score - 50);
setScore(newScore);
setTimeLeft(prev => Math.max(0, prev - 2));
}
// Prepare next round visuals based on NEW score
setTimeout(() => {
const nextLevel = Math.floor(newScore / 1000);
// Rotation (Level 1+)
if (nextLevel >= 1) {
setWordRotation(Math.random() * 30 - 15); // -15 to 15 degrees
} else {
setWordRotation(0);
}
// Blur (Level 2+, 30% chance)
if (nextLevel >= 2 && Math.random() > 0.7) {
setWordBlur(true);
} else {
setWordBlur(false);
}
setCurrentChallenge(getRandomChallenge());
}, 150);
};
// Keyboard controls
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (gameState !== 'PLAY') return;
if (e.key === 'ArrowLeft') handleInput(true); // Left for Match
if (e.key === 'ArrowRight') handleInput(false); // Right for No Match
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [gameState, currentChallenge, score, streak]);
// Dynamic Border Class based on feedback
const borderClass = feedback === 'correct'
? 'border-[12px] border-green-500'
: feedback === 'wrong'
? 'border-[12px] border-red-500'
: 'border-0';
const multiplier = Math.floor(streak / 5) + 1;
const tickDuration = Math.max(200, 1000 - (level * 100)); // Sync bar transition with tick rate
// Calculate End Stats
const calculateStats = () => {
const duration = (endTimeRef.current - startTimeRef.current) / 1000;
const accuracy = totalAttempts > 0 ? Math.round((correctAnswers / totalAttempts) * 100) : 0;
const avgSpeed = correctAnswers > 0 ? (duration / correctAnswers).toFixed(2) : "0.00";
let rank = "Novice";
let rankColor = "text-gray-400";
if (score >= 5000) { rank = "Grandmaster"; rankColor = "text-purple-400"; }
else if (score >= 2500) { rank = "Master"; rankColor = "text-yellow-400"; }
else if (score >= 1000) { rank = "Sharp"; rankColor = "text-blue-400"; }
return { accuracy, avgSpeed, rank, rankColor };
};
return (
<div className={`fixed inset-0 z-50 bg-[#121212] flex flex-col items-center justify-center select-none overflow-hidden font-sans text-white transition-all duration-75 ${borderClass}`}>
{/* --- Top Bar: Countdown & Score --- */}
<div className="absolute top-0 w-full p-6 flex justify-between items-start z-20">
<div className="flex flex-col gap-2 w-1/3">
<div className="flex items-center gap-2 text-gray-400 text-xs font-bold uppercase tracking-widest">
<Timer size={14} /> Time Left
</div>
<div className="w-full h-3 bg-gray-800 rounded-full overflow-hidden border border-gray-700">
<div
className={`h-full rounded-full transition-all duration-100 ease-linear ${timeLeft < 10 ? 'bg-red-500 shadow-[0_0_10px_#ef4444]' : 'bg-cyan-400 shadow-[0_0_10px_#22d3ee]'}`}
style={{
width: `${(timeLeft / 60) * 100}%`,
transition: `width ${tickDuration}ms linear`
}}
></div>
</div>
</div>
<div className="flex flex-col items-center">
<div className={`flex flex-col items-center transition-transform duration-100 ${scorePulse ? 'scale-125 text-yellow-400' : 'scale-100'}`}>
<span className="text-xs font-bold text-gray-500 uppercase tracking-widest">Score</span>
<span className="text-4xl font-mono font-black tracking-tighter drop-shadow-lg">{score}</span>
</div>
{multiplier > 1 && (
<div className="mt-1 flex items-center gap-1 text-orange-400 animate-bounce">
<Flame size={12} fill="currentColor" />
<span className="text-xs font-black uppercase tracking-widest">{multiplier}x Streak</span>
</div>
)}
</div>
<div className="w-1/3 flex justify-end">
<button
onClick={onExit}
className="bg-white/5 hover:bg-white/10 p-3 rounded-full transition-all border border-white/10"
>
<X size={20} />
</button>
</div>
</div>
{/* --- Main Game Area --- */}
<main className="w-full max-w-lg px-6 flex flex-col items-center justify-center gap-12 z-10">
{gameState === 'START' && (
<div className="bg-[#1e1e1e] p-10 rounded-[2.5rem] border border-white/5 shadow-2xl text-center animate-in zoom-in duration-500">
<div className="w-20 h-20 bg-purple-500/20 rounded-3xl flex items-center justify-center text-purple-400 mx-auto mb-6 shadow-[0_0_30px_rgba(168,85,247,0.3)]">
<Activity size={40} />
</div>
<h1 className="text-4xl font-black italic uppercase tracking-tighter mb-4">Mental Agility</h1>
<p className="text-gray-400 text-sm mb-8 leading-relaxed">
Does the <span className="text-white font-bold">Meaning</span> match the <span className="text-white font-bold">Ink Color</span>?
<br/>Think fast. Don't get tricked.
</p>
<Button onClick={startGame} className="w-full h-16 text-xl font-black uppercase tracking-widest bg-purple-600 hover:bg-purple-700 shadow-[0_0_20px_rgba(147,51,234,0.5)] rounded-2xl">
Start Challenge
</Button>
</div>
)}
{gameState === 'PLAY' && currentChallenge && (
<>
{/* Challenge Card */}
<div className={`relative w-full aspect-square max-w-sm bg-[#1e1e1e] rounded-[3rem] border border-white/10 flex flex-col items-center justify-center shadow-2xl transition-transform ${shake ? 'animate-shake border-red-500/50' : ''}`}>
<span className="text-xs font-bold text-gray-500 uppercase tracking-[0.3em] mb-8">Does this match?</span>
{/* The Word with Dynamic Scaling Effects */}
<div
className="text-6xl md:text-8xl font-black italic tracking-tighter uppercase"
style={{
color: currentChallenge.displayColor,
textShadow: `0 0 30px ${currentChallenge.displayColor}66`,
transform: `rotate(${wordRotation}deg)`,
filter: wordBlur ? 'blur(8px)' : 'blur(0px)',
transition: 'filter 0.5s ease-out, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)'
}}
>
{currentChallenge.displayText}
</div>
</div>
{/* Controls */}
<div className="grid grid-cols-2 gap-6 w-full">
<button
onClick={() => handleInput(true)}
className="group relative h-24 rounded-2xl bg-green-500/10 border-2 border-green-500 hover:bg-green-500/20 transition-all active:scale-95 shadow-[0_0_20px_rgba(34,197,94,0.2)] hover:shadow-[0_0_30px_rgba(34,197,94,0.4)]"
>
<div className="flex flex-col items-center gap-1">
<span className="text-2xl font-black text-green-400 uppercase italic tracking-tighter group-hover:text-green-300">MATCH</span>
<span className="text-[10px] font-bold text-green-600/60 uppercase tracking-widest">[ LEFT ARROW ]</span>
</div>
</button>
<button
onClick={() => handleInput(false)}
className="group relative h-24 rounded-2xl bg-red-500/10 border-2 border-red-500 hover:bg-red-500/20 transition-all active:scale-95 shadow-[0_0_20px_rgba(239,68,68,0.2)] hover:shadow-[0_0_30px_rgba(239,68,68,0.4)]"
>
<div className="flex flex-col items-center gap-1">
<span className="text-2xl font-black text-red-400 uppercase italic tracking-tighter group-hover:text-red-300">NOT A MATCH</span>
<span className="text-[10px] font-bold text-red-600/60 uppercase tracking-widest">[ RIGHT ARROW ]</span>
</div>
</button>
</div>
</>
)}
{gameState === 'OVER' && (
<div className="bg-[#1e1e1e]/95 backdrop-blur-xl p-12 rounded-[3.5rem] border border-white/10 shadow-2xl text-center w-full max-w-md animate-in zoom-in duration-300 relative overflow-hidden">
{/* Background Glow */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-3/4 h-24 bg-purple-600/20 blur-[60px]"></div>
<div className="mb-8">
<Trophy size={64} className="text-yellow-500 mx-auto mb-4 animate-bounce" />
<h2 className="text-5xl font-black text-white tracking-tighter uppercase italic">Session Report</h2>
</div>
{(() => {
const stats = calculateStats();
return (
<div className="space-y-6 mb-10">
{/* Score Card */}
<div className="bg-black/40 p-6 rounded-[2rem] border border-white/5">
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-gray-500">Total Points</span>
<div className="text-6xl font-mono font-black text-white mt-1">{score}</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<div className="flex items-center justify-center gap-2 mb-1">
<MousePointer2 size={12} className="text-blue-400"/>
<span className="text-[9px] font-black uppercase tracking-widest text-gray-400">Accuracy</span>
</div>
<div className="text-2xl font-black text-blue-400">{stats.accuracy}%</div>
</div>
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<div className="flex items-center justify-center gap-2 mb-1">
<Zap size={12} className="text-green-400"/>
<span className="text-[9px] font-black uppercase tracking-widest text-gray-400">Top Speed</span>
</div>
<div className="text-2xl font-black text-green-400">{stats.avgSpeed}s</div>
</div>
</div>
{/* Rank */}
<div className="flex items-center justify-center gap-3 bg-white/5 py-3 rounded-full border border-white/10">
<Award size={16} className={stats.rankColor} />
<span className="text-xs font-black text-gray-400 uppercase tracking-widest">Brain Rank:</span>
<span className={`text-sm font-black uppercase ${stats.rankColor}`}>{stats.rank}</span>
</div>
</div>
);
})()}
<div className="flex flex-col gap-3">
<Button onClick={startGame} className="w-full h-16 bg-white text-black font-black uppercase text-lg rounded-[1.5rem] hover:scale-[1.02] transition-transform shadow-xl">
<RotateCcw size={20} className="mr-2"/> Replay
</Button>
<Button onClick={onExit} variant="ghost" className="w-full h-14 text-white/50 hover:text-white hover:bg-white/5 font-black uppercase tracking-widest text-xs rounded-xl">
<LogOut size={16} className="mr-2"/> Return to Arcade
</Button>
</div>
</div>
)}
</main>
<style>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
.animate-shake {
animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both;
}
`}</style>
</div>
);
};

46
games/MotoGorilla.tsx Normal file
View File

@ -0,0 +1,46 @@
import React from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, Rocket, Construction } from 'lucide-react';
interface GameProps {
onExit: () => void;
}
export const MotoGorilla: React.FC<GameProps> = ({ onExit }) => {
return (
<div className="fixed inset-0 z-50 bg-[#020617] flex flex-col items-center justify-center select-none overflow-hidden font-sans">
<div className="absolute top-6 left-6 z-50">
<button
onClick={onExit}
className="flex items-center gap-2 px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all font-black text-xs tracking-widest shadow-2xl"
>
<ArrowLeft size={18} /> EXIT
</button>
</div>
<div className="relative z-10 p-12 text-center max-w-2xl animate-in zoom-in duration-700">
<div className="w-32 h-32 bg-pink-500/20 rounded-[2.5rem] flex items-center justify-center text-pink-500 mb-10 border border-pink-500/30 mx-auto shadow-[0_0_60px_rgba(236,72,153,0.3)]">
<Rocket size={64} className="animate-bounce" />
</div>
<h2 className="text-6xl md:text-8xl font-black italic tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-purple-400 mb-6 uppercase drop-shadow-2xl">
FUN GAME
</h2>
<div className="bg-white/5 backdrop-blur-xl border border-white/10 px-8 py-4 rounded-full inline-flex items-center gap-3 mb-8">
<Construction size={18} className="text-yellow-400"/>
<span className="text-sm font-black uppercase tracking-[0.2em] text-gray-300">Under Construction</span>
</div>
<p className="text-gray-400 text-xl font-medium leading-relaxed max-w-lg mx-auto">
We are crafting a new experience. Check back soon for the ultimate fun protocol.
</p>
</div>
{/* Decorative elements */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-pink-600/5 rounded-full blur-[150px] pointer-events-none"></div>
</div>
);
};

863
games/SpeedZone.tsx Normal file
View File

@ -0,0 +1,863 @@
import React, { useState, useEffect, useRef, useReducer, useMemo } from 'react';
import {
Trophy, Zap, ShoppingBag, ArrowLeft, Shield, Star,
Settings, Activity, Cpu, Layers, HardDrive,
Flame, Crosshair, MapPin, Gauge, AlertOctagon,
ChevronRight, Lock, Unlock, Play, Pause, RotateCcw
} from 'lucide-react';
import { StorageService } from '../services/storageService';
/**
* SPEEDZONE: OMEGA PROTOCOL (V3.1)
* ================================
* Removed in-game currency.
* Progression is now based on Pilot Level (XP).
* Game Over awards global Karma points.
*/
// --- 1. CONSTANTS & CONFIGURATION ---
const DB_KEY = 'SPEEDZONE_OMEGA_V2'; // Version bump for data migration
const FPS = 60;
const LANE_WIDTH = 140;
const CANVAS_WIDTH = 600;
const CANVAS_HEIGHT = 800;
// Color Palettes for different Biomes
const BIOMES = [
{
id: 'NEON_CITY', name: 'Neo-Tokyo',
road: '#1e293b', line: '#facc15', grid: 'rgba(250, 204, 21, 0.1)',
fog: 'rgba(30, 41, 59, 0)', sky: '#0f172a'
},
{
id: 'WASTELAND', name: 'Mars Outpost',
road: '#2b1212', line: '#f97316', grid: 'rgba(249, 115, 22, 0.1)',
fog: 'rgba(43, 18, 18, 0)', sky: '#1a0505'
},
{
id: 'VOID', name: 'The Null Zone',
road: '#000000', line: '#a855f7', grid: 'rgba(168, 85, 247, 0.15)',
fog: 'rgba(0,0,0,0)', sky: '#000000'
}
];
// Ships unlock at specific levels
const SHIPS = [
{ id: 'alpha', name: 'Yellow Cab', requiredLevel: 1, baseSpeed: 1.0, baseHandling: 1.0, baseNitro: 1.0, color: '#facc15' },
{ id: 'beta', name: 'Turbo Taxi', requiredLevel: 5, baseSpeed: 1.1, baseHandling: 0.8, baseNitro: 1.3, color: '#fbbf24' },
{ id: 'gamma', name: 'Crazy Cab', requiredLevel: 10, baseSpeed: 1.3, baseHandling: 1.4, baseNitro: 1.1, color: '#f59e0b' },
{ id: 'omega', name: 'King Cab', requiredLevel: 20, baseSpeed: 1.6, baseHandling: 1.5, baseNitro: 2.0, color: '#d97706' },
];
const POWERUPS = {
SHIELD: { color: '#06b6d4', duration: 5000 },
MAGNET: { color: '#eab308', duration: 8000 },
DOUBLE: { color: '#8b5cf6', duration: 10000 },
};
// --- 2. TYPES & INTERFACES ---
type GameState = 'MENU' | 'PLAYING' | 'PAUSED' | 'GAMEOVER' | 'GARAGE';
interface Entity {
id: string;
x: number;
y: number;
w: number;
h: number;
type: 'PLAYER' | 'RIVAL' | 'HAZARD_STATIC' | 'COIN' | 'POWERUP';
subtype?: string; // e.g., 'TRUCK', 'SHIELD'
color: string;
speed: number;
lane: number;
dead?: boolean;
}
interface Particle {
id: number;
x: number;
y: number;
vx: number;
vy: number;
life: number; // 0.0 to 1.0
decay: number;
color: string;
size: number;
type: 'SPARK' | 'SMOKE' | 'GLOW' | 'TEXT' | 'FIRE';
text?: string;
}
interface UserProfile {
xp: number;
level: number;
highScore: number;
activeShip: string;
upgrades: {
speed: number; // Level 0-5
handling: number; // Level 0-5
nitro: number; // Level 0-5
};
}
// --- 3. UTILITY FUNCTIONS ---
const randomRange = (min: number, max: number) => Math.random() * (max - min) + min;
const lerp = (start: number, end: number, t: number) => start * (1 - t) + end * t;
const checkRectCollision = (r1: Entity, r2: Entity, padding = 0) => {
return (
r1.x < r2.x + r2.w - padding &&
r1.x + r1.w > r2.x + padding &&
r1.y < r2.y + r2.h - padding &&
r1.h + r1.y > r2.y + padding
);
};
// --- 4. MAIN ENGINE COMPONENT ---
export const SpeedZone: React.FC<{ onExit: () => void }> = ({ onExit }) => {
// --- REACT STATE (UI) ---
const canvasRef = useRef<HTMLCanvasElement>(null);
const [gameState, setGameState] = useState<GameState>('MENU');
const [profile, setProfile] = useState<UserProfile>(() => {
try {
const saved = localStorage.getItem(DB_KEY);
return saved ? JSON.parse(saved) : {
xp: 0, level: 1, highScore: 0,
activeShip: 'alpha',
upgrades: { speed: 0, handling: 0, nitro: 0 }
};
} catch { return { xp: 0, level: 1, highScore: 0, activeShip: 'alpha', upgrades: { speed: 0, handling: 0, nitro: 0 } }; }
});
// Persistent Save
useEffect(() => {
localStorage.setItem(DB_KEY, JSON.stringify(profile));
}, [profile]);
// --- GAME ENGINE STATE (MUTABLE REFS) ---
const engine = useRef({
// Core
lastTime: 0,
deltaTime: 0,
score: 0,
distance: 0,
speed: 0,
targetSpeed: 0,
// World
biomeIdx: 0,
roadOffset: 0,
shake: 0,
// Player
lane: 1, // 0, 1, 2, 3
x: 300,
y: 600,
nitro: 100, // FUEL: 0-100
isNitro: false,
shield: 0, // Time remaining
magnet: 0,
doublePoints: 0,
// Entities
entities: [] as Entity[],
particles: [] as Particle[],
// Timers
lastSpawn: 0,
});
const requestRef = useRef<number>(0);
const triggerShake = (amount: number) => {
engine.current.shake = amount;
};
const spawnParticle = (x: number, y: number, type: Particle['type'], color: string, count = 1, text?: string) => {
for (let i = 0; i < count; i++) {
engine.current.particles.push({
id: Math.random(),
x, y,
vx: (Math.random() - 0.5) * (type === 'SPARK' ? 10 : type === 'FIRE' ? 2 : 4),
vy: (Math.random() - 0.5) * (type === 'SPARK' ? 10 : 4) + (type === 'SMOKE' ? 5 : type === 'FIRE' ? 10 : 0),
life: 1.0,
decay: type === 'TEXT' ? 0.015 : type === 'FIRE' ? 0.05 : randomRange(0.02, 0.05),
color,
size: randomRange(2, type === 'SMOKE' ? 15 : type === 'FIRE' ? 20 : 6),
type,
text
});
}
};
const spawnEntity = (currentSpeed: number) => {
const lane = Math.floor(Math.random() * 4); // 4 Lanes
const x = (lane * LANE_WIDTH) + (CANVAS_WIDTH - (4 * LANE_WIDTH)) / 2 + (LANE_WIDTH/2) - 35; // Centered
const typeRoll = Math.random();
let entity: Entity = {
id: Math.random().toString(),
x, y: -200, w: 70, h: 140,
lane,
speed: 0,
type: 'HAZARD_STATIC',
color: '#fff'
};
if (typeRoll > 0.95) {
entity.type = 'POWERUP';
entity.w = 40; entity.h = 40;
entity.color = '#fff';
const pType = Math.random();
if (pType < 0.33) { entity.subtype = 'SHIELD'; entity.color = POWERUPS.SHIELD.color; }
else if (pType < 0.66) { entity.subtype = 'MAGNET'; entity.color = POWERUPS.MAGNET.color; }
else { entity.subtype = 'DOUBLE'; entity.color = POWERUPS.DOUBLE.color; }
}
else if (typeRoll > 0.8) {
// Coin acts as small XP boost
entity.type = 'COIN';
entity.w = 30; entity.h = 30;
entity.color = '#fbbf24';
}
else if (typeRoll > 0.6) {
entity.type = 'RIVAL';
entity.w = 70; entity.h = 130;
entity.color = '#1e293b';
entity.speed = currentSpeed * 0.7;
}
else {
entity.type = 'HAZARD_STATIC';
entity.w = 80; entity.h = 150;
entity.color = '#64748b';
entity.speed = currentSpeed * 0.4;
}
engine.current.entities.push(entity);
};
const update = (time: number) => {
if (gameState !== 'PLAYING') return;
const S = engine.current;
if (!S.lastTime) S.lastTime = time;
const dt = (time - S.lastTime) / 16.67;
S.lastTime = time;
S.deltaTime = dt;
const activeShip = SHIPS.find(s => s.id === profile.activeShip) || SHIPS[0];
const upgrades = profile.upgrades;
const maxSpeed = (activeShip.baseSpeed + (upgrades.speed * 0.1)) * 20;
const handling = (activeShip.baseHandling + (upgrades.handling * 0.1)) * 0.2;
const nitroBurn = 0.5 - (upgrades.nitro * 0.05);
if (S.isNitro && S.nitro > 0) {
S.targetSpeed = maxSpeed * 2.0;
S.nitro -= nitroBurn * dt;
triggerShake(3);
if (Math.random() > 0.3) {
spawnParticle(S.x + 15, S.y + 130, 'FIRE', '#ef4444', 1);
spawnParticle(S.x + 55, S.y + 130, 'FIRE', '#f59e0b', 1);
}
} else {
S.targetSpeed = maxSpeed;
}
S.nitro = Math.max(0, Math.min(100, S.nitro));
S.speed = lerp(S.speed, S.targetSpeed, 0.05 * dt);
const oldDistanceBlock = Math.floor(S.distance / 100);
S.distance += S.speed * dt;
const newDistanceBlock = Math.floor(S.distance / 100);
if (newDistanceBlock > oldDistanceBlock) {
S.nitro = Math.min(100, S.nitro + 10);
}
S.score += Math.floor(S.speed * 0.1 * (S.doublePoints > 0 ? 2 : 1) * dt);
const targetX = (S.lane * LANE_WIDTH) + (CANVAS_WIDTH - (4 * LANE_WIDTH)) / 2 + (LANE_WIDTH/2) - 35;
S.x = lerp(S.x, targetX, handling * dt);
S.x = Math.max(0, Math.min(CANVAS_WIDTH - 70, S.x));
if (S.shield > 0) S.shield -= 16 * dt;
if (S.magnet > 0) S.magnet -= 16 * dt;
if (S.doublePoints > 0) S.doublePoints -= 16 * dt;
const spawnRate = Math.max(400, 1500 - (S.speed * 20));
if (time - S.lastSpawn > spawnRate) {
spawnEntity(S.speed);
S.lastSpawn = time;
}
S.entities.forEach((e) => {
const relSpeed = S.speed - e.speed;
e.y += relSpeed * dt;
if (e.type === 'RIVAL') {
e.speed += 0.01 * dt;
}
if (S.magnet > 0 && e.type === 'COIN') {
e.x = lerp(e.x, S.x, 0.1 * dt);
e.y = lerp(e.y, S.y, 0.1 * dt);
}
const playerRect: Entity = {
id: 'player',
x: S.x + 10,
y: S.y + 10,
w: 50,
h: 110,
type: 'PLAYER',
lane: 0,
speed: 0,
color: ''
};
if (checkRectCollision(playerRect, e)) {
if (e.type === 'COIN') {
e.dead = true;
// Coins act as small score/xp boosts now, no credits
S.score += 50;
spawnParticle(e.x, e.y, 'TEXT', '#fbbf24', 1, '+50');
}
else if (e.type === 'POWERUP') {
e.dead = true;
if (e.subtype === 'SHIELD') S.shield = POWERUPS.SHIELD.duration;
if (e.subtype === 'MAGNET') S.magnet = POWERUPS.MAGNET.duration;
if (e.subtype === 'DOUBLE') S.doublePoints = POWERUPS.DOUBLE.duration;
spawnParticle(S.x, S.y, 'GLOW', e.color, 10);
}
else if (!e.dead) {
if (S.shield > 0) {
e.dead = true;
S.shield = 0;
triggerShake(10);
spawnParticle(e.x, e.y, 'SPARK', '#06b6d4', 20);
} else {
handleGameOver();
}
}
}
if (e.y > CANVAS_HEIGHT + 100) e.dead = true;
});
S.entities = S.entities.filter(e => !e.dead);
for (let i = S.particles.length - 1; i >= 0; i--) {
const p = S.particles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= p.decay * dt;
if (p.life <= 0) S.particles.splice(i, 1);
}
if (S.shake > 0) S.shake *= 0.9;
draw();
requestRef.current = requestAnimationFrame(update);
};
const draw = () => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
const S = engine.current;
const biome = BIOMES[S.biomeIdx];
ctx.save();
if (S.shake > 0.5) {
ctx.translate((Math.random() - 0.5) * S.shake, (Math.random() - 0.5) * S.shake);
}
const grad = ctx.createLinearGradient(0, 0, 0, CANVAS_HEIGHT);
grad.addColorStop(0, biome.sky);
grad.addColorStop(1, biome.road);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
ctx.strokeStyle = biome.grid;
ctx.lineWidth = 2;
const offsetX = (CANVAS_WIDTH - (4 * LANE_WIDTH)) / 2;
for (let i = 0; i <= 4; i++) {
const x = offsetX + (i * LANE_WIDTH);
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, CANVAS_HEIGHT);
ctx.stroke();
}
S.roadOffset = (S.roadOffset + S.speed * 0.5) % 100;
ctx.strokeStyle = biome.grid;
for (let i = 0; i < 20; i++) {
const y = (i * 50 + S.roadOffset) % CANVAS_HEIGHT;
ctx.beginPath();
ctx.moveTo(offsetX, y);
ctx.lineTo(CANVAS_WIDTH - offsetX, y);
ctx.stroke();
}
S.entities.forEach(e => {
ctx.save();
ctx.translate(e.x + e.w/2, e.y + e.h/2);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(-e.w/2 + 10, -e.h/2 + 10, e.w, e.h);
if (e.type === 'COIN') {
ctx.fillStyle = e.color;
ctx.beginPath(); ctx.arc(0, 0, e.w/2, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = '20px monospace'; ctx.textAlign = 'center'; ctx.fillText('★', 0, 6);
}
else if (e.type === 'POWERUP') {
ctx.fillStyle = e.color;
ctx.rotate(Date.now() / 200);
ctx.fillRect(-e.w/2, -e.h/2, e.w, e.h);
}
else {
ctx.fillStyle = e.color;
ctx.beginPath();
ctx.roundRect(-e.w/2, -e.h/2, e.w, e.h, 10);
ctx.fill();
ctx.fillStyle = '#ef4444';
ctx.fillRect(-e.w/2 + 5, e.h/2 - 10, 15, 5);
ctx.fillRect(e.w/2 - 20, e.h/2 - 10, 15, 5);
ctx.fillStyle = '#334155';
ctx.fillRect(-e.w/2 + 5, -e.h/2 + 10, e.w - 10, 20);
}
ctx.restore();
});
const shipDef = SHIPS.find(s => s.id === profile.activeShip);
ctx.save();
ctx.translate(S.x + 35, S.y + 70);
const tilt = (S.lane * LANE_WIDTH + offsetX + LANE_WIDTH/2 - 35 - S.x) * -0.08;
ctx.rotate(tilt * Math.PI / 180);
if (S.shield > 0) {
ctx.shadowBlur = 20;
ctx.shadowColor = '#06b6d4';
ctx.strokeStyle = '#06b6d4';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(0, 0, 80, 0, Math.PI*2); ctx.stroke();
}
ctx.fillStyle = shipDef?.color || '#facc15';
ctx.shadowBlur = S.isNitro ? 20 : 5;
ctx.shadowColor = shipDef?.color || '#facc15';
ctx.beginPath();
ctx.roundRect(-30, -60, 60, 120, 12);
ctx.fill();
ctx.fillStyle = '#eab308';
ctx.beginPath();
ctx.roundRect(-25, -20, 50, 50, 8);
ctx.fill();
ctx.fillStyle = '#1e293b';
ctx.beginPath();
ctx.roundRect(-22, -45, 44, 15, 4);
ctx.fill();
ctx.beginPath();
ctx.roundRect(-22, 35, 44, 10, 4);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.fillRect(-15, -5, 30, 10);
ctx.fillStyle = '#000';
ctx.font = 'bold 8px monospace';
ctx.textAlign = 'center';
ctx.fillText('TAXI', 0, 3);
ctx.fillStyle = '#000';
for(let i=0; i<6; i++) {
if (i%2===0) ctx.fillRect(-30, -50 + i*20, 5, 10);
else ctx.fillRect(25, -50 + i*20, 5, 10);
}
ctx.fillStyle = '#fef08a';
ctx.shadowBlur = 10;
ctx.shadowColor = '#fef08a';
ctx.beginPath();
ctx.roundRect(-25, -62, 12, 6, 2);
ctx.roundRect(13, -62, 12, 6, 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#dc2626';
ctx.beginPath();
ctx.roundRect(-25, 58, 12, 4, 2);
ctx.roundRect(13, 58, 12, 4, 2);
ctx.fill();
if (S.isNitro && S.nitro > 0) {
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.moveTo(-18, 62);
ctx.lineTo(-24, 62 + Math.random() * 40 + 20);
ctx.lineTo(-12, 62);
ctx.fill();
ctx.beginPath();
ctx.moveTo(18, 62);
ctx.lineTo(24, 62 + Math.random() * 40 + 20);
ctx.lineTo(12, 62);
ctx.fill();
ctx.fillStyle = '#facc15';
ctx.beginPath();
ctx.moveTo(-18, 62);
ctx.lineTo(-22, 62 + Math.random() * 20 + 10);
ctx.lineTo(-14, 62);
ctx.fill();
ctx.beginPath();
ctx.moveTo(18, 62);
ctx.lineTo(22, 62 + Math.random() * 20 + 10);
ctx.lineTo(14, 62);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
}
ctx.restore();
S.particles.forEach(p => {
ctx.globalAlpha = p.life;
if (p.type === 'TEXT') {
ctx.fillStyle = '#fff';
ctx.font = 'bold 20px Arial';
ctx.fillText(p.text || '', p.x, p.y);
} else if (p.type === 'FIRE') {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.size/2, p.y + p.size);
ctx.lineTo(p.x + p.size/2, p.y + p.size);
ctx.fill();
} else {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI*2);
ctx.fill();
}
});
ctx.globalAlpha = 1.0;
const gradV = ctx.createRadialGradient(CANVAS_WIDTH/2, CANVAS_HEIGHT/2, 200, CANVAS_WIDTH/2, CANVAS_HEIGHT/2, 500);
gradV.addColorStop(0, 'transparent');
gradV.addColorStop(1, 'rgba(0,0,0,0.8)');
ctx.fillStyle = gradV;
ctx.fillRect(0,0,CANVAS_WIDTH, CANVAS_HEIGHT);
ctx.restore();
};
const handleGameOver = () => {
setGameState('GAMEOVER');
const finalScore = Math.floor(engine.current.score);
// Award Karma to global profile with transaction log
StorageService.addPoints(Math.floor(finalScore / 10), finalScore, 'game_reward', 'Speed Zone Completion');
// Update Local Profile Level
const xpEarned = finalScore;
setProfile(prev => ({
...prev,
xp: prev.xp + xpEarned,
level: Math.floor((prev.xp + xpEarned) / 1000) + 1,
highScore: Math.max(prev.highScore, finalScore)
}));
};
const handleStart = () => {
engine.current = {
...engine.current,
score: 0, distance: 0, speed: 0,
entities: [], particles: [],
shield: 0, magnet: 0, doublePoints: 0,
nitro: 100, lastTime: 0
};
setGameState('PLAYING');
requestRef.current = requestAnimationFrame(update);
};
const handleTouch = (e: React.TouchEvent | React.MouseEvent) => {
if (gameState !== 'PLAYING') return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
const x = clientX - rect.left;
const mid = rect.width / 2;
if (x < mid && engine.current.lane > 0) engine.current.lane--;
else if (x >= mid && engine.current.lane < 3) engine.current.lane++;
};
const handleInput = (e: KeyboardEvent) => {
if (gameState !== 'PLAYING') return;
if (e.key === 'ArrowLeft' && engine.current.lane > 0) engine.current.lane--;
if (e.key === 'ArrowRight' && engine.current.lane < 3) engine.current.lane++;
if (e.key === 'ArrowUp') engine.current.isNitro = true;
if (e.key === 'Escape') setGameState(prev => prev === 'PAUSED' ? 'PLAYING' : 'PAUSED');
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'ArrowUp') engine.current.isNitro = false;
};
useEffect(() => {
window.addEventListener('keydown', handleInput);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleInput);
window.removeEventListener('keyup', handleKeyUp);
cancelAnimationFrame(requestRef.current);
};
}, [gameState]);
useEffect(() => {
if (gameState === 'PLAYING') {
engine.current.lastTime = performance.now();
requestRef.current = requestAnimationFrame(update);
}
}, [gameState]);
return (
<div className="fixed inset-0 bg-black text-white font-sans overflow-hidden select-none">
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-full h-full max-w-xl aspect-[3/4] max-h-screen md:border-x-2 md:border-slate-800 shadow-2xl bg-black" onMouseDown={handleTouch} onTouchStart={handleTouch}>
<canvas ref={canvasRef} width={600} height={800} className="w-full h-full object-cover block" />
{gameState === 'PLAYING' && (
<div className="absolute inset-0 p-6 flex flex-col justify-between pointer-events-none">
<div className="flex justify-between items-start">
<div className="flex flex-col gap-1">
<div className="text-6xl font-black italic tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-white to-slate-400">
{Math.floor(engine.current.speed * 6)} <span className="text-lg text-slate-500">MPH</span>
</div>
<div className="flex items-center gap-2">
<div className="px-3 py-1 bg-yellow-500/20 text-yellow-500 rounded text-xs font-bold border border-yellow-500/50">
SCORE: {Math.floor(engine.current.score)}
</div>
</div>
</div>
<div className="flex flex-col gap-2 items-end">
{engine.current.shield > 0 && <div className="px-3 py-1 bg-cyan-500/20 text-cyan-400 border border-cyan-500 rounded font-bold text-xs animate-pulse">SHIELD ACTIVE</div>}
{engine.current.magnet > 0 && <div className="px-3 py-1 bg-yellow-500/20 text-yellow-400 border border-yellow-500 rounded font-bold text-xs animate-pulse">MAGNET ACTIVE</div>}
{engine.current.doublePoints > 0 && <div className="px-3 py-1 bg-purple-500/20 text-purple-400 border border-purple-500 rounded font-bold text-xs animate-pulse">2X SCORE</div>}
</div>
</div>
<div className="w-full max-w-md mx-auto">
<div className="flex justify-between text-xs font-black uppercase tracking-widest text-slate-500 mb-1">
<span>Nitro Fuel</span>
<span className={engine.current.nitro < 20 ? "text-red-500 animate-bounce" : "text-cyan-500"}>{Math.floor(engine.current.nitro)}%</span>
</div>
<div className="h-4 bg-slate-900/80 backdrop-blur border border-slate-700 rounded-full p-0.5">
<div
className="h-full rounded-full transition-all duration-75 shadow-[0_0_15px_currentColor]"
style={{
width: `${engine.current.nitro}%`,
backgroundColor: engine.current.nitro < 30 ? '#ef4444' : '#06b6d4',
color: engine.current.nitro < 30 ? '#ef4444' : '#06b6d4'
}}
/>
</div>
<p className="text-[9px] text-center mt-1 text-slate-500 uppercase tracking-widest">+10% Charge per 100m</p>
</div>
</div>
)}
</div>
</div>
{gameState === 'MENU' && (
<div className="absolute inset-0 bg-black/80 backdrop-blur-md flex items-center justify-center z-50 p-4">
<div className="max-w-4xl w-full grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 p-4 md:p-8">
<div className="flex flex-col justify-center text-center md:text-left">
<div className="flex items-center justify-center md:justify-start gap-3 mb-6">
<span className="bg-red-600 text-white text-[10px] font-black px-2 py-1 uppercase tracking-widest rounded">Omega Protocol</span>
<span className="text-slate-500 text-[10px] font-black uppercase tracking-widest">V3.1.0</span>
</div>
<h1 className="text-6xl md:text-8xl font-black italic tracking-tighter uppercase leading-[0.8] mb-8 text-transparent bg-clip-text bg-gradient-to-br from-white via-slate-200 to-slate-600">
Speed<br/><span className="text-red-600">Zone</span>
</h1>
<div className="flex justify-center md:justify-start gap-4 mb-12">
<div className="bg-slate-900 p-4 rounded-2xl border border-slate-800">
<div className="text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">Pilot Rank</div>
<div className="text-2xl font-black text-white">LVL {profile.level}</div>
</div>
<div className="bg-slate-900 p-4 rounded-2xl border border-slate-800 min-w-[120px]">
<div className="text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">Best Score</div>
<div className="text-2xl font-black text-yellow-400">{profile.highScore.toLocaleString()}</div>
</div>
</div>
<div className="flex flex-col md:flex-row gap-4">
<button onClick={handleStart} className="flex-1 bg-white text-black h-16 rounded-xl font-black text-xl uppercase tracking-widest hover:bg-red-600 hover:text-white transition-all flex items-center justify-center gap-3 group">
<Play size={24} className="group-hover:fill-current" /> Initialize
</button>
<div className="flex gap-4">
<button onClick={() => setGameState('GARAGE')} className="flex-1 md:w-16 h-16 bg-slate-800 rounded-xl flex items-center justify-center hover:bg-slate-700 transition-all border border-slate-700">
<ShoppingBag size={24} className="text-slate-300" />
</button>
<button onClick={onExit} className="flex-1 md:w-16 h-16 bg-slate-800 rounded-xl flex items-center justify-center hover:bg-red-900/50 hover:border-red-500 transition-all border border-slate-700 group">
<ArrowLeft size={24} className="text-slate-300 group-hover:text-red-500" />
</button>
</div>
</div>
</div>
<div className="hidden md:flex items-center justify-center relative">
<div className="absolute inset-0 bg-red-600/20 blur-[100px] rounded-full" />
<div className="relative w-64 h-96 bg-slate-900/50 backdrop-blur-xl border border-white/10 rounded-[3rem] p-8 flex flex-col items-center justify-center transform rotate-6 hover:rotate-0 transition-all duration-500">
<div className="w-20 h-20 bg-red-500 rounded-2xl mb-8 shadow-[0_0_50px_rgba(239,68,68,0.5)] animate-pulse" />
<h3 className="text-2xl font-black uppercase italic text-center mb-2">Ready to Race</h3>
<p className="text-slate-400 text-center text-xs font-medium leading-relaxed">
Earn Pilot XP to unlock advanced chassis prototypes. Global Karma awarded on completion.
</p>
</div>
</div>
</div>
</div>
)}
{/* --- GARAGE UI --- */}
{gameState === 'GARAGE' && (
<div className="absolute inset-0 bg-slate-950 z-50 flex flex-col">
<div className="p-6 md:p-8 flex justify-between items-center border-b border-white/5 bg-slate-900/50 backdrop-blur">
<div>
<h2 className="text-3xl md:text-4xl font-black italic uppercase tracking-tighter">The Hangar</h2>
<p className="text-slate-500 text-xs font-bold uppercase tracking-widest">Level Up to Unlock</p>
</div>
<div className="flex items-center gap-4 md:gap-6">
<div className="text-right hidden md:block">
<div className="text-[10px] text-slate-500 font-black uppercase">Current Level</div>
<div className="text-2xl font-mono font-black text-white">{profile.level}</div>
</div>
<button onClick={() => setGameState('MENU')} className="bg-white/10 p-3 md:p-4 rounded-xl hover:bg-white text-white hover:text-black transition-all">
<ArrowLeft size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 md:p-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-4">
<h3 className="text-lg font-black uppercase text-slate-500 mb-4">Chassis Selection</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{SHIPS.map(ship => {
const unlocked = profile.level >= ship.requiredLevel;
const active = profile.activeShip === ship.id;
return (
<button
key={ship.id}
onClick={() => {
if (unlocked) setProfile(p => ({ ...p, activeShip: ship.id }));
}}
className={`relative p-6 rounded-3xl border-2 text-left transition-all group overflow-hidden ${active ? 'border-red-600 bg-red-600/10' : 'border-slate-800 bg-slate-900 hover:border-slate-600'} ${!unlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<div className="flex justify-between items-start mb-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${active ? 'bg-red-600 text-white' : 'bg-slate-800 text-slate-400'}`}>
{unlocked ? <Unlock size={18}/> : <Lock size={18}/>}
</div>
{!unlocked && <span className="text-red-400 font-mono font-black text-sm">LVL {ship.requiredLevel}</span>}
</div>
<div className="relative z-10">
<div className="text-2xl font-black italic uppercase mb-1">{ship.name}</div>
<div className="flex gap-2">
<div className="h-1 w-8 bg-slate-700 rounded-full overflow-hidden"><div style={{width: `${ship.baseSpeed*50}%`}} className="h-full bg-white"/></div>
<div className="h-1 w-8 bg-slate-700 rounded-full overflow-hidden"><div style={{width: `${ship.baseNitro*50}%`}} className="h-full bg-blue-500"/></div>
</div>
</div>
<div className={`absolute -right-4 -bottom-4 w-32 h-32 blur-[40px] rounded-full opacity-20 transition-opacity`} style={{background: ship.color}} />
</button>
);
})}
</div>
</div>
<div className="bg-slate-900 rounded-[2rem] md:rounded-[3rem] p-6 md:p-10 border border-slate-800">
<h3 className="text-lg font-black uppercase text-slate-500 mb-8">Performance Tuning</h3>
<div className="space-y-6 md:space-y-8">
{[
{ id: 'speed', label: 'Engine Output', icon: Gauge, desc: 'Increases top speed cap.' },
{ id: 'handling', label: 'Traction Control', icon: Activity, desc: 'Improves lane switching response.' },
{ id: 'nitro', label: 'Reactor Efficiency', icon: Flame, desc: 'Reduces nitro fuel consumption.' }
].map((stat: any) => {
// Upgrades unlock every 5 levels for simplicity in this no-currency model
// Actually, let's just make upgrades tied to level.
// Level 1 = 0 upgrades. Level 2 = 1 upgrade available to apply?
// Simplest: Auto-scale upgrades with Level up to max 5.
const level = Math.min(5, Math.floor((profile.level - 1) / 2));
return (
<div key={stat.id} className="bg-black/40 p-4 md:p-6 rounded-3xl border border-white/5">
<div className="flex items-center gap-4 mb-4">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400">
<stat.icon size={18} />
</div>
<div>
<div className="font-black uppercase text-sm">{stat.label}</div>
<div className="text-xs text-slate-500">{stat.desc}</div>
</div>
<div className="ml-auto text-xl font-black italic text-slate-700">TIER {level}</div>
</div>
<div className="w-full h-3 bg-slate-800 rounded-full overflow-hidden flex gap-0.5 p-0.5">
{[0,1,2,3,4].map(i => (
<div key={i} className={`flex-1 rounded-sm ${i < level ? 'bg-red-500' : 'bg-slate-700'}`} />
))}
</div>
<p className="text-[9px] text-slate-500 uppercase tracking-widest mt-2">Auto-scales with Pilot Level</p>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
{gameState === 'GAMEOVER' && (
<div className="absolute inset-0 bg-red-950/90 backdrop-blur-xl flex items-center justify-center z-[100] animate-in zoom-in duration-300 p-4">
<div className="text-center w-full max-w-lg">
<AlertOctagon size={80} className="text-red-500 mb-6 mx-auto animate-bounce" />
<h2 className="text-7xl md:text-9xl font-black italic uppercase tracking-tighter text-white mb-2 leading-none">Wrecked</h2>
<p className="text-red-200 uppercase tracking-[0.5em] font-bold text-xs mb-12">Critical Hull Failure</p>
<div className="bg-black/40 p-8 md:p-10 rounded-[2rem] md:rounded-[3rem] border border-white/10 mb-10 w-full">
<div className="grid grid-cols-2 gap-8 text-left">
<div>
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Total Distance</div>
<div className="text-3xl md:text-4xl font-mono font-black text-white">{Math.floor(engine.current.distance)}<span className="text-sm text-slate-500 ml-2">M</span></div>
</div>
<div>
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Score</div>
<div className="text-3xl md:text-4xl font-mono font-black text-yellow-400">{Math.floor(engine.current.score)}</div>
</div>
</div>
</div>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button onClick={handleStart} className="bg-white text-black px-12 py-6 rounded-2xl font-black uppercase text-xl hover:scale-105 transition-transform flex items-center justify-center gap-3">
<RotateCcw size={24}/> Retry
</button>
<button onClick={() => setGameState('MENU')} className="bg-black/40 text-white px-8 py-6 rounded-2xl font-black uppercase text-xl hover:bg-black/60 transition-colors">
Menu
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default SpeedZone;

233
games/TruthTrek.tsx Normal file
View File

@ -0,0 +1,233 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, Play, RefreshCw, Trophy } from 'lucide-react';
import { StorageService } from '../services/storageService';
interface GameProps {
onExit: () => void;
}
export const TruthTrek: React.FC<GameProps> = ({ onExit }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [gameOver, setGameOver] = useState(false);
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('medium');
const MASTER_QUESTIONS = [
{ question: "2 + 2 = 4", answer: true, trueLabel: "True", falseLabel: "False" },
{ question: "Nepal is in Europe", answer: false, trueLabel: "True", falseLabel: "False" },
{ question: "Water boils at 100°C", answer: true, trueLabel: "True", falseLabel: "False" },
{ question: "The Earth is flat", answer: false, trueLabel: "True", falseLabel: "False" },
{ question: "Sagarmatha is 8848m", answer: true, trueLabel: "True", falseLabel: "False" },
{ question: "Zebra is a fish", answer: false, trueLabel: "True", falseLabel: "False" }
];
const gameState = useRef({
lane: 0, gates: [] as any[], particles: [] as any[], torches: [] as any[],
speed: 3, score: 0, frameCount: 0, isDying: false
});
const requestRef = useRef<number>(0);
useEffect(() => {
const loadHighScore = async () => {
const profile = await StorageService.getProfile();
if (profile?.highScores?.truth) {
setHighScore(profile.highScores.truth);
}
};
loadHighScore();
for(let i=0; i<10; i++) gameState.current.torches.push({y: i*100, frame: Math.random()*100});
return () => cancelAnimationFrame(requestRef.current);
}, []);
const startGame = () => {
cancelAnimationFrame(requestRef.current);
setIsPlaying(true); setGameOver(false); setScore(0);
gameState.current = {
lane: 0, gates: [{y: -200, ...MASTER_QUESTIONS[0], passed: false}],
particles: [], torches: gameState.current.torches,
speed: difficulty === 'easy' ? 3 : difficulty === 'medium' ? 5 : 7,
score: 0, frameCount: 0, isDying: false
};
requestRef.current = requestAnimationFrame(gameLoop);
};
const switchLane = () => {
if (gameState.current.isDying) return;
gameState.current.lane = gameState.current.lane === 0 ? 1 : 0;
};
const gameLoop = () => {
const ctx = canvasRef.current?.getContext('2d');
if(!ctx || !canvasRef.current) return;
const W = 800; const H = 450;
const S = gameState.current;
if (!S.isDying) {
S.frameCount++; S.gates.forEach(g => g.y += S.speed);
S.torches.forEach(t => { t.y += S.speed; if(t.y > H) t.y = -50; t.frame++; });
if (S.gates[S.gates.length - 1].y > 200) {
const q = MASTER_QUESTIONS[Math.floor(Math.random() * MASTER_QUESTIONS.length)];
S.gates.push({y: -250, ...q, passed: false});
}
if (S.gates[0].y > H) S.gates.shift();
const pY = H - 80;
S.gates.forEach(g => {
if (!g.passed && g.y > pY - 30 && g.y < pY + 30) {
const isCorrect = (S.lane === 0 && g.answer) || (S.lane === 1 && !g.answer);
if (isCorrect) {
g.passed = true; S.score += 10; setScore(S.score);
for(let i=0; i<15; i++) S.particles.push({x: S.lane===0?W/4:W*0.75, y: pY, vx: Math.random()*6-3, vy: Math.random()*6-3, l: 20, c: '#4ade80'});
} else {
S.isDying = true;
setTimeout(() => {
setGameOver(true); setIsPlaying(false);
const finalScore = S.score;
if (finalScore > highScore) setHighScore(finalScore);
StorageService.saveHighScore('truth', finalScore);
StorageService.addPoints(Math.floor(finalScore / 2), finalScore, 'game_reward', 'Truth Trek Expedition');
}, 1000);
}
}
});
}
const grad = ctx.createLinearGradient(0,0,W,0); grad.addColorStop(0, '#1e1b4b'); grad.addColorStop(0.5, '#312e81'); grad.addColorStop(1, '#1e1b4b');
ctx.fillStyle = grad; ctx.fillRect(0,0,W,H);
ctx.strokeStyle = 'rgba(99, 102, 241, 0.2)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(W/2, 0); ctx.lineTo(W/2, H); ctx.stroke();
S.torches.forEach(t => {
const flicker = Math.sin(t.frame * 0.5) * 5;
ctx.fillStyle = '#ea580c';
ctx.beginPath(); ctx.arc(20, t.y, 5 + flicker/2, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(W-20, t.y, 5 + flicker/2, 0, Math.PI*2); ctx.fill();
});
S.gates.forEach(g => {
const scale = 1 + (g.y / H);
const w = 180 * scale; const h = 100 * scale;
ctx.fillStyle = 'rgba(17, 24, 39, 0.9)'; ctx.fillRect((W-w*2)/2, g.y - 80, w*2, 60);
ctx.fillStyle = 'white'; ctx.font = `bold ${Math.max(12, 16*scale)}px sans-serif`; ctx.textAlign='center';
ctx.fillText(g.question, W/2, g.y - 45);
ctx.fillStyle = 'rgba(34, 197, 94, 0.2)'; ctx.strokeStyle = '#22c55e'; ctx.lineWidth = 4;
ctx.fillRect(W/2 - w - 10, g.y, w, h); ctx.strokeRect(W/2 - w - 10, g.y, w, h);
ctx.fillStyle = '#4ade80'; ctx.fillText(g.trueLabel, W/2 - w/2 - 10, g.y + h/2);
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)'; ctx.strokeStyle = '#ef4444';
ctx.fillRect(W/2 + 10, g.y, w, h); ctx.strokeRect(W/2 + 10, g.y, w, h);
ctx.fillStyle = '#f87171'; ctx.fillText(g.falseLabel, W/2 + w/2 + 10, g.y + h/2);
});
if (!S.isDying) {
const px = S.lane === 0 ? W/4 : W*0.75;
ctx.shadowBlur = 20; ctx.shadowColor = '#60a5fa';
ctx.fillStyle = '#3b82f6'; ctx.beginPath(); ctx.arc(px, H-80, 25, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'white'; ctx.beginPath(); ctx.arc(px-5, H-85, 8, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
} else {
for(let i=0; i<5; i++) S.particles.push({x: S.lane===0?W/4:W*0.75, y: H-80, vx: Math.random()*10-5, vy: Math.random()*10-5, l: 30, c: '#ef4444'});
}
S.particles.forEach(p => {
p.x += p.vx; p.y += p.vy; p.l--;
ctx.fillStyle = p.c; ctx.globalAlpha = p.l/30; ctx.fillRect(p.x, p.y, 5, 5);
});
ctx.globalAlpha = 1;
requestRef.current = requestAnimationFrame(gameLoop);
};
return (
<div className="fixed inset-0 z-50 bg-indigo-950/90 backdrop-blur-xl flex flex-col items-center justify-center relative overflow-hidden select-none"
onClick={switchLane}
onKeyDown={(e) => {
if (e.code === 'ArrowLeft') gameState.current.lane = 0;
if (e.code === 'ArrowRight') gameState.current.lane = 1;
if (e.code === 'Space') switchLane();
}}
tabIndex={0}
>
{/* Header HUD */}
<div className="absolute top-6 w-full max-w-5xl px-6 z-50 flex justify-between items-start pointer-events-none">
<button
onClick={onExit}
className="pointer-events-auto flex items-center gap-2 px-5 py-2.5 bg-white/10 hover:bg-white/20 text-white rounded-full backdrop-blur-md border border-white/20 transition-all hover:scale-105 active:scale-95 font-bold text-sm shadow-xl"
>
<ArrowLeft size={18} /> EXIT
</button>
{isPlaying && (
<div className="flex flex-col items-end gap-2 animate-in fade-in slide-in-from-top-4">
<div className="bg-black/40 backdrop-blur-md px-6 py-2 rounded-2xl border border-white/10 text-white font-mono font-black text-4xl shadow-xl tracking-wider">
{score}
</div>
<div className="text-xs text-white/80 font-bold bg-black/20 px-3 py-1 rounded-full uppercase tracking-widest border border-white/5">HI: {highScore}</div>
</div>
)}
</div>
<div className="relative rounded-[2rem] overflow-hidden shadow-2xl shadow-indigo-500/20 border-8 border-indigo-900/50 w-full max-w-5xl aspect-video bg-black ring-1 ring-white/10">
<canvas ref={canvasRef} width={800} height={450} className="w-full h-full block touch-none cursor-pointer"/>
{!isPlaying && !gameOver && (
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm flex flex-col items-center justify-center text-white p-6 text-center animate-in fade-in">
<h2 className="text-6xl font-black mb-6 text-indigo-400 tracking-wider drop-shadow-md">TRUTH TREK</h2>
<div className="mb-8 bg-white/10 px-6 py-3 rounded-full border border-white/20 flex items-center gap-3 backdrop-blur-md">
<Trophy size={18} className="text-yellow-400" />
<span className="text-sm font-bold text-yellow-100 uppercase tracking-widest">Personal Best: {highScore}</span>
</div>
<div className="flex gap-4 mb-10">
{['easy', 'medium', 'hard'].map((d) => (
<button
key={d}
onClick={(e) => { e.stopPropagation(); setDifficulty(d as any); }}
className={`px-6 py-3 rounded-xl text-sm font-black uppercase transition-all border-2 ${
difficulty === d
? 'bg-indigo-600 border-indigo-400 text-white shadow-[0_0_20px_rgba(99,102,241,0.5)] scale-110'
: 'bg-black/40 border-gray-700 text-gray-400 hover:text-white hover:border-gray-500'
}`}
>
{d}
</button>
))}
</div>
<Button onClick={(e) => { e.stopPropagation(); startGame(); }} className="bg-indigo-600 hover:bg-indigo-500 text-xl px-12 py-6 rounded-full font-black shadow-[0_0_30px_rgba(99,102,241,0.5)] transform hover:scale-105 active:scale-95 transition-all border border-white/10 group">
<Play size={28} className="mr-3 fill-white group-hover:scale-110 transition-transform"/> START MISSION
</Button>
</div>
)}
{gameOver && (
<div className="absolute inset-0 bg-red-950/90 backdrop-blur-md flex flex-col items-center justify-center text-white p-6 text-center animate-in zoom-in duration-300">
<h2 className="text-7xl font-black mb-4 tracking-tighter drop-shadow-lg">FAILED!</h2>
<div className="flex flex-col items-center gap-1 mb-10 bg-black/30 px-8 py-4 rounded-3xl border border-red-500/30">
<span className="text-red-200 text-xs uppercase font-bold tracking-[0.2em]">Score</span>
<span className="text-6xl font-mono font-black text-yellow-400">{score}</span>
</div>
<div className="flex gap-4">
<Button onClick={(e) => { e.stopPropagation(); startGame(); }} className="bg-white text-indigo-900 hover:bg-gray-200 font-black px-10 py-5 text-xl rounded-full shadow-[0_0_30px_rgba(255,255,255,0.3)] hover:scale-105 active:scale-95 transition-all flex items-center gap-3">
<RefreshCw size={24} className="mr-2"/> RETRY
</Button>
</div>
</div>
)}
</div>
</div>
);
};

243
games/YetiPeak.tsx Normal file
View File

@ -0,0 +1,243 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, Play, RefreshCw, Trophy, Mountain } from 'lucide-react';
import { StorageService } from '../services/storageService';
interface GameProps {
onExit: () => void;
}
export const YetiPeak: React.FC<GameProps> = ({ onExit }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [gameOver, setGameOver] = useState(false);
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const gameState = useRef({
player: { x: 400, y: 300, w: 40, h: 40, vy: 0 },
platforms: [] as any[],
particles: [] as any[],
frame: 0,
cameraY: 0,
score: 0,
lastPlatformY: 450
});
const requestRef = useRef<number>(0);
const GRAVITY = 0.3;
const JUMP_FORCE = -11;
const WIDTH = 800;
const HEIGHT = 450;
useEffect(() => {
const loadHighScore = async () => {
const profile = await StorageService.getProfile();
if (profile?.highScores?.truth) setHighScore(profile.highScores.truth); // Reusing slot or assume new one
};
loadHighScore();
return () => cancelAnimationFrame(requestRef.current);
}, []);
const startGame = () => {
setIsPlaying(true);
setGameOver(false);
setScore(0);
gameState.current = {
player: { x: WIDTH / 2, y: HEIGHT - 100, w: 50, h: 60, vy: JUMP_FORCE },
platforms: [],
particles: [],
frame: 0,
cameraY: 0,
score: 0,
lastPlatformY: HEIGHT
};
// Initial platforms
for (let i = 0; i < 10; i++) {
spawnPlatform();
}
requestRef.current = requestAnimationFrame(gameLoop);
};
const spawnPlatform = () => {
const S = gameState.current;
const w = 100 + Math.random() * 50;
const x = Math.random() * (WIDTH - w);
const y = S.lastPlatformY - (80 + Math.random() * 40);
S.platforms.push({ x, y, w, h: 15 });
S.lastPlatformY = y;
};
const handleInput = (e: React.MouseEvent | React.TouchEvent) => {
if (!isPlaying) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const x = clientX - rect.left;
gameState.current.player.x = (x / rect.width) * WIDTH - 25;
};
const gameLoop = () => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx || !canvasRef.current) return;
const S = gameState.current;
if (isPlaying && !gameOver) {
S.frame++;
// Physics
S.player.vy += GRAVITY;
S.player.y += S.player.vy;
// Wrap horizontal
if (S.player.x < -25) S.player.x = WIDTH + 25;
if (S.player.x > WIDTH + 25) S.player.x = -25;
// Platform Collisions (only when falling)
if (S.player.vy > 0) {
S.platforms.forEach(p => {
if (S.player.x + S.player.w > p.x && S.player.x < p.x + p.w &&
S.player.y + S.player.h > p.y && S.player.y + S.player.h < p.y + p.h + 10) {
S.player.vy = JUMP_FORCE;
// Particles
for(let i=0; i<5; i++) S.particles.push({x: S.player.x + 25, y: p.y, vx: Math.random()*4-2, vy: -Math.random()*2, l: 15});
}
});
}
// Camera Follow & Score
if (S.player.y < HEIGHT / 2) {
const diff = HEIGHT / 2 - S.player.y;
S.cameraY += diff;
S.player.y = HEIGHT / 2;
S.platforms.forEach(p => p.y += diff);
S.lastPlatformY += diff;
S.score += Math.floor(diff / 10);
setScore(S.score);
}
// Cleanup & Spawn new platforms
S.platforms = S.platforms.filter(p => p.y < HEIGHT + 50);
while (S.platforms.length < 15) {
spawnPlatform();
}
// Death
if (S.player.y > HEIGHT + 100) {
setGameOver(true);
if (S.score > highScore) {
setHighScore(S.score);
StorageService.saveHighScore('truth', S.score); // Reuse or assume new
}
}
S.particles.forEach(p => { p.x += p.vx; p.y += p.vy; p.l--; });
S.particles = S.particles.filter(p => p.l > 0);
}
// DRAWING
ctx.clearRect(0, 0, WIDTH, HEIGHT);
// Background
const bgGrad = ctx.createLinearGradient(0, 0, 0, HEIGHT);
bgGrad.addColorStop(0, '#1e293b');
bgGrad.addColorStop(1, '#334155');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Grid lines for motion feel
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
for(let y = S.cameraY % 50; y < HEIGHT; y += 50) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(WIDTH, y); ctx.stroke();
}
// Platforms
S.platforms.forEach(p => {
ctx.fillStyle = '#f8fafc';
ctx.shadowBlur = 10; ctx.shadowColor = '#60a5fa';
ctx.beginPath();
ctx.roundRect(p.x, p.y, p.w, p.h, 8);
ctx.fill();
ctx.shadowBlur = 0;
// Ice crack detail
ctx.strokeStyle = '#cbd5e1';
ctx.beginPath(); ctx.moveTo(p.x + 20, p.y + 5); ctx.lineTo(p.x + 40, p.y + 10); ctx.stroke();
});
// Player (Yeti)
ctx.save();
ctx.translate(S.player.x, S.player.y);
// Body (fluffy white)
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.roundRect(0, 0, S.player.w, S.player.h, 20); ctx.fill();
// Face (blueish)
ctx.fillStyle = '#bae6fd';
ctx.beginPath(); ctx.arc(25, 20, 15, 0, Math.PI*2); ctx.fill();
// Eyes
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(20, 18, 2, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(30, 18, 2, 0, Math.PI*2); ctx.fill();
// Horns/Ears
ctx.fillStyle = '#94a3b8';
ctx.beginPath(); ctx.moveTo(5, 5); ctx.lineTo(0, -10); ctx.lineTo(15, 5); ctx.fill();
ctx.beginPath(); ctx.moveTo(45, 5); ctx.lineTo(50, -10); ctx.lineTo(35, 5); ctx.fill();
ctx.restore();
// Particles
S.particles.forEach(p => {
ctx.globalAlpha = p.l / 15;
ctx.fillStyle = '#fff';
ctx.fillRect(p.x, p.y, 4, 4);
});
ctx.globalAlpha = 1;
if (isPlaying && !gameOver) {
requestRef.current = requestAnimationFrame(gameLoop);
}
};
return (
<div className="fixed inset-0 z-50 bg-[#020617] flex flex-col items-center justify-center select-none overflow-hidden font-sans"
onMouseMove={handleInput} onTouchMove={handleInput}>
<div className="absolute top-6 w-full max-w-2xl px-6 z-50 flex justify-between items-center pointer-events-none">
<button onClick={onExit} className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all font-black text-xs tracking-widest shadow-2xl">
<ArrowLeft size={18} /> EXIT
</button>
{isPlaying && (
<div className="bg-black/60 backdrop-blur-xl px-8 py-3 rounded-2xl border border-white/10 text-white font-mono font-black text-3xl shadow-2xl tabular-nums">
{score}m
</div>
)}
</div>
<div className="relative rounded-[3rem] overflow-hidden shadow-[0_0_100px_rgba(255,255,255,0.1)] border-[12px] border-gray-900 w-full max-w-2xl aspect-[3/4] bg-slate-900">
<canvas ref={canvasRef} width={800} height={450 * (4/3)} className="w-full h-full block touch-none cursor-none"/>
{!isPlaying && !gameOver && (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex flex-col items-center justify-center text-white p-6 text-center animate-in fade-in">
<h2 className="text-7xl font-black italic tracking-tighter text-white mb-6 uppercase">YETI PEAK</h2>
<div className="mb-10 p-6 bg-white/10 rounded-[2rem] border border-white/20 backdrop-blur-md">
<p className="text-sm font-medium text-gray-300">Move your mouse or touch to guide the Yeti to the highest peak. Don't fall into the abyss!</p>
</div>
<Button onClick={startGame} className="bg-blue-600 hover:bg-blue-700 text-2xl px-16 py-7 rounded-[2rem] font-black shadow-2xl">
ASCEND MOUNTAIN
</Button>
</div>
)}
{gameOver && (
<div className="absolute inset-0 bg-slate-950/90 backdrop-blur-xl flex flex-col items-center justify-center text-white p-6 text-center animate-in zoom-in duration-300">
<h2 className="text-8xl font-black text-blue-400 tracking-tighter mb-4 italic uppercase">FROZEN!</h2>
<p className="text-gray-400 text-xl font-bold uppercase tracking-widest mb-10">Altitude: {score}m</p>
<Button onClick={startGame} className="bg-white text-slate-900 font-black px-12 py-6 text-2xl rounded-[2rem] shadow-2xl flex items-center gap-3">
<RefreshCw size={28}/> RETRY CLIMB
</Button>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,43 @@
export const colorNames = ['RED', 'BLUE', 'GREEN', 'YELLOW', 'PURPLE'];
// Hex codes ordered to match the colorNames array indices for logical consistency
export const colorValues = [
'#FF3131', // Red
'#1F51FF', // Blue
'#39FF14', // Green
'#FFFF33', // Yellow
'#BC13FE' // Purple
];
export interface Challenge {
displayText: string;
displayColor: string;
isMatch: boolean;
}
export const getRandomChallenge = (): Challenge => {
// 1. Determine if this round should be a match (40% chance)
const isMatch = Math.random() < 0.4;
// 2. Select a random index for the text
const textIndex = Math.floor(Math.random() * colorNames.length);
let colorIndex;
if (isMatch) {
// If it must match, the color index must equal the text index
colorIndex = textIndex;
} else {
// If it must NOT match, pick a random color index that is different from text index
do {
colorIndex = Math.floor(Math.random() * colorValues.length);
} while (colorIndex === textIndex);
}
// 3. Return the challenge object
return {
displayText: colorNames[textIndex],
displayColor: colorValues[colorIndex],
isMatch: isMatch
};
};

136
index.html Normal file
View File

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Project Rudraksha</title>
<!-- Favicon: Rudraksha Logo -->
<link rel="icon" href="https://iili.io/fgyxLsn.md.png">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
rudra: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
},
red: {
50: 'var(--color-red-50)',
100: 'var(--color-red-100)',
200: 'var(--color-red-200)',
300: 'var(--color-red-300)',
400: 'var(--color-red-400)',
500: 'var(--color-red-500)',
600: 'var(--color-red-600)',
700: 'var(--color-red-700)',
800: 'var(--color-red-800)',
900: 'var(--color-red-900)',
},
orange: {
50: 'var(--color-orange-50)',
100: 'var(--color-orange-100)',
200: 'var(--color-orange-200)',
300: 'var(--color-orange-300)',
400: 'var(--color-orange-400)',
500: 'var(--color-orange-500)',
600: 'var(--color-orange-600)',
700: 'var(--color-orange-700)',
800: 'var(--color-orange-800)',
900: 'var(--color-orange-900)',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.5s ease-out',
'pulse-slow': 'pulse 3s infinite',
'dial-enter': 'dialEnter 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
'float': 'float 3s ease-in-out infinite',
'pop': 'pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
'wave': 'wave 2s linear infinite',
'neon-flow': 'neonFlow 10s ease infinite',
},
keyframes: {
fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } },
slideUp: { '0%': { transform: 'translateY(20px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' } },
dialEnter: { '0%': { transform: 'scale(0) rotate(-90deg)', opacity: '0' }, '100%': { transform: 'scale(1) rotate(0deg)', opacity: '1' } },
float: { '0%, 100%': { transform: 'translateY(0)' }, '50%': { transform: 'translateY(-10px)' } },
pop: { '0%': { transform: 'scale(0.9)' }, '100%': { transform: 'scale(1)' } },
wave: { '0%': { transform: 'translateX(0)' }, '100%': { transform: 'translateX(-50%)' } },
neonFlow: { '0%, 100%': { backgroundPosition: '0% 50%' }, '50%': { backgroundPosition: '100% 50%' } }
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<!-- MarkerCluster CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- MarkerCluster JS -->
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<style>
:root {
--color-red-50: #fef2f2; --color-red-100: #fee2e2; --color-red-200: #fecaca; --color-red-300: #fca5a5; --color-red-400: #f87171; --color-red-500: #ef4444; --color-red-600: #dc2626; --color-red-700: #b91c1c; --color-red-800: #991b1b; --color-red-900: #7f1d1d;
--color-orange-50: #fff7ed; --color-orange-100: #ffedd5; --color-orange-200: #fed7aa; --color-orange-300: #fdba74; --color-orange-400: #fb923c; --color-orange-500: #f97316; --color-orange-600: #ea580c; --color-orange-700: #c2410c; --color-orange-800: #9a3412; --color-orange-900: #7c2d12;
}
body {
font-family: 'Inter', sans-serif;
transition: background-color 0.5s ease, color 0.5s ease, background-image 0.5s ease;
min-height: 100vh;
-webkit-user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: none;
}
.dark body { color: #f3f4f6; }
input, textarea, [contenteditable="true"] { -webkit-user-select: text; user-select: text; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-orange-300); border-radius: 4px; }
.dark ::-webkit-scrollbar-thumb { background: var(--color-orange-800); }
.animate-neon-flow { background-size: 200% 200% !important; animation: neonFlow 10s ease infinite !important; }
@keyframes neonFlow { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
</style>
<script type="importmap">
{
"imports": {
"react/": "https://aistudiocdn.com/react@^19.2.1/",
"react": "https://aistudiocdn.com/react@^19.2.1",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.32.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.1/",
"recharts": "https://aistudiocdn.com/recharts@^3.5.1",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.559.0",
"react-router-dom": "https://aistudiocdn.com/react-router-dom@^7.10.1",
"canvas-confetti": "https://aistudiocdn.com/canvas-confetti@^1.9.4",
"framer-motion": "https://esm.sh/framer-motion@11.11.11?external=react,react-dom"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="transition-colors duration-500">
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

9
metadata.json Normal file
View File

@ -0,0 +1,9 @@
{
"name": "Rudraksha-main",
"description": "The Ultimate Nepali Companion: A multi-featured web app for culture, health, student productivity, and safety.",
"requestFramePermissions": [
"geolocation",
"camera",
"microphone"
]
}

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "rudraksha-main",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.1",
"@google/genai": "^1.32.0",
"react-dom": "^19.2.1",
"recharts": "^3.5.1",
"lucide-react": "^0.559.0",
"react-router-dom": "^7.10.1",
"canvas-confetti": "^1.9.4",
"framer-motion": "11.11.11"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

464
pages/Analytics.tsx Normal file
View File

@ -0,0 +1,464 @@
import React, { useEffect, useState, useMemo } from 'react';
import { StorageService } from '../services/storageService';
import { Task, TaskStatus, StudySession, Priority, UserProfile } from '../types';
import { useNavigate } from 'react-router-dom';
import {
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer,
Cell, PieChart, Pie
} from 'recharts';
import {
TrendingUp, Zap, Target,
Brain, Info,
ArrowUpRight,
Loader2,
GraduationCap, Activity,
CheckCircle2, Clock,
Sparkles,
PieChart as PieIcon,
Zap as ZapIcon,
Layout,
ArrowUp,
ArrowDown,
Minus,
AlertCircle
} from 'lucide-react';
import { Button } from '../components/ui/Button';
import { useLanguage } from '../contexts/LanguageContext';
const CountUp = ({ end, duration = 1500 }: { end: number, duration?: number }) => {
const [count, setCount] = useState(0);
useEffect(() => {
let startTime: number;
let animationFrame: number;
const update = (currentTime: number) => {
if (!startTime) startTime = currentTime;
const progress = Math.min((currentTime - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - progress, 4);
setCount(Math.floor(ease * end));
if (progress < 1) animationFrame = requestAnimationFrame(update);
};
animationFrame = requestAnimationFrame(update);
return () => cancelAnimationFrame(animationFrame);
}, [end, duration]);
return <>{count}</>;
};
const EmptyMetricState = ({
title,
message,
cta,
onCtaClick,
icon: Icon
}: {
title: string,
message: string,
cta: string,
onCtaClick: () => void,
icon: any
}) => (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center bg-gray-50/50 dark:bg-gray-900/30 rounded-[2.5rem] border-2 border-dashed border-gray-200 dark:border-gray-800 animate-in fade-in zoom-in duration-500">
<div className="w-16 h-16 rounded-2xl bg-white dark:bg-gray-800 shadow-sm flex items-center justify-center text-gray-400 mb-6 group-hover:scale-110 transition-transform">
<Icon size={32} />
</div>
<h3 className="text-xl font-black text-gray-900 dark:text-white uppercase tracking-tighter mb-2">{title}</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm font-medium max-w-xs mb-8 leading-relaxed">"{message}"</p>
<Button onClick={onCtaClick} variant="primary" className="h-12 px-8 rounded-xl font-black uppercase text-[10px] tracking-widest shadow-xl shadow-red-500/20">
{cta}
</Button>
</div>
);
const Analytics: React.FC = () => {
const navigate = useNavigate();
const { t } = useLanguage();
const [tasks, setTasks] = useState<Task[]>([]);
const [sessions, setSessions] = useState<StudySession[]>([]);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
const [tasksData, sessionsData, profileData] = await Promise.all([
StorageService.getTasks(),
StorageService.getStudySessions(),
StorageService.getProfile()
]);
setTasks(tasksData);
setSessions(sessionsData);
setProfile(profileData);
setLoading(false);
};
fetchData();
}, []);
const stats = useMemo(() => {
if (tasks.length === 0 && sessions.length === 0) {
return { score: 0, trend: 'stable', focus: 0, deepWork: 0, curriculum: 0, consistency: 0, difficulty: 0 };
}
// 1. Focus Grade (30%): Completion percentage
const completedTasks = tasks.filter(t => t.status === TaskStatus.COMPLETED).length;
const focusGrade = tasks.length > 0 ? (completedTasks / tasks.length) * 100 : 0;
// 2. Deep Work Hours (25%): Target 15 hours / week as 100%
const totalFocusMinutes = sessions.filter(s => s.isFocusMode).reduce((sum, s) => sum + s.durationMinutes, 0);
const deepWorkHours = totalFocusMinutes / 60;
const deepWorkScore = Math.min(100, (deepWorkHours / 15) * 100);
// 3. Curriculum Load Completion (20%): Percentage of tasks completed
const curriculumScore = tasks.length > 0 ? (completedTasks / tasks.length) * 100 : 0;
// 4. Consistency/Streaks (15%): Days active in the last 7 days
const uniqueDays = new Set(sessions.map(s => new Date(s.timestamp).toLocaleDateString())).size;
const consistencyScore = (uniqueDays / 7) * 100;
// 5. Task Difficulty Handling (10%): Completion of High Priority tasks
const highPriorityTasks = tasks.filter(t => t.priority === Priority.HIGH);
const completedHighPriority = highPriorityTasks.filter(t => t.status === TaskStatus.COMPLETED).length;
const difficultyScore = highPriorityTasks.length > 0 ? (completedHighPriority / highPriorityTasks.length) * 100 : 50;
const compositeScore = Math.round(
(focusGrade * 0.30) +
(deepWorkScore * 0.25) +
(curriculumScore * 0.20) +
(consistencyScore * 0.15) +
(difficultyScore * 0.10)
);
let trend: 'up' | 'down' | 'stable' = 'stable';
if (sessions.length > 2) {
const mid = Math.floor(sessions.length / 2);
const recent = sessions.slice(mid).reduce((a, b) => a + b.durationMinutes, 0);
const older = sessions.slice(0, mid).reduce((a, b) => a + b.durationMinutes, 0);
if (recent > older * 1.1) trend = 'up';
else if (recent < older * 0.9) trend = 'down';
}
return {
score: compositeScore,
trend,
focus: Math.round(focusGrade),
deepWork: Math.round(deepWorkHours * 10) / 10,
curriculum: Math.round(curriculumScore),
consistency: Math.round(consistencyScore),
difficulty: Math.round(difficultyScore)
};
}, [tasks, sessions]);
const intensityMapping = useMemo(() => {
const days = 7;
const result = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toLocaleDateString();
const mins = sessions
.filter(s => new Date(s.timestamp).toLocaleDateString() === dateStr)
.reduce((sum, s) => sum + s.durationMinutes, 0);
result.push({ name: d.toLocaleDateString(undefined, {weekday: 'short'}), mins });
}
return result;
}, [sessions]);
const velocityByDiscipline = useMemo(() => {
const subs = Array.from(new Set(tasks.map(t => t.subject)));
return subs.map(sub => {
const subTasks = tasks.filter(t => t.subject === sub);
const completed = subTasks.filter(t => t.status === TaskStatus.COMPLETED).length;
const subSessions = sessions.filter(s => s.subject === sub);
const totalMins = subSessions.reduce((sum, s) => sum + s.durationMinutes, 0);
return {
subject: sub,
percent: Math.round((completed / subTasks.length) * 100),
time: totalMins,
tasks: subTasks.length
};
}).sort((a, b) => b.percent - a.percent);
}, [tasks, sessions]);
const curriculumLoad = useMemo(() => {
const subjects = Array.from(new Set(tasks.map(t => t.subject)));
return subjects.map((sub, i) => ({
name: sub,
value: tasks.filter(t => t.subject === sub).length,
fill: ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#6366f1', '#ec4899'][i % 6]
}));
}, [tasks]);
const stateAnalysisData = useMemo(() => {
const deepWork = sessions.filter(s => s.isFocusMode).reduce((sum, s) => sum + s.durationMinutes, 0);
const standardWork = sessions.filter(s => !s.isFocusMode).reduce((sum, s) => sum + s.durationMinutes, 0);
return [
{ name: 'Deep Work', value: deepWork, fill: '#4f46e5' },
{ name: 'Standard', value: standardWork, fill: '#94a3b8' }
].filter(v => v.value > 0);
}, [sessions]);
const nextBestAction = useMemo(() => {
const pending = tasks.filter(t => t.status !== TaskStatus.COMPLETED);
if (pending.length === 0) return { title: "Set New Goals", desc: "Your queue is empty. Ready for the next challenge?", subject: "General" };
const highPriority = pending.find(t => t.priority === Priority.HIGH);
if (highPriority) return { title: "High Priority Mission", desc: `Focus on ${highPriority.title}. It's critical for your curriculum progress.`, subject: highPriority.subject };
return { title: "Momentum Builder", desc: `Keep the streak alive by finishing "${pending[0].title}".`, subject: pending[0].subject };
}, [tasks]);
if (loading) return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<Loader2 className="animate-spin text-red-600 w-12 h-12" />
<p className="text-gray-500 font-bold uppercase tracking-widest text-xs">Processing Intelligence Command...</p>
</div>
);
return (
<div className="max-w-6xl mx-auto space-y-8 pb-20 animate-in fade-in duration-700">
{/* HEADER & COMPOSITE SCORE */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-[2.5rem] p-8 md:p-10 shadow-xl border border-gray-100 dark:border-gray-700 relative overflow-hidden flex flex-col md:flex-row items-center gap-10">
<div className="relative shrink-0">
<svg className="w-44 h-44 transform -rotate-90">
<circle cx="88" cy="88" r="80" stroke="currentColor" strokeWidth="12" fill="transparent" className="text-gray-100 dark:text-gray-700" />
<circle cx="88" cy="88" r="80" stroke="currentColor" strokeWidth="12" fill="transparent" strokeDasharray={502} strokeDashoffset={502 - (502 * stats.score) / 100} className="text-red-600 transition-all duration-1000 ease-out" strokeLinecap="round" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="flex items-center gap-1">
<span className="text-6xl font-black text-gray-900 dark:text-white leading-none tracking-tighter"><CountUp end={stats.score} /></span>
<div className="flex flex-col">
{stats.trend === 'up' && <ArrowUp size={24} className="text-emerald-500 animate-bounce" />}
{stats.trend === 'down' && <ArrowDown size={24} className="text-red-500 animate-bounce" />}
{stats.trend === 'stable' && <Minus size={24} className="text-gray-400" />}
</div>
</div>
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest mt-1">Intelligence Score</span>
</div>
</div>
<div className="flex-1 text-center md:text-left space-y-4">
<div className="flex items-center justify-center md:justify-start gap-3">
<h1 className="text-3xl font-black text-gray-900 dark:text-white uppercase tracking-tighter leading-none">Cognitive Profile</h1>
<div className="group relative">
<Info size={18} className="text-gray-300 hover:text-red-500 cursor-help transition-colors" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 p-4 bg-gray-900 text-white text-[10px] font-bold rounded-2xl shadow-2xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 text-center leading-relaxed">
This score reflects learning quality, not IQ.
Breakdown: Focus (30%), Deep Work (25%), Completion (20%), Consistency (15%), Difficulty (10%).
</div>
</div>
</div>
<p className="text-gray-500 dark:text-gray-400 font-medium leading-relaxed">
Efficiency at <span className="text-red-600 font-black">{stats.focus}%</span>.
Deep work sessions are <span className="text-emerald-500 font-bold">{stats.trend === 'up' ? 'increasing' : 'stabilizing'}</span>.
</p>
<div className="flex flex-wrap justify-center md:justify-start gap-3">
<div className="bg-emerald-50 dark:bg-emerald-900/20 px-4 py-2 rounded-xl text-[10px] font-black uppercase text-emerald-600 border border-emerald-100 dark:border-emerald-800 flex items-center gap-2">
<TrendingUp size={14}/> {stats.score > 70 ? 'Scholar Class' : 'Active Growth'}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 px-4 py-2 rounded-xl text-[10px] font-black uppercase text-blue-600 border border-blue-100 dark:border-blue-800 flex items-center gap-2">
<Zap size={14}/> Level {Math.floor((profile?.xp || 0)/500)+1}
</div>
</div>
</div>
</div>
<div className="bg-gradient-to-br from-indigo-600 to-indigo-900 rounded-[2.5rem] p-8 text-white shadow-xl flex flex-col justify-between group overflow-hidden relative">
<div className="relative z-10">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-60 mb-6 flex items-center gap-2">
<Sparkles size={14}/> Recommendation
</h3>
<h2 className="text-2xl font-black uppercase tracking-tighter mb-2 leading-none">{nextBestAction.title}</h2>
<p className="text-indigo-100 font-medium text-sm leading-relaxed mb-8">"{nextBestAction.desc}"</p>
</div>
<Button onClick={() => navigate('/planner')} className="relative z-10 w-full h-14 bg-white text-indigo-900 font-black uppercase text-xs tracking-widest rounded-2xl shadow-xl hover:scale-[1.02] transition-all">
Execute Mission <ArrowUpRight size={16} className="ml-2"/>
</Button>
<Brain size={180} className="absolute -right-16 -bottom-16 text-white opacity-5 rotate-12 group-hover:rotate-0 transition-transform duration-1000"/>
</div>
</div>
{/* INTENSITY MAPPING (STUDY PULSE) */}
<div className="bg-white dark:bg-gray-800 rounded-[2.5rem] p-8 shadow-xl border border-gray-100 dark:border-gray-700">
<div className="flex justify-between items-center mb-8">
<div>
<h3 className="text-lg font-black text-gray-900 dark:text-white uppercase tracking-tighter flex items-center gap-3">
<Activity className="text-red-600" /> Intensity Pulse
</h3>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest mt-1">Concentration Peaks (Last 7 Days)</p>
</div>
{sessions.length > 0 && (
<div className="text-right">
<p className="text-2xl font-black text-gray-900 dark:text-white">{(sessions.reduce((a,b) => a+b.durationMinutes,0)/60).toFixed(1)}h</p>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest">Total Energy Invested</p>
</div>
)}
</div>
{sessions.length === 0 ? (
<EmptyMetricState
title="Pulse Flatlined"
message="Intensity Pulse tracks your focus density over time. Start a session to map your power."
cta="Initiate Deep Work"
onCtaClick={() => navigate('/study-buddy')}
icon={ZapIcon}
/>
) : (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={intensityMapping}>
<RechartsTooltip
cursor={{fill: 'rgba(0,0,0,0.05)'}}
content={({active, payload}) => {
if (active && payload) return <div className="bg-gray-900 text-white px-4 py-2 rounded-xl text-xs font-black shadow-2xl border border-white/10">{payload[0].value} Minutes</div>;
return null;
}}
/>
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize: 10, fontWeight: 900, fill: '#94a3b8'}} />
<Bar dataKey="mins" radius={[8, 8, 8, 8]} barSize={40}>
{intensityMapping.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.mins > 60 ? '#dc2626' : entry.mins > 0 ? '#4f46e5' : '#e2e8f0'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* CURRICULUM LOAD BREAKDOWN */}
<div className="bg-white dark:bg-gray-800 rounded-[2.5rem] p-8 shadow-xl border border-gray-100 dark:border-gray-700">
<h3 className="text-lg font-black text-gray-900 dark:text-white uppercase tracking-tighter flex items-center gap-3 mb-8">
<PieIcon className="text-blue-500" /> Equilibrium Index
</h3>
{tasks.length === 0 ? (
<EmptyMetricState
title="Load Balanced"
message="Curriculum Load maps your subject distribution. Add tasks to see your focus areas."
cta="Define Subject Goals"
onCtaClick={() => navigate('/planner')}
icon={Layout}
/>
) : (
<div className="h-64 w-full flex flex-col sm:flex-row items-center justify-between">
<div className="h-full w-full sm:w-1/2">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={curriculumLoad}
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
animationDuration={1500}
>
{curriculumLoad.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<RechartsTooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 w-full sm:w-1/2 space-y-2 mt-4 sm:mt-0 max-h-48 overflow-y-auto no-scrollbar">
{curriculumLoad.map((item, i) => (
<div key={i} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{backgroundColor: item.fill}} />
<span className="text-[10px] font-black uppercase text-gray-600 dark:text-gray-400">{item.name}</span>
</div>
<span className="text-xs font-black text-gray-900 dark:text-white">{item.value} Units</span>
</div>
))}
</div>
</div>
)}
</div>
{/* STATE ANALYSIS */}
<div className="bg-white dark:bg-gray-800 rounded-[2.5rem] p-8 shadow-xl border border-gray-100 dark:border-gray-700">
<h3 className="text-lg font-black text-gray-900 dark:text-white uppercase tracking-tighter flex items-center gap-3 mb-8">
<Brain className="text-purple-500" /> Flow Ratio
</h3>
{sessions.length === 0 ? (
<EmptyMetricState
title="State Undefined"
message="Flow Analysis evaluates your cognitive performance and session depth."
cta="Log Study Block"
onCtaClick={() => navigate('/study-buddy')}
icon={ZapIcon}
/>
) : (
<div className="h-64 w-full flex flex-col items-center justify-center">
<div className="w-full max-w-xs space-y-6">
{stateAnalysisData.map((item, i) => {
const total = stateAnalysisData.reduce((a,b) => a+b.value, 0);
const percent = Math.round((item.value / total) * 100);
return (
<div key={i} className="space-y-2">
<div className="flex justify-between items-end">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">{item.name}</span>
<span className="text-xl font-black text-gray-900 dark:text-white">{percent}%</span>
</div>
<div className="h-4 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden p-0.5">
<div
className="h-full rounded-full transition-all duration-1000 ease-out"
style={{ width: `${percent}%`, backgroundColor: item.fill }}
/>
</div>
</div>
);
})}
<div className="flex items-center gap-2 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl mt-4 border border-indigo-100 dark:border-indigo-800">
<AlertCircle size={14} className="text-indigo-600" />
<p className="text-[9px] text-indigo-800 dark:text-indigo-300 font-bold uppercase leading-tight">Focus sessions yield 2x faster mastery conversion.</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* VELOCITY BY DISCIPLINE */}
<div className="bg-white dark:bg-gray-800 rounded-[2.5rem] p-8 md:p-10 shadow-xl border border-gray-100 dark:border-gray-700">
<div className="flex justify-between items-center mb-10">
<h3 className="text-xl font-black text-gray-900 dark:text-white uppercase tracking-tighter flex items-center gap-3">
<GraduationCap className="text-emerald-600" /> Subject Velocity
</h3>
</div>
{velocityByDiscipline.length === 0 ? (
<EmptyMetricState
title="Velocity Locked"
message="Velocity measures how fast you convert effort into mastery. Plan missions to unlock."
cta="Plan First Study Block"
onCtaClick={() => navigate('/planner')}
icon={TrendingUp}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-10">
{velocityByDiscipline.map((sub, i) => (
<div key={i} className="space-y-3 group">
<div className="flex justify-between items-end">
<div>
<p className="text-sm font-black text-gray-900 dark:text-white uppercase tracking-tight group-hover:text-red-600 transition-colors">{sub.subject}</p>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">{sub.time} Minutes Invested {sub.tasks} Units</p>
</div>
<span className="text-lg font-black text-red-600">{sub.percent}%</span>
</div>
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden p-0.5 border border-black/5">
<div
className="h-full bg-gradient-to-r from-red-600 to-orange-500 rounded-full transition-all duration-1000 ease-out shadow-[0_0_10px_rgba(220,38,38,0.3)]"
style={{ width: `${sub.percent}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default Analytics;

408
pages/Auth.tsx Normal file
View File

@ -0,0 +1,408 @@
import React, { useState, useEffect } from 'react';
import { StorageService } from '../services/storageService';
import { PlatformService } from '../services/platformService';
import { useNavigate } from 'react-router-dom';
import { UserProfile, UserRole } from '../types';
import { Button } from '../components/ui/Button';
import { Loader2, Eye, EyeOff, GraduationCap, Users, BookOpen, Sun, Moon, KeyRound, ChevronLeft, AtSign, ArrowLeft, CheckSquare, Square, AlertCircle, ShieldCheck, X, Facebook, Globe } from 'lucide-react';
import { Logo } from '../components/ui/Logo';
const Auth: React.FC = () => {
const [isLogin, setIsLogin] = useState(true);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [role, setRole] = useState<UserRole>('student');
const [error, setError] = useState('');
const [rememberMe, setRememberMe] = useState(true);
const [isDarkMode, setIsDarkMode] = useState(() => {
if (typeof window !== 'undefined') {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) return savedTheme === 'dark';
return true;
}
return true;
});
const [showDemoMenu, setShowDemoMenu] = useState(false);
const navigate = useNavigate();
const [formData, setFormData] = useState({
email: '',
password: '',
name: '',
username: '',
schoolName: '',
birthCertificateId: '',
studentId: '',
guardianName: '',
grade: '',
subjects: '',
profession: ''
});
const [passwordStrength, setPasswordStrength] = useState(0);
const [passwordFeedback, setPasswordFeedback] = useState<string[]>([]);
useEffect(() => {
const root = window.document.documentElement;
if (isDarkMode) {
root.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
root.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}, [isDarkMode]);
const validatePassword = (pass: string) => {
const feedback = [];
let score = 0;
if (pass.length >= 8) score++; else feedback.push("At least 8 characters");
if (/[A-Z]/.test(pass)) score++; else feedback.push("One uppercase letter");
if (/[0-9]/.test(pass)) score++; else feedback.push("One number");
if (/[^A-Za-z0-9]/.test(pass)) score++; else feedback.push("One special character");
if (pass.length > 12) score++;
setPasswordStrength(score);
setPasswordFeedback(feedback);
return score;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
if (e.target.name === 'password') {
validatePassword(e.target.value);
}
};
const validateEmail = (email: string) => {
return String(email)
.toLowerCase()
.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
);
};
const handleDemoLogin = async (type: 'student' | 'teacher') => {
setLoading(true);
let email = '';
const password = 'demo123';
switch (type) {
case 'student': email = 'student@demo.com'; break;
case 'teacher': email = 'teacher@demo.com'; break;
}
try {
const { success, error } = await StorageService.login(email, password, true);
if (success) {
navigate('/greeting');
} else {
setError(error || "Demo login failed.");
setLoading(false);
}
} catch (e) {
setError("Login error");
setLoading(false);
}
};
const handleSocialLogin = async (provider: 'facebook' | 'google') => {
setLoading(true);
// Simulate API delay
await new Promise(r => setTimeout(r, 1500));
// Simulate account linking
PlatformService.connect(provider, { username: provider === 'facebook' ? 'fb_user' : 'goog_user' });
// Login with a demo account to proceed to app
// In a real app, this would get a token and create/fetch a user
const { success } = await StorageService.login('student@demo.com', 'demo123', true);
if (success) {
navigate('/greeting');
} else {
setError("Social Auth Failed");
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(formData.email)) {
setError("Please enter a valid email address.");
return;
}
if (!isLogin) {
if (passwordStrength < 3) {
setError("Password is too weak. Please meet the requirements.");
return;
}
}
setLoading(true);
try {
if (isLogin) {
const { success, error } = await StorageService.login(formData.email, formData.password, rememberMe);
if (success) {
navigate('/greeting');
} else {
setError(error || "Invalid credentials.");
}
} else {
const newProfile: UserProfile = {
id: '',
email: formData.email,
name: formData.name,
username: formData.username || formData.email.split('@')[0],
role: role,
schoolName: (role === 'student' || role === 'teacher') ? formData.schoolName : undefined,
birthCertificateId: role === 'student' ? formData.birthCertificateId : undefined,
studentId: role === 'student' ? formData.studentId : undefined,
guardianName: role === 'student' ? formData.guardianName : undefined,
grade: role === 'student' ? formData.grade : undefined,
subjects: (role === 'student' || role === 'teacher') && formData.subjects ? formData.subjects.split(',').map(s => s.trim()) : undefined,
profession: role === 'citizen' ? formData.profession : (role === 'teacher' ? 'Teacher' : undefined),
points: 0,
xp: 0
};
const { success, error } = await StorageService.register(newProfile, formData.password, true);
if (success) {
navigate('/greeting');
} else {
setError(error || "Registration failed.");
}
}
} catch (err) {
setError("An unexpected error occurred.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-black transition-colors duration-500 font-sans">
<div
className="absolute inset-0 bg-cover bg-center opacity-70"
style={{ backgroundImage: "url('https://images.unsplash.com/photo-1486870591958-9b9d0d1dda99?q=80&w=2400&auto=format&fit=crop')" }}
></div>
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-transparent to-black/90"></div>
<div className="absolute inset-0 bg-black/20"></div>
<div className="fixed top-6 left-6 z-50">
<button onClick={() => navigate('/welcome')} className="p-3 rounded-full bg-white/10 dark:bg-gray-900/40 backdrop-blur-md text-white shadow-xl border border-white/20 hover:scale-110 transition-all">
<ArrowLeft size={20}/>
</button>
</div>
<div className="fixed top-6 right-6 z-50">
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-3 rounded-full bg-white/10 dark:bg-gray-900/40 backdrop-blur-md text-white dark:text-yellow-400 shadow-xl border border-white/20 hover:scale-110 transition-all"
>
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
</button>
</div>
<div className="relative z-10 w-full max-w-lg px-4 flex flex-col items-center">
<div className="text-center mb-10 animate-in fade-in slide-in-from-top-4 duration-1000">
<h1 className="text-5xl md:text-7xl font-black text-white tracking-tighter uppercase italic drop-shadow-[0_10px_20px_rgba(0,0,0,0.8)]">
Welcome to <span className="text-red-500 drop-shadow-[0_0_20px_rgba(220,38,38,0.5)]">Rudraksha</span>
</h1>
<p className="text-gray-200 text-sm md:text-base font-bold uppercase tracking-[0.4em] mt-4 opacity-80 drop-shadow-md">
Secure Digital Portal
</p>
</div>
<div className="bg-white/10 dark:bg-black/40 backdrop-blur-3xl p-8 md:p-12 rounded-[3.5rem] shadow-[0_30px_100px_rgba(0,0,0,0.5)] w-full border border-white/20 animate-in zoom-in duration-700">
<div className="text-center mb-8 flex flex-col items-center">
<div className="w-20 h-20 mb-6 transform hover:scale-110 transition-transform duration-500">
<Logo className="w-full h-full drop-shadow-xl" />
</div>
<p className="text-white font-bold uppercase tracking-widest text-xs opacity-60">
{isLogin ? "Authenticate to Access World" : "Create your digital identity"}
</p>
</div>
{error && (
<div className="bg-red-500/20 text-red-100 p-4 rounded-2xl text-sm mb-6 border border-red-500/30 font-bold flex items-center gap-2 animate-in shake">
<AlertCircle size={18} className="text-red-400" /> {error}
</div>
)}
{!isLogin && (
<div className="flex p-1.5 bg-white/10 dark:bg-black/30 rounded-2xl mb-8 gap-1 border border-white/10">
{[{id: 'student', icon: GraduationCap, label: 'Student'}, {id: 'teacher', icon: BookOpen, label: 'Teacher'}, {id: 'citizen', icon: Users, label: 'Citizen'}].map((r) => (
<button
key={r.id}
type="button"
onClick={() => setRole(r.id as UserRole)}
className={`flex-1 py-3 text-xs font-black rounded-xl flex flex-col items-center justify-center gap-1.5 transition-all ${
role === r.id
? 'bg-red-600 text-white shadow-lg scale-105'
: 'text-gray-400 hover:text-white'
}`}
>
<r.icon size={18} /> {r.label}
</button>
))}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<div className="relative group">
<AtSign className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-red-500 transition-colors" size={20}/>
<input
name="email" type="email" required
value={formData.email}
className="w-full pl-14 pr-5 py-4 rounded-2xl border-2 border-white/10 bg-black/20 text-white focus:border-red-500 outline-none transition-all font-bold text-sm placeholder-gray-500"
placeholder={role === 'citizen' ? "Email Address" : "School Email Address"}
onChange={handleChange}
/>
</div>
</div>
<div>
<div className="relative group">
<KeyRound className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-red-500 transition-colors" size={20}/>
<input
name="password"
type={showPassword ? "text" : "password"}
required
value={formData.password}
className="w-full pl-14 pr-12 py-4 rounded-2xl border-2 border-white/10 bg-black/20 text-white focus:border-red-500 outline-none transition-all font-bold text-sm placeholder-gray-500"
placeholder="Password"
onChange={handleChange}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
{!isLogin && formData.password && (
<div className="mt-3 space-y-2 animate-in fade-in slide-in-from-top-2">
<div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden flex gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className={`flex-1 rounded-full h-full transition-all duration-500 ${
passwordStrength >= i
? (passwordStrength < 3 ? 'bg-red-500' : (passwordStrength < 4 ? 'bg-yellow-500' : 'bg-green-500'))
: 'opacity-0'
}`}
/>
))}
</div>
</div>
)}
</div>
{isLogin && (
<div className="flex items-center justify-between px-2">
<button type="button" onClick={() => setRememberMe(!rememberMe)} className="flex items-center gap-2 text-sm font-bold text-gray-400 hover:text-white transition-colors">
{rememberMe ? <CheckSquare size={18} className="text-red-500" /> : <Square size={18} />}
Remember Me
</button>
<button type="button" className="text-xs font-bold text-red-400 hover:text-red-300 uppercase tracking-wide">Forgot Pass?</button>
</div>
)}
{!isLogin && (
<div className="space-y-5 animate-in fade-in slide-in-from-top-4 pt-2">
<div className="grid grid-cols-1 gap-5">
<input
name="name" type="text" required
className="w-full px-6 py-4 rounded-2xl border-2 border-white/10 bg-black/20 text-white focus:border-red-500 outline-none transition-all font-bold text-sm"
placeholder="Full Name"
onChange={handleChange}
/>
<input
name="username" type="text" required
className="w-full px-6 py-4 rounded-2xl border-2 border-white/10 bg-black/20 text-white focus:border-red-500 outline-none transition-all font-bold text-sm"
placeholder="Username (e.g. ram_b)"
onChange={handleChange}
/>
</div>
</div>
)}
<Button type="submit" className="w-full h-16 text-lg font-black uppercase tracking-widest shadow-[0_15px_40px_rgba(220,38,38,0.3)] rounded-2xl bg-red-600 hover:bg-red-700 transform hover:scale-[1.02] active:scale-95 transition-all text-white border-none" disabled={loading}>
{loading ? <Loader2 className="animate-spin" /> : (isLogin ? "Enter Portal" : "Create Account")}
</Button>
{isLogin && (
<div className="space-y-3 pt-2">
<div className="relative flex py-2 items-center">
<div className="flex-grow border-t border-white/10"></div>
<span className="flex-shrink-0 mx-4 text-gray-500 text-[10px] font-black uppercase tracking-widest">Or Continue With</span>
<div className="flex-grow border-t border-white/10"></div>
</div>
<div className="grid grid-cols-2 gap-3">
<button type="button" onClick={() => handleSocialLogin('facebook')} className="flex items-center justify-center gap-2 bg-blue-600/20 hover:bg-blue-600 text-blue-200 hover:text-white border border-blue-500/30 p-3 rounded-2xl transition-all active:scale-95">
<Facebook size={18} /> <span className="text-xs font-bold">Facebook</span>
</button>
<button type="button" onClick={() => handleSocialLogin('google')} className="flex items-center justify-center gap-2 bg-white/10 hover:bg-white text-gray-200 hover:text-black border border-white/20 p-3 rounded-2xl transition-all active:scale-95">
<Globe size={18} /> <span className="text-xs font-bold">Google</span>
</button>
</div>
{!showDemoMenu ? (
<button
type="button"
onClick={() => setShowDemoMenu(true)}
className="w-full py-3 text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white flex items-center justify-center gap-2 transition-colors border-2 border-dashed border-white/10 rounded-2xl hover:bg-white/5 mt-4"
>
<KeyRound size={16} /> Quick Demo Access
</button>
) : (
<div className="bg-black/40 p-5 rounded-3xl border border-white/10 animate-in zoom-in-95 duration-200 mt-2">
<div className="flex justify-between items-center mb-4 px-2">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-[0.2em]">Select Role</span>
<button type="button" onClick={() => setShowDemoMenu(false)} className="text-gray-500 hover:text-white">
<X size={16} />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<button type="button" onClick={() => handleDemoLogin('student')} className="flex flex-col items-center justify-center p-3 bg-white/5 hover:bg-white/10 rounded-2xl text-[10px] font-black uppercase tracking-wide transition-all group">
<GraduationCap size={24} className="mb-2 text-blue-400"/> Student
</button>
<button type="button" onClick={() => handleDemoLogin('teacher')} className="flex flex-col items-center justify-center p-3 bg-white/5 hover:bg-white/10 rounded-2xl text-[10px] font-black uppercase tracking-wide transition-all group">
<BookOpen size={24} className="mb-2 text-purple-400"/> Teacher
</button>
</div>
</div>
)}
</div>
)}
</form>
<div className="mt-8 text-center border-t border-white/10 pt-6">
<button
onClick={() => { setIsLogin(!isLogin); setError(''); }}
className="text-sm font-bold text-gray-400 hover:text-white transition-colors"
>
{isLogin ? "New User? Create Account" : "Existing User? Login"}
</button>
</div>
</div>
</div>
</div>
);
};
export default Auth;

423
pages/CommunityChat.tsx Normal file
View File

@ -0,0 +1,423 @@
import React, { useState, useEffect, useRef } from 'react';
import { StorageService } from '../services/storageService';
import { RewardService } from '../services/rewardService';
import { CommunityMessage, UserProfile, ChatGroup, DirectMessage } from '../types';
import { Button } from '../components/ui/Button';
import { Send, MessageCircle, Loader2, Image as ImageIcon, Smile, Plus, Hash, Users, X, Search, ChevronLeft, Gift, Coins, Cpu, Bot, UserPlus, Check, UserCheck, Upload } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { useNavigate } from 'react-router-dom';
const EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🔥', '🎉', '🙏', '🇳🇵', '👋', '😊', '🤔'];
const CommunityChat: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'community' | 'dm'>('community');
const [messages, setMessages] = useState<CommunityMessage[]>([]);
const [directMessages, setDirectMessages] = useState<DirectMessage[]>([]);
const [groups, setGroups] = useState<ChatGroup[]>([]);
const [availableUsers, setAvailableUsers] = useState<UserProfile[]>([]);
const [friendRequests, setFriendRequests] = useState<UserProfile[]>([]);
const [activeGroup, setActiveGroup] = useState<ChatGroup | null>(null);
const [activeDmUser, setActiveDmUser] = useState<UserProfile | null>(null);
const [input, setInput] = useState('');
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [showCreateGroup, setShowCreateGroup] = useState(false);
const [showGiftModal, setShowGiftModal] = useState(false);
const [giftAmount, setGiftAmount] = useState<number>(10);
const [newGroupName, setNewGroupName] = useState('');
const [searchUserQuery, setSearchUserQuery] = useState('');
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
// Image Preview State
const [previewImage, setPreviewImage] = useState<string | null>(null);
useEffect(() => {
loadInitialData();
const interval = setInterval(refreshData, 3000);
return () => clearInterval(interval);
}, [activeGroup, activeDmUser, activeTab]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, directMessages, activeTab]);
useEffect(() => {
if (searchUserQuery.trim().length > 1) {
StorageService.searchUsers(searchUserQuery).then(setSearchResults);
} else {
setSearchResults([]);
}
}, [searchUserQuery]);
const loadInitialData = async () => {
setLoading(true);
await refreshData();
const p = await StorageService.getProfile();
setProfile(p);
setLoading(false);
};
const isDemoUser = (email?: string) => {
return email === 'admin@gmail.com' || email?.endsWith('@demo.com');
};
const refreshData = async () => {
const p = await StorageService.getProfile();
if (!p) return;
setProfile(p);
if (activeTab === 'community') {
const msgs = await StorageService.getCommunityMessages(activeGroup?.id);
const grps = await StorageService.getGroups();
setMessages(msgs);
setGroups(grps);
} else {
// Fetch all users to filter friends
const allUsers = await StorageService.getAvailableUsers();
const currentUserIsDemo = isDemoUser(p.email);
// Filter Friends & Rudra for DM List
// Also allow demo accounts to see each other
const friendsList = allUsers.filter(u => {
if (u.id === 'rudra-ai-system') return true;
if (p.friends && p.friends.includes(u.id)) return true;
if (currentUserIsDemo && isDemoUser(u.email)) return true;
return false;
});
setAvailableUsers(friendsList);
// Get Friend Requests
if (p.friendRequests && p.friendRequests.length > 0) {
const requests = allUsers.filter(u => p.friendRequests!.includes(u.id));
setFriendRequests(requests);
} else {
setFriendRequests([]);
}
if (activeDmUser) {
const dms = await StorageService.getDirectMessages(activeDmUser.id);
setDirectMessages(dms);
}
}
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || !profile) return;
if (activeTab === 'community') {
await StorageService.sendCommunityMessage(input.trim(), 'text', undefined, activeGroup?.id);
const msgs = await StorageService.getCommunityMessages(activeGroup?.id);
setMessages(msgs);
} else {
if (activeDmUser) {
await StorageService.sendDirectMessage(activeDmUser.id, input.trim());
const dms = await StorageService.getDirectMessages(activeDmUser.id);
setDirectMessages(dms);
}
}
setInput('');
setShowEmojiPicker(false);
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = async () => {
const base64 = reader.result as string;
if (activeTab === 'community') {
await StorageService.sendCommunityMessage('', 'image', base64, activeGroup?.id);
refreshData();
} else if (activeDmUser) {
await StorageService.sendDirectMessage(activeDmUser.id, '', 'image', { imageUrl: base64 });
refreshData();
}
};
reader.readAsDataURL(file);
}
};
const handleGiftKarma = async () => {
if (!activeDmUser || !profile) return;
const result = await RewardService.transferKarma(profile.id, activeDmUser.id, giftAmount);
if (result.success) {
await StorageService.sendDirectMessage(activeDmUser.id, `Sent ${giftAmount} Karma Points`, 'karma', { amount: giftAmount });
const dms = await StorageService.getDirectMessages(activeDmUser.id);
setDirectMessages(dms);
const updatedProfile = await StorageService.getProfile();
setProfile(updatedProfile);
setShowGiftModal(false);
} else {
alert(result.message);
}
};
const handleCreateGroup = async () => {
if (!newGroupName.trim()) return;
const res = await StorageService.createGroup(newGroupName, selectedMembers);
if (res.success && res.groupId) {
setShowCreateGroup(false);
setNewGroupName('');
setSelectedMembers([]);
setSearchUserQuery('');
const grps = await StorageService.getGroups();
setGroups(grps);
const newGrp = grps.find(g => g.id === res.groupId);
if (newGrp) setActiveGroup(newGrp);
}
};
const toggleMember = (username: string) => {
if (selectedMembers.includes(username)) setSelectedMembers(prev => prev.filter(u => u !== username));
else setSelectedMembers(prev => [...prev, username]);
};
const handleAcceptRequest = async (userId: string) => {
await StorageService.acceptFriendRequest(userId);
refreshData();
};
const handleRejectRequest = async (userId: string) => {
await StorageService.rejectFriendRequest(userId);
refreshData();
};
if (loading) return (
<div className="flex justify-center items-center h-[calc(100vh-200px)]">
<Loader2 className="animate-spin text-red-600 w-12 h-12" />
</div>
);
return (
<div className="flex h-[calc(100vh-7rem)] bg-white dark:bg-gray-800 rounded-3xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden relative font-sans">
{/* Sidebar */}
<div className={`
absolute inset-y-0 left-0 z-20 w-80 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 transform transition-transform duration-300 md:relative md:translate-x-0 flex flex-col
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<div className="p-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 flex gap-1">
<button onClick={() => setActiveTab('community')} className={`flex-1 py-2 rounded-xl text-xs font-bold uppercase flex items-center justify-center gap-2 transition-all ${activeTab === 'community' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'}`}>
<Users size={16}/> Community
</button>
<button onClick={() => setActiveTab('dm')} className={`flex-1 py-2 rounded-xl text-xs font-bold uppercase flex items-center justify-center gap-2 transition-all ${activeTab === 'dm' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'}`}>
<MessageCircle size={16}/> Messages
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-3 space-y-1">
{activeTab === 'community' ? (
<>
<button onClick={() => { setActiveGroup(null); setIsSidebarOpen(false); }} className={`w-full text-left px-4 py-3 rounded-xl flex items-center gap-3 transition-colors ${!activeGroup ? 'bg-white dark:bg-gray-800 border-2 border-red-500 shadow-md' : 'hover:bg-gray-200 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'}`}>
<div className="p-2 bg-red-100 dark:bg-red-900/50 text-red-600 rounded-full"><MessageCircle size={20}/></div>
<div><p className="text-sm font-bold dark:text-white">Global Chat</p><p className="text-[10px] font-medium opacity-60 uppercase tracking-wider">Public Room</p></div>
</button>
<div className="pt-6 pb-2 px-2 text-[10px] font-black text-gray-400 uppercase tracking-widest flex justify-between items-center">
<span>Your Groups</span>
<button onClick={() => setShowCreateGroup(true)} className="p-1 hover:bg-white dark:hover:bg-gray-800 rounded-lg"><Plus size={14}/></button>
</div>
{groups.map(group => (
<button key={group.id} onClick={() => { setActiveGroup(group); setIsSidebarOpen(false); }} className={`w-full text-left px-4 py-3 rounded-xl flex items-center gap-3 transition-colors ${activeGroup?.id === group.id ? 'bg-white dark:bg-gray-800 border-2 border-blue-500 shadow-md' : 'hover:bg-gray-200 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'}`}>
<div className="p-2 bg-blue-100 dark:bg-blue-900/50 text-blue-600 rounded-full"><Hash size={20}/></div>
<div className="min-w-0"><p className="text-sm font-bold dark:text-white truncate">{group.name}</p><p className="text-[10px] font-medium opacity-60">{group.members.length} members</p></div>
</button>
))}
</>
) : (
<>
{friendRequests.length > 0 && (
<div className="mb-4">
<p className="text-[10px] font-black text-orange-500 uppercase tracking-widest mb-2 px-2">Friend Requests</p>
<div className="space-y-2">
{friendRequests.map(req => (
<div key={req.id} className="bg-orange-50 dark:bg-orange-900/10 p-3 rounded-xl border border-orange-200 dark:border-orange-800">
<div className="flex items-center gap-2 mb-2">
<img src={req.avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${req.name}`} className="w-8 h-8 rounded-full bg-white" />
<p className="text-xs font-bold dark:text-white truncate">{req.name}</p>
</div>
<div className="flex gap-2">
<button onClick={() => handleAcceptRequest(req.id)} className="flex-1 bg-green-500 text-white text-[10px] font-bold py-1.5 rounded-lg hover:bg-green-600 transition-colors">Accept</button>
<button onClick={() => handleRejectRequest(req.id)} className="flex-1 bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-[10px] font-bold py-1.5 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Decline</button>
</div>
</div>
))}
</div>
</div>
)}
<div className="px-2 py-3">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">Direct Messages</p>
<div className="space-y-2">
{availableUsers.map(user => {
const isAI = user.id === 'rudra-ai-system';
return (
<button key={user.id} onClick={() => { setActiveDmUser(user); setIsSidebarOpen(false); }} className={`w-full text-left p-3 rounded-xl flex items-center gap-3 transition-all ${activeDmUser?.id === user.id ? 'bg-white dark:bg-gray-800 border-2 border-indigo-500 shadow-md' : 'hover:bg-gray-200 dark:hover:bg-gray-800 border-2 border-transparent'}`}>
<div className="relative">
<img src={user.avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`} className={`w-10 h-10 rounded-full bg-gray-200 ${isAI ? 'ring-2 ring-red-500 ring-offset-2 dark:ring-offset-gray-900' : ''}`} alt={user.name} />
<div className={`absolute -bottom-1 -right-1 w-3 h-3 border-2 border-white dark:border-gray-900 rounded-full ${isAI ? 'bg-red-600 animate-pulse' : 'bg-green-500'}`}></div>
</div>
<div className="min-w-0">
<p className="text-sm font-bold dark:text-white truncate flex items-center gap-1.5">{user.name} {isAI && <Cpu size={12} className="text-red-500" />}</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400 truncate font-medium">@{user.username || 'user'}</p>
</div>
</button>
);
})}
{availableUsers.length === 0 && (
<div className="text-center py-4 px-2">
<p className="text-xs text-gray-400">No friends yet.</p>
<p className="text-[10px] text-gray-500 mt-1">Connect with people in Community Chat to DM them.</p>
</div>
)}
</div>
</div>
</>
)}
</div>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0 bg-white dark:bg-gray-800 relative">
<header className="bg-white/90 dark:bg-gray-900/90 backdrop-blur-md p-4 flex items-center justify-between shadow-sm z-10 border-b border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-3">
<button onClick={() => setIsSidebarOpen(true)} className="md:hidden p-2 -ml-2 text-gray-600 dark:text-gray-300"><ChevronLeft/></button>
{activeTab === 'community' ? (
<>
<div className={`p-2 rounded-xl ${activeGroup ? 'bg-blue-100 text-blue-600' : 'bg-red-100 text-red-600'}`}>
{activeGroup ? <Hash size={20}/> : <MessageCircle size={20}/>}
</div>
<div>
<h1 className="font-black text-gray-900 dark:text-white leading-tight uppercase italic tracking-tight">{activeGroup ? activeGroup.name : "Global Chat"}</h1>
<p className="text-[10px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-widest">{activeGroup ? `${activeGroup.members.length} MEMBERS` : t("LIVE PUBLIC FEED", "LIVE PUBLIC FEED")}</p>
</div>
</>
) : activeDmUser ? (
<>
<div className="relative cursor-pointer" onClick={() => navigate(`/profile/${activeDmUser.id}`)}>
<img src={activeDmUser.avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${activeDmUser.name}`} className="w-10 h-10 rounded-full border-2 border-indigo-100" />
<div className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 border-2 border-white rounded-full ${activeDmUser.id === 'rudra-ai-system' ? 'bg-red-600' : 'bg-green-500'}`}></div>
</div>
<div><h1 className="font-bold dark:text-white cursor-pointer hover:underline" onClick={() => navigate(`/profile/${activeDmUser.id}`)}>{activeDmUser.name}</h1><p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">@{activeDmUser.username || 'user'} {activeDmUser.role}</p></div>
</>
) : (
<div className="flex items-center gap-2 text-gray-400"><MessageCircle size={24}/><span className="font-bold">Select a friend to message</span></div>
)}
</div>
{activeTab === 'dm' && activeDmUser && activeDmUser.id !== 'rudra-ai-system' && (
<Button onClick={() => setShowGiftModal(true)} size="sm" className="bg-yellow-500 hover:bg-yellow-600 text-white rounded-xl shadow-lg shadow-yellow-500/20 font-black uppercase text-[10px] tracking-widest px-4">
<Gift size={16} className="mr-2"/> Gift Karma
</Button>
)}
</header>
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50/50 dark:bg-black/20">
{(activeTab === 'community' ? messages : directMessages).map((msg: any) => {
const isMe = profile?.id === (msg.userId || msg.senderId);
const isRudra = (msg.userId || msg.senderId) === 'rudra-ai-system';
const isKarma = msg.type === 'karma';
return (
<div key={msg.id} className={`flex gap-3 ${isMe ? 'flex-row-reverse' : ''} group animate-in slide-in-from-bottom-2 duration-300`}>
{activeTab === 'community' && (
<button onClick={() => navigate(`/profile/${msg.userId}`)} className="flex-shrink-0 transition-transform active:scale-95">
{msg.avatarUrl ? <img src={msg.avatarUrl} alt={msg.userName} className="w-8 h-8 rounded-full object-cover shadow-sm" /> : <div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shadow-sm ${isMe ? 'bg-red-500' : 'bg-gray-400'}`}>{msg.userName?.charAt(0)}</div>}
</button>
)}
<div className={`flex flex-col max-w-[75%] ${isMe ? 'items-end' : 'items-start'}`}>
{activeTab === 'community' && (
<div className="flex items-center gap-2 mb-1 px-1">
<span className="text-[10px] font-bold text-gray-600 dark:text-gray-300 cursor-pointer hover:underline" onClick={() => navigate(`/profile/${msg.userId}`)}>{isMe ? 'You' : msg.userName}</span>
<span className="text-[8px] text-gray-400 dark:text-gray-500 uppercase tracking-wider border border-gray-200 dark:border-gray-600 px-1 rounded-sm">{msg.userRole}</span>
</div>
)}
{isKarma ? (
<div className="bg-yellow-100 dark:bg-yellow-900/30 border-2 border-yellow-400/50 text-yellow-800 dark:text-yellow-200 px-4 py-3 rounded-2xl flex items-center gap-3 shadow-sm">
<div className="p-2 bg-yellow-400 text-white rounded-full"><Gift size={16}/></div>
<div><p className="text-xs font-black uppercase tracking-wide">Karma Gift</p><p className="font-bold text-lg flex items-center gap-1"><Coins size={16}/> {msg.amount}</p></div>
</div>
) : (
<div className={`px-4 py-2.5 rounded-2xl shadow-sm text-sm overflow-hidden ${isMe ? 'bg-red-600 text-white rounded-tr-none' : isRudra ? 'bg-gray-950 text-red-500 border border-red-900/50 rounded-tl-none font-mono italic shadow-[0_0_15px_rgba(220,38,38,0.1)]' : 'bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded-tl-none border border-gray-200 dark:border-gray-600'}`}>
{isRudra && <div className="flex items-center gap-1.5 mb-1.5 text-[9px] font-black uppercase tracking-widest text-red-600"><Cpu size={10}/> Rudra System</div>}
{msg.type === 'image' && msg.imageUrl ? <img src={msg.imageUrl} alt="Shared" className="max-w-full rounded-lg mb-1 cursor-pointer hover:opacity-90" onClick={() => setPreviewImage(msg.imageUrl)} /> : null}
{msg.text && <p className="leading-relaxed whitespace-pre-wrap">{msg.text}</p>}
</div>
)}
<span className="text-[9px] text-gray-400 mt-1 px-1 opacity-0 group-hover:opacity-100 transition-opacity font-medium uppercase tracking-widest">{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
<div className="p-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shrink-0 relative">
{showEmojiPicker && (
<div className="absolute bottom-full left-4 mb-2 p-3 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 grid grid-cols-6 gap-2 z-30 animate-in zoom-in-95 duration-200">{EMOJIS.map(emoji => (<button key={emoji} onClick={() => { setInput(prev => prev + emoji); setShowEmojiPicker(false); }} className="text-xl hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded-lg transition-colors">{emoji}</button>))}</div>
)}
<form onSubmit={handleSend} className="flex gap-2 items-end">
<button type="button" onClick={() => setShowEmojiPicker(!showEmojiPicker)} className={`p-3 rounded-xl transition-colors ${showEmojiPicker ? 'bg-yellow-100 text-yellow-600' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}`}><Smile size={20}/></button>
<label className="p-3 rounded-xl text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors">
<ImageIcon size={20}/>
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} />
</label>
<div className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center"><textarea value={input} onChange={(e) => setInput(e.target.value)} placeholder={t("Type a message...", "Type a message...")} className="w-full bg-transparent border-none focus:ring-0 px-4 py-3 max-h-32 min-h-[44px] resize-none text-sm dark:text-white" rows={1} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(e); } }} /></div>
<Button type="submit" disabled={!input.trim()} className="w-11 h-11 rounded-xl flex items-center justify-center bg-red-600 hover:bg-red-700 shadow-lg shadow-red-200 dark:shadow-none p-0 flex-shrink-0"><Send size={18} className={input.trim() ? "ml-0.5" : ""} /></Button>
</form>
</div>
</div>
{showCreateGroup && (
<div className="absolute inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 w-full max-w-sm rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80%] animate-in zoom-in duration-200">
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center bg-gray-50 dark:bg-gray-900"><h3 className="font-bold dark:text-white">Create New Group</h3><button onClick={() => setShowCreateGroup(false)}><X size={20} className="text-gray-500 hover:text-red-500"/></button></div>
<div className="p-4 space-y-4 overflow-y-auto">
<div><label className="text-xs font-bold text-gray-500 uppercase">Group Name</label><input className="w-full mt-1 p-2 border rounded-lg dark:bg-gray-700 dark:text-white dark:border-gray-600" placeholder="e.g. Science Project" value={newGroupName} onChange={e => setNewGroupName(e.target.value)}/></div>
<div><label className="text-xs font-bold text-gray-500 uppercase">Add Members</label><div className="relative mt-1"><Search size={16} className="absolute left-3 top-2.5 text-gray-400"/><input className="w-full pl-9 p-2 border rounded-lg dark:bg-gray-700 dark:text-white dark:border-gray-600" placeholder="Search by username..." value={searchUserQuery} onChange={e => setSearchUserQuery(e.target.value)}/></div>
{searchResults.length > 0 && (<div className="mt-2 border rounded-lg max-h-32 overflow-y-auto dark:border-gray-600">{searchResults.map(user => (<button key={user.id} onClick={() => toggleMember(user.username || '')} className="w-full flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 text-left"><div className={`w-4 h-4 rounded border flex items-center justify-center ${selectedMembers.includes(user.username || '') ? 'bg-blue-500 border-blue-500 text-white' : 'border-gray-400'}`}></div><span className="text-sm dark:text-gray-200">{user.username} ({user.name})</span></button>))}</div>)}
<div className="flex flex-wrap gap-2 mt-3">{selectedMembers.map(m => (<span key={m} className="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-xs px-2 py-1 rounded-full flex items-center gap-1">{m} <button onClick={() => toggleMember(m)}><X size={12}/></button></span>))}</div>
</div>
</div>
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"><Button onClick={handleCreateGroup} disabled={!newGroupName || selectedMembers.length === 0} className="w-full">Create Group</Button></div>
</div>
</div>
)}
{showGiftModal && activeDmUser && (
<div className="absolute inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-xs rounded-3xl shadow-2xl p-6 text-center animate-in zoom-in duration-200 border-4 border-yellow-400">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-4 text-yellow-600 shadow-inner"><Gift size={32} /></div>
<h3 className="text-xl font-black uppercase italic tracking-tighter text-gray-900 dark:text-white mb-1">Gift Karma</h3><p className="text-xs text-gray-500 mb-6 font-bold uppercase tracking-widest">To {activeDmUser.name}</p>
<div className="flex justify-center items-center gap-4 mb-6"><button onClick={() => setGiftAmount(Math.max(10, giftAmount - 10))} className="w-10 h-10 rounded-xl bg-gray-100 dark:bg-gray-800 text-gray-500 font-bold hover:bg-gray-200">-</button><div className="text-3xl font-mono font-black text-yellow-500">{giftAmount}</div><button onClick={() => setGiftAmount(Math.min((profile?.points || 0), giftAmount + 10))} className="w-10 h-10 rounded-xl bg-gray-100 dark:bg-gray-800 text-gray-500 font-bold hover:bg-gray-200">+</button></div>
<p className="text-[10px] text-gray-400 mb-6 uppercase tracking-widest">Available: {profile?.points || 0}</p>
<div className="flex gap-2"><Button variant="ghost" onClick={() => setShowGiftModal(false)} className="flex-1">Cancel</Button><Button onClick={handleGiftKarma} className="flex-1 bg-yellow-500 hover:bg-yellow-600 text-white shadow-lg shadow-yellow-500/20">Send</Button></div>
</div>
</div>
)}
{previewImage && (
<div className="fixed inset-0 z-[200] bg-black/95 backdrop-blur-xl flex items-center justify-center p-4 animate-in fade-in" onClick={() => setPreviewImage(null)}>
<button onClick={() => setPreviewImage(null)} className="absolute top-6 right-6 p-2 bg-white/10 text-white rounded-full hover:bg-red-600 transition-colors"><X size={24}/></button>
<img src={previewImage} className="max-w-full max-h-[90vh] rounded-xl shadow-2xl object-contain" onClick={(e) => e.stopPropagation()} alt="Preview" />
</div>
)}
</div>
);
};
export default CommunityChat;

386
pages/Culture.tsx Normal file
View File

@ -0,0 +1,386 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Tent, Calendar as CalendarIcon, ChevronLeft, ChevronRight, Loader2, Info, Languages, Sparkles, Clock, MapPin, CalendarDays, ExternalLink } from 'lucide-react';
import { CalendarService, NEPALI_MONTHS_DATA_2082 } from '../services/calendarService';
import { explainHoliday } from '../services/geminiService';
import { NepaliDate } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { Button } from '../components/ui/Button';
// Mock data for holidays to simulate functional next festival logic
// Now aligned with correct 2082 dates from service
const HOLIDAYS_2082: Record<string, {en: string, ne: string}> = {
"1-1": {en: "New Year 2082", ne: "नयाँ वर्ष २०८२"},
"1-11": {en: "Loktantra Diwas", ne: "लोकतन्त्र दिवस"},
"1-15": {en: "Matatirtha Aunsi", ne: "मातातीर्थ औंसी"},
"1-25": {en: "Buddha Jayanti", ne: "बुद्ध जयन्ती"},
"3-15": {en: "Dhan Diwas", ne: "धान दिवस"},
"6-3": {en: "Constitution Day", ne: "संविधान दिवस"},
"7-7": {en: "Vijaya Dashami", ne: "विजया दशमी"},
"7-30": {en: "Laxmi Puja", ne: "लक्ष्मी पूजा"},
"9-10": {en: "Christmas Day", ne: "क्रिसमस डे"},
"10-1": {en: "Maghe Sankranti", ne: "माघे संक्रान्ति"},
"11-15": {en: "Maha Shivaratri", ne: "महा शिवरात्री"},
"12-13": {en: "Holi", ne: "फागु पूर्णिमा"}
};
const Culture: React.FC = () => {
const { language, setLanguage, t } = useLanguage();
// Real-time Date Tracking
const today = CalendarService.getCurrentNepaliDate();
// Calendar State
const [currentYear, setCurrentYear] = useState(2082);
const [currentMonth, setCurrentMonth] = useState(today.month);
const [dates, setDates] = useState<NepaliDate[]>([]);
const [loading, setLoading] = useState(false);
const [monthHolidays, setMonthHolidays] = useState<NepaliDate[]>([]);
// Holiday Info State
const [selectedHoliday, setSelectedHoliday] = useState<string | null>(null);
const [explanation, setExplanation] = useState<{en: string, ne: string} | null>(null);
const [explaining, setExplaining] = useState(false);
useEffect(() => {
const fetchCalendar = async () => {
setLoading(true);
const data = await CalendarService.getDatesForMonth(currentYear, currentMonth);
setDates(data);
// Filter holidays for the side list
const holidays = data.filter(d => d.events.length > 0);
setMonthHolidays(holidays);
setLoading(false);
};
fetchCalendar();
}, [currentYear, currentMonth]);
const changeMonth = (delta: number) => {
let nextMonth = currentMonth + delta;
if (nextMonth > 12) {
nextMonth = 1;
} else if (nextMonth < 1) {
nextMonth = 12;
}
setCurrentMonth(nextMonth);
};
const jumpToToday = () => {
setCurrentMonth(today.month);
};
const handleHolidayClick = async (holidayName: string) => {
setSelectedHoliday(holidayName);
setExplanation(null);
setExplaining(true);
const cached = await CalendarService.getHolidayExplanation(holidayName);
if (cached) {
setExplanation(cached);
} else {
const aiResult = await explainHoliday(holidayName);
setExplanation(aiResult);
await CalendarService.saveHolidayExplanation(holidayName, aiResult.en, aiResult.ne);
}
setExplaining(false);
};
const currentMonthNameEn = dates[0]?.bs_month_str_en || 'Loading...';
const currentMonthNameNp = dates[0]?.bs_month_str_np || '';
// Helper to localize numeric strings
const convertToNepaliDigits = (value: number | string): string => {
const str = value.toString();
const devanagariDigits = ['', '१', '२', '३', '४', '५', '६', '७', '८', '९'];
return str.replace(/[0-9]/g, (match) => devanagariDigits[parseInt(match)]);
};
const translateWeekday = (day: string): string => {
const map: Record<string, string> = {
'Sun': 'आइत', 'Mon': 'सोम', 'Tue': 'मंगल', 'Wed': 'बुध', 'Thu': 'बिही', 'Fri': 'शुक्र', 'Sat': 'शनि'
};
return map[day] || day;
};
// Functional Next Festival Calculation
const nextFestival = useMemo(() => {
const currentSimulatedMonth = today.month;
const currentSimulatedDay = today.day;
let nearestHoliday = null;
let minDiff = Infinity;
Object.entries(HOLIDAYS_2082).forEach(([key, val]) => {
const [m, d] = key.split('-').map(Number);
// Calculate approximate days distance from "today"
let diff = (m - currentSimulatedMonth) * 30 + (d - currentSimulatedDay);
// Handle wrap around year
if (diff < 0) diff += 365;
if (diff >= 0 && diff < minDiff) {
minDiff = diff;
nearestHoliday = {
nameEn: val.en,
nameNe: val.ne,
month: m,
day: d,
daysLeft: diff
};
}
});
return nearestHoliday;
}, [today]);
return (
<div className="space-y-8 pb-24 font-sans animate-in fade-in duration-700 bg-slate-950 text-slate-100 min-h-screen p-4 md:p-8">
{/* HEADER */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div>
<h1 className="text-4xl font-black text-white flex items-center gap-3 italic tracking-tighter uppercase">
<div className="p-3 bg-red-600 rounded-2xl text-white shadow-xl shadow-red-600/20">
<Tent size={32} />
</div>
{t("Culture Hub", "Culture Hub")}
</h1>
<p className="text-slate-400 font-medium text-lg mt-2 ml-1">
{t("Celebrate our rich heritage, festivals, and traditions.", "Celebrate our rich heritage, festivals, and traditions.")}
</p>
</div>
<div className="flex items-center gap-3 bg-slate-900 p-2 rounded-2xl shadow-sm border border-slate-800">
<button
onClick={jumpToToday}
className="px-4 py-2 bg-slate-800 text-slate-300 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-slate-700 transition-colors"
>
{t("Today", "Today")}
</button>
<div className="w-px h-6 bg-slate-800"></div>
<button
onClick={() => setLanguage(language === 'en' ? 'ne' : 'en')}
className="flex items-center gap-2 px-4 py-2 bg-red-900/20 text-red-400 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-red-900/40 transition-colors"
>
<Languages size={14} />
{language === 'en' ? 'NE' : 'EN'}
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* MAIN CALENDAR */}
<div className="xl:col-span-2 space-y-6">
<div className="bg-slate-900 rounded-[2.5rem] shadow-xl border border-slate-800 overflow-hidden flex flex-col">
{/* Calendar Header */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 flex justify-between items-center border-b border-slate-800">
<button onClick={() => changeMonth(-1)} className="p-3 bg-slate-800 hover:bg-slate-700 rounded-2xl text-slate-400 shadow-sm transition-all active:scale-90 border border-slate-700">
<ChevronLeft size={24} />
</button>
<div className="text-center animate-in zoom-in duration-300" key={currentMonth}>
<div className="flex items-baseline justify-center gap-2">
<h2 className="text-3xl font-black text-white uppercase tracking-tight">
{language === 'en' ? currentMonthNameEn : currentMonthNameNp}
</h2>
<span className="text-xl font-bold text-red-500">
{language === 'en' ? currentYear : convertToNepaliDigits(currentYear)}
</span>
</div>
<p className="text-xs font-black text-slate-500 uppercase tracking-[0.2em] mt-1">
{language === 'en' ? `${currentMonthNameNp} • Nepal Sambat 1146` : `${currentMonthNameEn} • Nepal Sambat 1146`}
</p>
</div>
<button onClick={() => changeMonth(1)} className="p-3 bg-slate-800 hover:bg-slate-700 rounded-2xl text-slate-400 shadow-sm transition-all active:scale-90 border border-slate-700">
<ChevronRight size={24} />
</button>
</div>
{/* Calendar Grid */}
<div className="p-4 md:p-6 flex-1 bg-slate-950/50">
{loading ? (
<div className="h-96 flex flex-col items-center justify-center gap-4">
<Loader2 className="animate-spin text-red-600 w-10 h-10"/>
<p className="text-slate-500 text-xs font-black uppercase tracking-widest">Consulting Patro...</p>
</div>
) : (
<div className="grid grid-cols-7 gap-2 md:gap-3">
{/* Weekday Headers */}
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => (
<div key={d} className="text-center text-[10px] md:text-xs font-black text-slate-500 uppercase tracking-widest py-3">
{language === 'ne' ? translateWeekday(d) : d}
</div>
))}
{/* Days */}
{dates.map((date, idx) => {
const holidayEvent = date.events?.find(e => e.isHoliday);
const isToday = date.bs_month === today.month && date.bs_day === today.day;
// Calculate grid column start for first day
const colStart = idx === 0 ? (date.weekday_str_en === 'Sun' ? 1 : date.weekday_str_en === 'Mon' ? 2 : date.weekday_str_en === 'Tue' ? 3 : date.weekday_str_en === 'Wed' ? 4 : date.weekday_str_en === 'Thu' ? 5 : date.weekday_str_en === 'Fri' ? 6 : 7) : undefined;
return (
<div
key={`${date.bs_day}-${idx}`}
style={colStart ? { gridColumnStart: colStart } : {}}
className={`
min-h-[80px] md:min-h-[100px] border rounded-2xl p-2 relative hover:shadow-lg transition-all cursor-pointer flex flex-col justify-between overflow-hidden animate-in zoom-in duration-300 group
${isToday ? 'ring-2 ring-blue-500 bg-blue-900/10' : ''}
${date.is_holiday
? 'bg-red-900/10 border-red-900/30'
: 'bg-slate-900 border-slate-800 hover:border-slate-700'}
`}
onClick={() => holidayEvent && handleHolidayClick(language === 'en' ? holidayEvent.strEn : holidayEvent.strNp)}
>
<div className="flex justify-between items-start">
<span className={`text-lg md:text-2xl font-black ${date.is_holiday ? 'text-red-500' : 'text-slate-200'} ${isToday ? 'text-blue-400' : ''}`}>
{language === 'ne' ? convertToNepaliDigits(date.bs_day) : date.bs_day}
</span>
<span className="text-[10px] font-mono font-bold text-slate-600 group-hover:text-slate-400 transition-colors">
{date.ad_day}
</span>
</div>
{holidayEvent && (
<div className="mt-1">
<div className="text-[9px] md:text-[10px] font-bold text-red-300 leading-tight line-clamp-2 bg-black/20 rounded-lg px-1.5 py-1 backdrop-blur-sm">
{language === 'en' ? holidayEvent.strEn : holidayEvent.strNp}
</div>
</div>
)}
{date.tithi_str_en && (
<div className="hidden group-hover:block absolute bottom-1 right-2 text-[8px] text-slate-500 font-medium">
{language === 'ne' ? date.tithi_str_np : date.tithi_str_en}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
{/* Insights Panel - COMPACTED */}
<div className="bg-slate-900 rounded-3xl shadow-lg border border-slate-800 overflow-hidden">
<div className="bg-indigo-900/30 px-4 py-3 text-white flex justify-between items-center border-b border-indigo-500/20">
<h3 className="font-black uppercase tracking-widest text-xs flex items-center gap-2 text-indigo-300">
<Info size={14} /> {t("Holiday Insights", "Holiday Insights")}
</h3>
{selectedHoliday && (
<span className="text-[9px] font-bold bg-white/10 px-2 py-0.5 rounded-full">Guru Ba AI</span>
)}
</div>
<div className="p-6 min-h-[100px] flex items-center justify-center">
{!selectedHoliday ? (
<div className="flex items-center justify-center text-slate-600 text-center gap-2">
<Sparkles size={16} className="opacity-50" />
<p className="font-medium text-xs">{t("Tap a holiday for details.", "Tap a holiday for details.")}</p>
</div>
) : (
<div className="w-full animate-in fade-in slide-in-from-bottom-2">
<h3 className="text-lg font-black text-white mb-2 uppercase italic">{selectedHoliday}</h3>
{explaining ? (
<div className="flex items-center gap-2 text-indigo-500">
<Loader2 className="animate-spin" size={16} />
<p className="text-[10px] font-black uppercase tracking-widest">Consulting ancient texts...</p>
</div>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="leading-relaxed text-slate-300 font-medium text-sm">
"{language === 'en' ? explanation?.en : explanation?.ne}"
</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* SIDEBAR WIDGETS */}
<div className="space-y-6">
{/* Next Festival (Google Calendar Style) */}
{nextFestival && (
<div className="bg-white text-slate-900 rounded-[2.5rem] p-8 shadow-2xl relative overflow-hidden group border-4 border-white">
<div className="absolute top-6 right-6">
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Google_Calendar_icon_%282020%29.svg/1024px-Google_Calendar_icon_%282020%29.svg.png" className="w-8 h-8 opacity-80" alt="Calendar"/>
</div>
<div className="relative z-10">
<div className="flex items-center gap-2 mb-2 opacity-60">
<Clock size={16} className="text-blue-600"/>
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">Up Next</span>
</div>
<h3 className="text-3xl font-black tracking-tight mb-6 leading-tight max-w-[80%]">
{language === 'en' ? nextFestival.nameEn : nextFestival.nameNe}
</h3>
<div className="flex items-center gap-4">
<div className="flex flex-col items-center bg-blue-50 px-4 py-2 rounded-2xl border border-blue-100">
<span className="text-xs font-black text-blue-600 uppercase">
{/* Use NEPALI_MONTHS_DATA for name */}
{NEPALI_MONTHS_DATA_2082[nextFestival.month-1].nameEn.slice(0,3)}
</span>
<span className="text-3xl font-black text-slate-900">{nextFestival.day}</span>
</div>
<div className="flex flex-col justify-center">
<span className="text-sm font-bold text-slate-500 uppercase tracking-wider">In {nextFestival.daysLeft} Days</span>
<button className="text-[10px] font-black text-blue-600 flex items-center gap-1 mt-1 hover:underline uppercase tracking-wide">
Add to Calendar <ExternalLink size={10}/>
</button>
</div>
</div>
</div>
</div>
)}
{/* Updated Title: Month's Holidays */}
<div className="bg-slate-900 rounded-[2.5rem] shadow-xl border border-slate-800 overflow-hidden">
<div className="p-6 border-b border-slate-800 bg-slate-900/50">
<h3 className="font-black text-white uppercase tracking-widest text-xs flex items-center gap-2">
<CalendarDays size={16} className="text-orange-500"/> {t("Month's Holidays", "Month's Holidays")}
</h3>
</div>
<div className="divide-y divide-slate-800 max-h-[400px] overflow-y-auto custom-scrollbar">
{monthHolidays.map((date, i) => (
<div
key={i}
className="p-5 flex gap-4 hover:bg-slate-800/50 transition-colors cursor-pointer group"
onClick={() => handleHolidayClick(language === 'en' ? date.events[0].strEn : date.events[0].strNp)}
>
<div className="flex flex-col items-center justify-center bg-red-900/20 w-14 h-14 rounded-2xl text-red-400 shrink-0 border border-red-900/30">
<span className="text-[10px] font-black uppercase">{date.bs_month_str_en.slice(0, 3)}</span>
<span className="text-xl font-black leading-none">{date.bs_day}</span>
</div>
<div>
<h4 className="font-bold text-white text-sm leading-tight group-hover:text-red-400 transition-colors">
{language === 'en' ? date.events[0].strEn : date.events[0].strNp}
</h4>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mt-1">
{date.weekday_str_en} Public Holiday
</p>
</div>
</div>
))}
{monthHolidays.length === 0 && (
<div className="p-8 text-center text-slate-600 text-xs font-bold uppercase tracking-widest">
No holidays in {currentMonthNameEn}.
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default Culture;

338
pages/Dashboard.tsx Normal file
View File

@ -0,0 +1,338 @@
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { StorageService } from '../services/storageService';
import { UserProfile, Task, TaskStatus, Priority, FTLMission } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import {
ArrowRight, CheckSquare, Loader2,
Calendar as CalendarIcon, Zap,
Coins,
Library as LibraryIcon, MessageCircle, MapPin, Activity,
Gamepad2, CheckCircle2, Camera,
ListTodo, Siren, Utensils, Bot, Shield, ChevronRight
} from 'lucide-react';
import confetti from 'canvas-confetti';
// --- COMPONENTS ---
const XPToast = ({ amount, onComplete }: { amount: number, onComplete: () => void }) => {
useEffect(() => {
const timer = setTimeout(onComplete, 2000);
return () => clearTimeout(timer);
}, [onComplete]);
return (
<div className="absolute top-4 right-4 z-[100] pointer-events-none animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex items-center gap-2 bg-yellow-500 text-black px-3 py-1.5 rounded-full shadow-lg font-black text-xs uppercase tracking-widest">
<Zap size={12} className="fill-black" />
<span>+{amount} XP</span>
</div>
</div>
);
};
const CountUp = ({ end, duration = 1500 }: { end: number, duration?: number }) => {
const [count, setCount] = useState(0);
useEffect(() => {
let startTime: number;
let animationFrame: number;
const update = (currentTime: number) => {
if (!startTime) startTime = currentTime;
const progress = Math.min((currentTime - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - progress, 4);
setCount(Math.floor(ease * end));
if (progress < 1) animationFrame = requestAnimationFrame(update);
};
animationFrame = requestAnimationFrame(update);
return () => cancelAnimationFrame(animationFrame);
}, [end, duration]);
return <>{count}</>;
};
const getFrameStyle = (id?: string) => {
if (!id || id === 'none') return 'ring-2 ring-white/30';
if (id === 'unicorn') return 'ring-2 ring-pink-400 shadow-[0_0_15px_#f472b6]';
if (id === 'royal') return 'ring-2 ring-yellow-500 shadow-[0_0_20px_#eab308]';
if (id === 'nature') return 'ring-2 ring-green-500 border-green-300';
if (id === 'dark') return 'ring-2 ring-gray-800 shadow-[0_0_15px_#000]';
return 'ring-2 ring-white/30';
};
// --- DATA ---
const SLOGANS = [
{ en: "Small Country, Big Thinking", ne: "सानो देश, ठूलो सोच" },
{ en: "Heritage is Identity", ne: "सम्पदा नै पहिचान हो" },
{ en: "Unity in Diversity", ne: "विविधतामा एकता" },
{ en: "Digital Nepal, Smart Future", ne: "डिजिटल नेपाल, स्मार्ट भविष्य" },
];
// --- RENDERERS ---
interface TaskItemProps {
task: Task;
onComplete: (task: Task) => void | Promise<void>;
}
const TaskItem: React.FC<TaskItemProps> = ({ task, onComplete }) => (
<div className="flex items-center justify-between p-3 bg-white/5 hover:bg-white/10 rounded-xl border border-white/5 transition-colors group">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${task.priority === Priority.HIGH ? 'bg-red-500 animate-pulse' : 'bg-indigo-500'}`} />
<div className="min-w-0">
<p className="text-xs text-gray-400 font-bold uppercase tracking-wider truncate">{task.subject}</p>
<p className="text-sm font-bold text-gray-200 truncate">{task.title}</p>
</div>
</div>
<button onClick={(e) => { e.preventDefault(); onComplete(task); }} className="p-2 text-gray-500 hover:text-green-400 transition-colors">
<CheckCircle2 size={18} />
</button>
</div>
);
const Dashboard: React.FC = () => {
const { t, language } = useLanguage();
const navigate = useNavigate();
// Data State
const [profile, setProfile] = useState<UserProfile | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [missions, setMissions] = useState<FTLMission[]>([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
// UI State
const [xpGain, setXpGain] = useState<number | null>(null);
const [sloganIndex, setSloganIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => setCurrentDate(new Date()), 60000);
const sloganTimer = setInterval(() => setSloganIndex(prev => (prev + 1) % SLOGANS.length), 4000);
const fetchData = async () => {
setLoading(true);
const [p, t_data, m] = await Promise.all([
StorageService.getProfile(),
StorageService.getTasks(),
StorageService.getMissions()
]);
setProfile(p);
setTasks(t_data);
setMissions(m);
setLoading(false);
};
fetchData();
const handleUpdate = async () => {
const [p, t_data, m] = await Promise.all([
StorageService.getProfile(),
StorageService.getTasks(),
StorageService.getMissions()
]);
if (p && profile && p.xp > profile.xp) {
setXpGain(p.xp - profile.xp);
}
setProfile(p);
setTasks(t_data);
setMissions(m);
};
window.addEventListener('rudraksha-profile-update', handleUpdate);
return () => {
clearInterval(timer);
clearInterval(sloganTimer);
window.removeEventListener('rudraksha-profile-update', handleUpdate);
};
}, [profile?.xp]);
const handleQuickCompleteTask = async (task: Task) => {
const updatedStatus = TaskStatus.COMPLETED;
await StorageService.saveTask({ ...task, status: updatedStatus });
await StorageService.addPoints(10, 50);
confetti({ particleCount: 50, spread: 60, origin: { y: 0.8 } });
window.dispatchEvent(new Event('rudraksha-profile-update'));
};
if (loading && !profile) return <div className="flex justify-center items-center h-[60vh]"><Loader2 className="animate-spin text-red-600 w-12 h-12" /></div>;
const dateString = currentDate.toLocaleDateString(language === 'ne' ? 'ne-NP' : 'en-US', { weekday: 'long', month: 'long', day: 'numeric' });
const currentXP = profile?.xp || 0;
const userLevel = Math.floor(currentXP / 500) + 1;
const xpProgress = Math.min(100, Math.round(((currentXP - ((userLevel - 1) * 500)) / 500) * 100));
const pendingTasks = tasks.filter(t => t.status !== TaskStatus.COMPLETED).slice(0, 3);
const activeFTL = missions.filter(m => m.status === 'active');
const currentSlogan = SLOGANS[sloganIndex];
// Define Sections Logic
const SECTIONS = [
{
title: 'ACADEMICS',
color: 'text-indigo-500',
items: [
{ to: '/study-buddy', label: 'Rudra AI', icon: Bot, color: 'text-indigo-400', bg: 'bg-indigo-500/10', desc: 'Your Personal AI Tutor' },
{ to: '/planner', label: 'Planner', icon: CheckSquare, color: 'text-emerald-400', bg: 'bg-emerald-500/10', desc: `${pendingTasks.length} Pending Tasks` },
{ to: '/library', label: 'Library', icon: LibraryIcon, color: 'text-amber-400', bg: 'bg-amber-500/10', desc: 'Curriculum Resources' },
]
},
{
title: 'CULTURE & LIFESTYLE',
color: 'text-rose-500',
items: [
{ to: '/culture', label: 'Calendar', icon: CalendarIcon, color: 'text-rose-400', bg: 'bg-rose-500/10', desc: dateString },
{ to: '/map', label: 'Heritage Map', icon: MapPin, color: 'text-red-400', bg: 'bg-red-500/10', desc: 'Explore Nepal' },
{ to: '/recipes', label: 'Kitchen', icon: Utensils, color: 'text-orange-400', bg: 'bg-orange-500/10', desc: 'Traditional Recipes' },
]
},
{
title: 'COMMUNITY & UTILITIES',
color: 'text-blue-500',
items: [
{ to: '/community-chat', label: 'Community', icon: MessageCircle, color: 'text-blue-400', bg: 'bg-blue-500/10', desc: 'Global Chat' },
{ to: '/safety', label: 'Safety', icon: Siren, color: 'text-red-500', bg: 'bg-red-500/10', desc: activeFTL.length > 0 ? `${activeFTL.length} Active Alerts` : 'System Secure' },
{ to: '/health', label: 'Wellness', icon: Activity, color: 'text-teal-400', bg: 'bg-teal-500/10', desc: 'Health Tracker' },
{ to: '/arcade', label: 'Arcade', icon: Gamepad2, color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/10', desc: 'Play & Earn' },
{ to: '/rewards', label: 'Karma Bazaar', icon: Coins, color: 'text-yellow-400', bg: 'bg-yellow-500/10', desc: 'Redeem Points' },
]
}
];
return (
<div className="flex flex-col gap-8 pb-24">
{/* 1. HERO SECTION */}
<div className="bg-gradient-to-br from-gray-900 to-black rounded-[2.5rem] p-8 border border-white/10 relative overflow-hidden group shadow-2xl">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-20"></div>
<div className="absolute right-0 top-0 p-32 bg-red-600/20 blur-[100px] rounded-full pointer-events-none"></div>
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div className="flex items-center gap-6">
<div onClick={() => navigate('/profile', { state: { action: 'avatar' } })} className="relative cursor-pointer group/avatar">
<div className={`w-20 h-20 md:w-24 md:h-24 rounded-[1.5rem] overflow-hidden border-2 border-white/20 shadow-2xl ${getFrameStyle(profile?.frameId)}`}>
<img src={profile?.avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${profile?.name}`} className="w-full h-full object-cover transition-transform group-hover/avatar:scale-110 duration-700"/>
</div>
<div className="absolute -bottom-2 -right-2 bg-black/80 backdrop-blur text-white p-1.5 rounded-lg border border-white/10">
<Camera size={12} />
</div>
</div>
<div>
<div className="flex items-center gap-3 mb-1">
<span className="text-[10px] font-black bg-white/10 px-2 py-0.5 rounded text-gray-300 uppercase tracking-[0.2em] border border-white/5">{dateString}</span>
<span className="text-[10px] font-black text-green-500 uppercase tracking-widest flex items-center gap-1"><Shield size={10}/> ID Verified</span>
</div>
<h1 className="text-3xl md:text-5xl font-black text-white uppercase italic tracking-tighter leading-[0.9]">
Namaste, <span className="text-transparent bg-clip-text bg-gradient-to-r from-red-500 to-orange-500">{profile?.name.split(' ')[0]}</span>
</h1>
<p className="text-gray-400 font-medium mt-2 text-sm max-w-md line-clamp-1 min-h-[1.5em]">{language === 'ne' ? currentSlogan.ne : currentSlogan.en}</p>
</div>
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
<div className="bg-white/5 backdrop-blur-md px-5 py-3 rounded-2xl border border-white/10 text-center flex-1 md:flex-none">
<p className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1">Level</p>
<p className="text-3xl font-black text-white leading-none">{userLevel}</p>
</div>
<div className="bg-yellow-500/10 backdrop-blur-md px-5 py-3 rounded-2xl border border-yellow-500/20 text-center flex-1 md:flex-none">
<p className="text-[9px] font-black text-yellow-500 uppercase tracking-widest mb-1">Karma</p>
<p className="text-3xl font-black text-yellow-400 leading-none"><CountUp end={profile?.points || 0}/></p>
</div>
</div>
</div>
<div className="relative z-10 w-full mt-8">
<div className="flex justify-between text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">
<span>Progress to Level {userLevel + 1}</span>
<span>{currentXP} / {userLevel * 500} XP</span>
</div>
<div className="w-full h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-red-600 to-orange-500 shadow-[0_0_15px_rgba(220,38,38,0.5)] transition-all duration-1000 ease-out"
style={{ width: `${xpProgress}%` }}
></div>
</div>
{xpGain && <XPToast amount={xpGain} onComplete={() => setXpGain(null)} />}
</div>
</div>
{/* 2. DYNAMIC SECTIONS GRID */}
{SECTIONS.map((section, idx) => (
<div key={idx} className="space-y-4">
<div className="flex items-center gap-4 px-2">
<h3 className={`text-xs font-black uppercase tracking-[0.25em] ${section.color}`}>{section.title}</h3>
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-800"></div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{section.items.map((item, i) => (
<Link
key={i}
to={item.to}
className="bg-white dark:bg-gray-800 p-4 rounded-[1.5rem] border border-gray-100 dark:border-gray-700 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all group flex items-center gap-4"
>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${item.bg} ${item.color} group-hover:scale-110 transition-transform`}>
<item.icon size={24} />
</div>
<div>
<h4 className="font-black text-gray-900 dark:text-white uppercase tracking-tight text-sm">{item.label}</h4>
<p className="text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wide mt-0.5">{item.desc}</p>
</div>
<ChevronRight size={16} className="ml-auto text-gray-300 group-hover:text-gray-500 transition-colors opacity-0 group-hover:opacity-100"/>
</Link>
))}
</div>
</div>
))}
{/* 3. WIDGETS ROW */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Priority Tasks */}
<div className="bg-white dark:bg-gray-800 rounded-[2rem] p-6 border border-gray-100 dark:border-gray-700 shadow-lg">
<div className="flex justify-between items-center mb-6">
<h3 className="text-sm font-black text-gray-900 dark:text-white uppercase tracking-widest flex items-center gap-2">
<ListTodo size={16} className="text-indigo-500"/> Priority Queue
</h3>
<Link to="/planner" className="text-[10px] font-bold text-gray-400 uppercase hover:text-indigo-500">View All</Link>
</div>
<div className="space-y-3">
{pendingTasks.length > 0 ? pendingTasks.map(t => <TaskItem key={t.id} task={t} onComplete={handleQuickCompleteTask} />) : (
<div className="text-center py-8 text-gray-400">
<CheckCircle2 size={32} className="mx-auto mb-2 opacity-50"/>
<span className="text-xs font-bold uppercase">All Clear</span>
</div>
)}
</div>
</div>
{/* Active Alerts */}
<div className="bg-white dark:bg-gray-800 rounded-[2rem] p-6 border border-gray-100 dark:border-gray-700 shadow-lg">
<div className="flex justify-between items-center mb-6">
<h3 className="text-sm font-black text-gray-900 dark:text-white uppercase tracking-widest flex items-center gap-2">
<Siren size={16} className="text-red-500"/> FTL Network
</h3>
<Link to="/safety" className="text-[10px] font-bold text-gray-400 uppercase hover:text-red-500">View Map</Link>
</div>
<div className="space-y-3">
{activeFTL.length > 0 ? activeFTL.slice(0, 3).map(m => (
<div key={m.id} className="bg-red-50 dark:bg-red-900/10 p-3 rounded-xl border border-red-100 dark:border-red-900/30 flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 dark:bg-red-900/50 rounded-lg flex items-center justify-center text-red-600 shrink-0">
<Siren size={18} />
</div>
<div className="min-w-0">
<p className="text-xs font-black text-red-700 dark:text-red-400 truncate uppercase">{m.type} ALERT</p>
<p className="text-[10px] font-bold text-gray-500 dark:text-gray-400 truncate">{m.location}</p>
</div>
</div>
)) : (
<div className="text-center py-8 text-gray-400">
<Shield size={32} className="mx-auto mb-2 opacity-50"/>
<span className="text-xs font-bold uppercase">Sector Secure</span>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

705
pages/Game.tsx Normal file
View File

@ -0,0 +1,705 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Home, BarChart3, Settings, Flame, Zap, Brain, Target,
RefreshCw, Cpu, Gamepad2, ArrowRight, Loader2,
ArrowLeft, Volume2, VolumeX, Monitor, Smartphone, X, Power,
Trophy, Star, Activity, Shield, Info, Search, Filter,
TrendingUp, Award, Clock, ChevronRight, Mountain, CheckCircle2, Hexagon,
Dice5, Crown, Lock
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// Services & Types
import { StorageService } from '../services/storageService';
import { UserProfile, AppSettings } from '../types';
// UI Components
import { Button } from '../components/ui/Button';
import { Logo } from '../components/ui/Logo';
import { GameShell } from '../components/ui/GameShell';
// Games
import { SpeedZone } from '../games/SpeedZone';
import { MemoryShore } from '../games/MemoryShore';
import { AttentionTracks } from '../games/AttentionTracks';
import { MentalAgility } from '../games/MentalAgility';
import { LogicFuses } from '../games/LogicFuses';
import { DanpheRush } from '../games/DanpheRush';
import { ArcadeLeaderboard } from '../games/ArcadeLeaderboard';
import { Ludo } from '../games/Ludo';
import { Chess } from '../games/Chess';
import { MandalaMind } from '../games/MandalaMind';
// --- CONSTANTS & DATA ---
const CATEGORIES = [
{ id: 'all', label: 'All Protocols', icon: GridIcon },
{ id: 'speed', label: 'Reflex', icon: Zap },
{ id: 'memory', label: 'Memory', icon: Brain },
{ id: 'focus', label: 'Focus', icon: Target },
{ id: 'logic', label: 'Logic', icon: Cpu },
{ id: 'classic', label: 'Retro', icon: Gamepad2 },
];
const GAMES = [
{
id: 'speed',
title: 'Speed Zone',
desc: 'Calibrate your neural response time in a high-velocity environment.',
icon: Zap,
color: 'red',
category: 'speed',
difficulty: 'Advanced',
time: '2m',
points: '500+',
featured: true
},
{
id: 'mandala',
title: 'Mandala Mind',
desc: 'Synchronize your memory with complex audio-visual patterns.',
icon: Hexagon,
color: 'purple',
category: 'memory',
difficulty: 'Intermediate',
time: '∞',
points: 'VAR'
},
{
id: 'ludo',
title: 'Ludo King',
desc: 'Strategic board simulation for multiple neural networks.',
icon: Dice5,
color: 'purple',
category: 'classic',
difficulty: 'Easy',
time: '15m',
points: '1000+',
comingSoon: true // Changed to Coming Soon
},
{
id: 'chess',
title: 'Royal Chess',
desc: 'Grandmaster tactical evaluation protocol.',
icon: Crown,
color: 'white',
category: 'logic',
difficulty: 'Master',
time: '∞',
points: 'VAR',
comingSoon: true // Changed to Coming Soon
},
{
id: 'danphe',
title: 'Danphe Rush',
desc: 'Navigate the national bird through high-altitude obstacles.',
icon: TrendingUp,
color: 'emerald',
category: 'classic',
difficulty: 'Hard',
time: '∞',
points: 'VAR',
comingSoon: false // Active
},
{
id: 'memory',
title: 'Memory Shore',
desc: 'Identify and reconstruct complex visual patterns from short-term data.',
icon: Brain,
color: 'cyan',
category: 'memory',
difficulty: 'Intermediate',
time: '3m',
points: '400+'
},
// AttentionTracks (Focus Tracks) Removed as requested
{
id: 'flexibility',
title: 'Mental Agility',
desc: 'Switch between conflicting logic protocols without losing sync.',
icon: RefreshCw,
color: 'pink',
category: 'focus',
difficulty: 'Intermediate',
time: '3m',
points: '350+'
},
{
id: 'problem',
title: 'Logic Circuits',
desc: 'Debug and complete sequential energy paths to restore system flow.',
icon: Cpu,
color: 'emerald',
category: 'logic',
difficulty: 'Advanced',
time: '4m',
points: '600+'
}
];
// --- HELPER COMPONENTS ---
function GridIcon(props: any) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
</svg>
);
}
const EnhancedNeuralBackground = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const mouseRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationFrameId: number;
let particles: any[] = [];
const particleCount = 60;
const connectionDistance = 150;
class Particle {
x: number; y: number; vx: number; vy: number; size: number; baseSize: number;
constructor(width: number, height: number) {
this.x = Math.random() * width;
this.y = Math.random() * height;
this.vx = (Math.random() - 0.5) * 0.4;
this.vy = (Math.random() - 0.5) * 0.4;
this.baseSize = Math.random() * 2 + 1;
this.size = this.baseSize;
}
update(width: number, height: number) {
this.x += this.vx; this.y += this.vy;
if (this.x < 0 || this.x > width) this.vx *= -1;
if (this.y < 0 || this.y > height) this.vy *= -1;
const dx = this.x - mouseRef.current.x;
const dy = this.y - mouseRef.current.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 100) {
this.size = this.baseSize * 2;
} else {
this.size = this.baseSize;
}
}
draw() {
if (!ctx) return;
ctx.fillStyle = 'rgba(99, 102, 241, 0.4)';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
particles = [];
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle(canvas.width, canvas.height));
}
};
const handleMouseMove = (e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
};
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < particles.length; i++) {
particles[i].update(canvas.width, canvas.height);
particles[i].draw();
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < connectionDistance) {
const alpha = (1 - dist / connectionDistance) * 0.15;
ctx.strokeStyle = `rgba(129, 140, 248, ${alpha})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
animationFrameId = requestAnimationFrame(animate);
};
window.addEventListener('resize', resize);
window.addEventListener('mousemove', handleMouseMove);
resize();
animate();
return () => {
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', handleMouseMove);
cancelAnimationFrame(animationFrameId);
};
}, []);
return <canvas ref={canvasRef} className="absolute inset-0 w-full h-full pointer-events-none z-0" />;
};
const ArcadeSettingsModal = ({ onClose }: { onClose: () => void }) => {
const [settings, setSettings] = useState<AppSettings | null>(null);
useEffect(() => {
StorageService.getSettings().then(setSettings);
}, []);
const toggleSetting = async (key: keyof AppSettings) => {
if (!settings) return;
const newSettings = { ...settings, [key]: !settings[key as keyof AppSettings] };
setSettings(newSettings);
await StorageService.saveSettings(newSettings);
};
if (!settings) return null;
return (
<div className="fixed inset-0 z-[600] bg-black/80 backdrop-blur-md flex items-center justify-center p-4 animate-in fade-in duration-200">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-slate-900 border border-slate-700 w-full max-w-sm rounded-3xl overflow-hidden shadow-2xl"
>
<div className="p-6 border-b border-slate-800 flex justify-between items-center bg-slate-950">
<h2 className="text-xl font-black italic uppercase tracking-tighter text-white flex items-center gap-2">
<Settings size={20} className="text-indigo-500"/> System Config
</h2>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full text-slate-400 hover:text-white transition-colors">
<X size={20}/>
</button>
</div>
<div className="p-6 space-y-4">
<SettingToggle label="Audio FX" active={settings.soundEnabled} icon={Volume2} onClick={() => toggleSetting('soundEnabled')} />
<SettingToggle label="Haptics" active={settings.hapticFeedback} icon={Smartphone} onClick={() => toggleSetting('hapticFeedback')} color="bg-emerald-500" />
<SettingToggle label="High Perf" active={!settings.dataSaver} icon={Monitor} onClick={() => toggleSetting('dataSaver')} color="bg-purple-500" />
</div>
</motion.div>
</div>
);
};
const SettingToggle = ({ label, active, icon: Icon, onClick, color = "bg-indigo-500" }: any) => (
<div className="flex items-center justify-between p-4 bg-slate-800/50 rounded-2xl border border-slate-700/50">
<div className="flex items-center gap-3 text-slate-300">
<Icon size={20} />
<span className="font-bold text-sm uppercase tracking-wide">{label}</span>
</div>
<button onClick={onClick} className={`w-12 h-6 rounded-full p-1 transition-colors ${active ? color : 'bg-slate-700'}`}>
<div className={`w-4 h-4 bg-white rounded-full shadow-md transform transition-transform ${active ? 'translate-x-6' : 'translate-x-0'}`} />
</button>
</div>
);
const SidebarIcon = ({ icon: Icon, active = false, label, onClick }: any) => (
<motion.div
onClick={onClick}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="group relative flex items-center justify-center w-full py-4 cursor-pointer"
>
<div className={`p-3 rounded-2xl transition-all duration-300 ${active ? 'bg-indigo-600 text-white shadow-[0_0_15px_#4f46e5]' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}>
<Icon size={24} />
</div>
<div className="absolute left-full ml-4 px-3 py-1.5 bg-slate-900 text-white text-[10px] font-black uppercase tracking-widest rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-[110] border border-white/10 shadow-2xl hidden md:block">
{label}
</div>
</motion.div>
);
const GameCard = ({ game, onClick }: any) => {
const { title, desc, icon: Icon, color, difficulty, time, points, comingSoon } = game;
const styles: any = {
red: { text: 'text-red-400', border: 'hover:border-red-500', shadow: 'hover:shadow-[0_0_30px_rgba(239,68,68,0.3)]', bg: 'from-red-500/10' },
cyan: { text: 'text-cyan-400', border: 'hover:border-cyan-500', shadow: 'hover:shadow-[0_0_30px_rgba(34,211,238,0.3)]', bg: 'from-cyan-500/20' },
amber: { text: 'text-amber-400', border: 'hover:border-amber-500', shadow: 'hover:shadow-[0_0_30px_rgba(245,158,11,0.3)]', bg: 'from-amber-500/10' },
purple: { text: 'text-purple-400', border: 'hover:border-purple-500', shadow: 'hover:shadow-[0_0_30px_rgba(168,85,247,0.3)]', bg: 'from-purple-500/10' },
emerald: { text: 'text-emerald-400', border: 'hover:border-emerald-500', shadow: 'hover:shadow-[0_0_30px_rgba(16,185,129,0.3)]', bg: 'from-emerald-500/10' },
pink: { text: 'text-pink-400', border: 'hover:border-pink-500', shadow: 'hover:shadow-[0_0_30px_rgba(236,72,153,0.3)]', bg: 'from-pink-500/10' },
blue: { text: 'text-blue-400', border: 'hover:border-blue-500', shadow: 'hover:shadow-[0_0_30px_rgba(59,130,246,0.3)]', bg: 'from-blue-500/10' },
white: { text: 'text-gray-200', border: 'hover:border-gray-200', shadow: 'hover:shadow-[0_0_30px_rgba(255,255,255,0.2)]', bg: 'from-gray-500/10' }
}[color];
return (
<motion.div
variants={{ hidden: { opacity: 0, y: 30 }, visible: { opacity: 1, y: 0 } }}
whileHover={{ y: comingSoon ? 0 : -5 }}
whileTap={{ scale: comingSoon ? 1 : 0.98 }}
onClick={() => !comingSoon && onClick(game.id)}
className={`group relative h-80 md:h-96 rounded-[2rem] md:rounded-[2.5rem] bg-slate-900/40 backdrop-blur-md border border-white/10 overflow-hidden transition-all duration-500 ${comingSoon ? 'opacity-70 cursor-not-allowed' : `cursor-pointer ${styles.border} ${styles.shadow}`}`}
>
<div className={`absolute inset-0 bg-gradient-to-br ${styles.bg} to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
{comingSoon && (
<div className="absolute inset-0 bg-black/60 z-20 flex flex-col items-center justify-center pointer-events-none">
<Lock size={48} className="text-white/50 mb-2"/>
<span className="text-white/80 font-black uppercase tracking-widest text-xs">Coming Soon</span>
</div>
)}
<div className="relative z-10 p-6 md:p-10 h-full flex flex-col justify-between">
<div>
<div className="flex justify-between items-start mb-6">
<div className={`p-3 md:p-4 bg-black/40 rounded-2xl border border-white/5 ${styles.text} transition-transform duration-500 group-hover:scale-110 group-hover:rotate-6`}>
<Icon size={28} className="md:w-8 md:h-8" />
</div>
<div className="flex flex-col items-end gap-1">
<span className="text-[9px] md:text-[10px] font-black uppercase tracking-widest text-slate-500 bg-black/30 px-2 py-1 rounded-md border border-white/5">{difficulty}</span>
<span className="text-[9px] md:text-[10px] font-bold text-slate-400 flex items-center gap-1"><Clock size={10}/> {time}</span>
</div>
</div>
<h3 className="text-2xl md:text-3xl font-black italic uppercase tracking-tighter text-white mb-2 leading-none">{title}</h3>
<p className="text-slate-400 font-medium text-xs md:text-sm leading-relaxed line-clamp-2">{desc}</p>
</div>
<div className="space-y-3 md:space-y-4">
<div className="flex items-center justify-between text-[9px] md:text-[10px] font-black uppercase tracking-[0.2em]">
<span className="text-slate-500">Max Potential</span>
<span className={styles.text}>{points} Pts</span>
</div>
<div className="w-full h-1 md:h-1.5 bg-white/5 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: '100%' }}
className={`h-full bg-gradient-to-r ${styles.bg.replace('from-', 'bg-')}`}
/>
</div>
<Button disabled={comingSoon} className={`w-full bg-white/5 border-white/10 ${!comingSoon ? 'group-hover:bg-white group-hover:text-black' : ''} transition-all duration-500 rounded-xl h-10 md:h-12 uppercase text-[10px] md:text-xs font-black tracking-widest`}>
{comingSoon ? 'Locked' : 'Initiate Link'}
</Button>
</div>
</div>
<Icon size={180} className={`absolute -right-12 -bottom-12 opacity-[0.03] transition-all duration-1000 group-hover:opacity-[0.1] group-hover:rotate-12 ${styles.text}`} />
</motion.div>
);
};
// --- MAIN HUB COMPONENT ---
const Game: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [activeScreen, setActiveScreen] = useState('hub');
const [activeCategory, setActiveCategory] = useState('all');
const [profile, setProfile] = useState<UserProfile | null>(() => {
try {
const stored = localStorage.getItem('rudraksha_profile');
return stored ? JSON.parse(stored) : null;
} catch { return null; }
});
const [loading, setLoading] = useState(!profile);
const [showSettings, setShowSettings] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// Weekly Stats Data
const [weeklyStats, setWeeklyStats] = useState<number[]>([]);
useEffect(() => {
// Background refresh of profile & sessions
const init = async () => {
const [p, sessions] = await Promise.all([
StorageService.getProfile(),
StorageService.getGameSessions()
]);
setProfile(p);
// Calculate Weekly Stats (Last 7 Days)
const stats = new Array(7).fill(0);
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
sessions.forEach(s => {
const date = new Date(s.timestamp);
if (date > oneWeekAgo) {
// Calculate days difference (0 = today, 6 = 7 days ago)
// We want to map it to the 7 bars
// Simplified: Calculate day index relative to today
// Actually, let's just create a map for last 7 days strings
// For this visualization, let's map sessions to the last 7 calendar days
const dayDiff = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (dayDiff >= 0 && dayDiff < 7) {
// Store play time in minutes
stats[6 - dayDiff] += Math.ceil(s.durationSeconds / 60);
}
}
});
setWeeklyStats(stats);
setLoading(false);
};
init();
if (location.state && (location.state as any).autoLaunch) {
setActiveScreen((location.state as any).autoLaunch);
navigate(location.pathname, { replace: true, state: {} });
}
}, [location, activeScreen]); // Refresh stats when returning to hub
const filteredGames = useMemo(() => {
return GAMES.filter(g => {
const matchesCategory = activeCategory === 'all' || g.category === activeCategory;
const matchesSearch = g.title.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
}).sort((a, b) => {
// Sort: Active games first, Coming Soon last
if (a.comingSoon === b.comingSoon) return 0;
return a.comingSoon ? 1 : -1;
});
}, [activeCategory, searchQuery]);
const isGameActive = !['hub', 'leaderboard'].includes(activeScreen);
if (loading) return (
<div className="fixed inset-0 z-[100] bg-slate-950 flex flex-col items-center justify-center">
<div className="relative">
<Loader2 className="animate-spin text-indigo-500 w-16 h-16" />
<div className="absolute inset-0 bg-indigo-500 blur-2xl opacity-20 animate-pulse"></div>
</div>
<p className="mt-8 text-slate-500 font-black uppercase tracking-[0.5em] text-[10px]">Syncing Neural Data</p>
</div>
);
return (
<div className="fixed inset-0 z-50 bg-slate-950 text-white flex overflow-hidden font-sans">
{!isGameActive && <EnhancedNeuralBackground />}
<div className="relative z-10 flex w-full h-full flex-col md:flex-row">
{/* SIDEBAR */}
{!isGameActive && (
<aside className="w-full md:w-24 flex flex-row md:flex-col items-center justify-between md:justify-start px-4 md:px-0 py-3 md:py-8 bg-black/40 backdrop-blur-3xl border-b md:border-b-0 md:border-r border-white/5 shrink-0 z-[100] fixed bottom-0 md:relative md:bottom-auto">
<motion.div whileHover={{ scale: 1.1, rotate: 10 }} className="hidden md:block mb-12">
<Logo className="w-12 h-12" />
</motion.div>
<nav className="flex md:flex-col items-center justify-around w-full md:space-y-4 md:px-2">
<SidebarIcon icon={Home} active={activeScreen === 'hub'} label="Main Hub" onClick={() => setActiveScreen('hub')} />
<SidebarIcon icon={BarChart3} active={activeScreen === 'leaderboard'} label="Rankings" onClick={() => setActiveScreen('leaderboard')} />
<SidebarIcon icon={Settings} active={showSettings} label="System" onClick={() => setShowSettings(true)} />
</nav>
<div className="hidden md:flex mt-auto space-y-6 flex-col items-center">
<div className="p-1 bg-white/5 rounded-full border border-white/10">
<img src={profile?.avatarUrl || "https://api.dicebear.com/7.x/bottts/svg?seed=Felix"} className="w-10 h-10 rounded-full" alt="avatar" />
</div>
<button
onClick={() => navigate('/')}
className="group relative w-12 h-12 flex items-center justify-center bg-red-600/10 border border-red-500/20 rounded-xl hover:bg-red-600 hover:text-white transition-all duration-300"
>
<Power size={20} />
<span className="absolute left-full ml-4 px-2 py-1 bg-red-900 text-white text-[8px] font-black uppercase tracking-widest rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap hidden md:block">Logout</span>
</button>
</div>
</aside>
)}
{/* MAIN VIEW */}
<main className={`flex-1 h-full overflow-y-auto no-scrollbar relative ${isGameActive ? 'bg-black' : ''} pb-20 md:pb-0`}>
<AnimatePresence mode="wait">
{activeScreen === 'hub' ? (
<motion.div
key="hub"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="max-w-[1600px] mx-auto px-4 md:px-8 lg:px-16 py-8 md:py-12 pb-32"
>
{/* HEADER SECTION */}
<header className="flex flex-col lg:flex-row justify-between items-start lg:items-end mb-8 md:mb-12 gap-6 md:gap-8">
<div className="space-y-3 md:space-y-4">
<div className="flex items-center gap-3">
<span className="px-3 py-1 bg-indigo-500/20 border border-indigo-500/30 text-indigo-400 text-[9px] md:text-[10px] font-black uppercase tracking-widest rounded-full">System v4.2.0</span>
<span className="flex items-center gap-1 text-emerald-400 text-[9px] md:text-[10px] font-black uppercase tracking-widest">
<Activity size={12} /> Neural Link: Stable
</span>
</div>
<h1 className="text-4xl md:text-5xl lg:text-7xl font-black italic tracking-tighter uppercase text-white leading-[0.9]">
Neural <span className="text-indigo-500">Arcade</span>
</h1>
<p className="text-base md:text-xl text-slate-400 font-medium">
Welcome back, <span className="text-white font-bold">{profile?.name?.split(' ')[0] || 'Operator'}</span>. Cognitive load is 12%.
</p>
</div>
<div className="flex gap-4 w-full md:w-auto">
<div className="bg-slate-900/50 backdrop-blur-xl border border-white/10 rounded-3xl p-4 md:p-6 flex items-center gap-4 md:gap-6 shadow-2xl w-full md:w-auto">
<div className="flex flex-col">
<span className="text-[9px] md:text-[10px] font-black text-slate-500 uppercase tracking-widest">Global Rank</span>
<span className="text-xl md:text-2xl font-black text-white italic">#1,402</span>
</div>
<div className="w-px h-8 bg-white/10" />
<div className="flex flex-col">
<span className="text-[9px] md:text-[10px] font-black text-slate-500 uppercase tracking-widest">Neural Score</span>
<div className="flex items-center gap-2">
<Flame size={16} className="text-orange-500 fill-orange-500 md:w-5 md:h-5" />
<span className="text-xl md:text-2xl font-mono font-black text-white">{profile?.points?.toLocaleString() || '0'}</span>
</div>
</div>
</div>
</div>
</header>
{/* SEARCH & FILTER BAR */}
<div className="flex flex-col md:flex-row gap-4 mb-8 md:mb-12 sticky top-0 z-40 bg-slate-950/80 backdrop-blur-md py-4 -mx-4 px-4 border-b border-white/5">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
<input
type="text"
placeholder="SEARCH PROTOCOLS..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-2xl pl-12 pr-4 py-3 md:py-4 text-xs font-black tracking-widest focus:outline-none focus:border-indigo-500 transition-colors uppercase"
/>
</div>
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-1 md:pb-0">
{CATEGORIES.map(cat => (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`flex items-center gap-2 px-4 md:px-6 py-3 md:py-4 rounded-2xl border text-[10px] font-black uppercase tracking-widest whitespace-nowrap transition-all ${
activeCategory === cat.id
? 'bg-indigo-600 border-indigo-500 text-white shadow-[0_0_15px_rgba(79,70,229,0.3)]'
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10'
}`}
>
<cat.icon size={14} /> {cat.label}
</button>
))}
</div>
</div>
{/* GAMES GRID */}
<motion.div
initial="hidden" animate="visible"
variants={{ visible: { transition: { staggerChildren: 0.1 } } }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8"
>
{filteredGames.length > 0 ? (
filteredGames.map(game => (
<GameCard key={game.id} game={game} onClick={setActiveScreen} />
))
) : (
<div className="col-span-full py-20 text-center">
<Info size={48} className="mx-auto text-slate-700 mb-4" />
<h3 className="text-slate-500 font-black uppercase tracking-widest">No Protocols Found</h3>
</div>
)}
</motion.div>
{/* DASHBOARD WIDGETS */}
<section className="mt-12 md:mt-20 grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8">
<div className="lg:col-span-2 bg-gradient-to-br from-indigo-900/20 to-slate-900/40 rounded-[2rem] md:rounded-[3rem] p-6 md:p-10 border border-white/5 relative overflow-hidden">
<div className="relative z-10">
<div className="flex justify-between items-center mb-6 md:mb-8">
<h2 className="text-xl md:text-2xl font-black italic uppercase text-white flex items-center gap-2">
<TrendingUp className="text-indigo-400" /> Neural Performance (Minutes)
</h2>
<button className="text-[9px] md:text-[10px] font-black text-indigo-400 hover:text-white transition-colors uppercase tracking-widest">Weekly Report</button>
</div>
<div className="h-32 md:h-48 flex items-end justify-between gap-2 px-2 md:px-4">
{weeklyStats.length > 0 ? weeklyStats.map((mins, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-2 md:gap-4 group">
<div className="text-[8px] text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity absolute mb-36">{mins}m</div>
<motion.div
initial={{ height: 0 }} whileInView={{ height: `${Math.min(100, (mins / 60) * 100)}%` }}
className={`w-full rounded-t-lg md:rounded-t-xl transition-all cursor-help ${mins > 0 ? 'bg-gradient-to-t from-indigo-600 to-indigo-400 group-hover:from-indigo-400 group-hover:to-white' : 'bg-white/5 h-1'}`}
/>
<span className="text-[8px] font-black text-slate-500 uppercase tracking-tighter">D-{6-i}</span>
</div>
)) : (
<div className="w-full text-center text-slate-600 font-bold uppercase tracking-widest text-xs">No data recorded yet</div>
)}
</div>
</div>
<div className="absolute top-1/2 right-0 -translate-x-1/2 opacity-10 pointer-events-none">
<Activity size={200} strokeWidth={1} className="md:w-[300px] md:h-[300px]" />
</div>
</div>
<div className="bg-slate-900/40 rounded-[2rem] md:rounded-[3rem] p-6 md:p-10 border border-white/5">
<h2 className="text-xl md:text-2xl font-black italic uppercase text-white mb-6 md:mb-8 flex items-center gap-2">
<Award className="text-amber-400" /> Medals
</h2>
<div className="space-y-4">
{[
{ label: 'Reflex Master', desc: 'Reach 0.2s reaction time', icon: Zap, color: 'text-red-400' },
{ label: 'Focus Guru', desc: '5min session in focus tracks', icon: Target, color: 'text-amber-400' },
{ label: 'Grandmaster', desc: 'Reach level 50 in logic', icon: Shield, color: 'text-indigo-400' }
].map((item, i) => (
<div key={i} className="flex items-center gap-4 p-4 bg-white/5 rounded-2xl border border-white/5 hover:border-white/20 transition-all cursor-pointer">
<div className={`p-3 bg-black/40 rounded-xl ${item.color}`}>
<item.icon size={20} />
</div>
<div>
<p className="text-[10px] font-black text-white uppercase tracking-widest">{item.label}</p>
<p className="text-[9px] md:text-[10px] text-slate-500 font-medium">{item.desc}</p>
</div>
<ChevronRight size={14} className="ml-auto text-slate-700" />
</div>
))}
</div>
</div>
</section>
</motion.div>
) : activeScreen === 'leaderboard' ? (
<ArcadeLeaderboard onExit={() => setActiveScreen('hub')} />
) : (
/* --- GAME SHELL WRAPPER --- */
<motion.div key="game-view" className="h-full w-full">
{activeScreen === 'speed' && (
<SpeedZone onExit={() => setActiveScreen('hub')} />
)}
{activeScreen === 'memory' && (
<MemoryShore onExit={() => setActiveScreen('hub')} />
)}
{activeScreen === 'attention' && (
<GameShell gameId="attention" title="Focus Tracks" onExit={() => setActiveScreen('hub')}>
{({ onGameOver }) => <AttentionTracks onExit={() => setActiveScreen('hub')} />}
</GameShell>
)}
{activeScreen === 'flexibility' && (
<MentalAgility onExit={() => setActiveScreen('hub')} />
)}
{activeScreen === 'problem' && (
<GameShell gameId="logic" title="Logic Circuits" onExit={() => setActiveScreen('hub')}>
{({ onGameOver }) => <LogicFuses onExit={() => setActiveScreen('hub')} />}
</GameShell>
)}
{activeScreen === 'danphe' && (
<DanpheRush onExit={() => setActiveScreen('hub')} />
)}
{activeScreen === 'ludo' && (
<Ludo onExit={() => setActiveScreen('hub')} />
)}
{activeScreen === 'chess' && (
<Chess onExit={() => setActiveScreen('hub')} />
)}
{activeScreen === 'mandala' && (
<MandalaMind onExit={() => setActiveScreen('hub')} />
)}
</motion.div>
)}
</AnimatePresence>
</main>
</div>
{showSettings && <ArcadeSettingsModal onClose={() => setShowSettings(false)} />}
</div>
);
};
export default Game;

174
pages/Greeting.tsx Normal file
View File

@ -0,0 +1,174 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { StorageService } from '../services/storageService';
import { UserProfile } from '../types';
import { Logo } from '../components/ui/Logo';
import { TypewriterLoop, TextReveal } from '../components/animations/TextReveal';
import { ArrowRight, Activity, Zap } from 'lucide-react';
const Greeting: React.FC = () => {
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [showContent, setShowContent] = useState(false);
const [showInteraction, setShowInteraction] = useState(false);
const [showWish, setShowWish] = useState(false);
useEffect(() => {
const init = async () => {
const p = await StorageService.getProfile();
if (!p) {
navigate('/auth');
return;
}
setProfile(p);
// Accelerated entrance sequence for better UX
setTimeout(() => setShowContent(true), 100);
// Trigger wish text
setTimeout(() => {
setShowWish(true);
}, 1000);
// Reveal the button much faster so it's not "missed"
setTimeout(() => {
setShowInteraction(true);
}, 2000);
};
init();
}, [navigate]);
const handleEnter = () => {
navigate('/');
};
if (!profile) return null;
const firstName = profile.name.split(' ')[0];
// Time-based wish logic
const hour = new Date().getHours();
const timeOfDay = hour < 12 ? "morning" : hour < 18 ? "afternoon" : "evening";
const wishText = `Wishing you a productive ${timeOfDay}.`;
return (
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-black relative overflow-hidden font-sans text-white selection:bg-red-500/30 p-6">
{/* High-Resolution Dynamic Background Decor - Updated to Mountain Night */}
<div
className="absolute inset-0 bg-cover bg-center opacity-70"
style={{ backgroundImage: "url('https://images.unsplash.com/photo-1486870591958-9b9d0d1dda99?q=80&w=2400&auto=format&fit=crop')" }}
></div>
{/* Overlays - Darker for text legibility without boxes */}
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-transparent to-black/90"></div>
<div className="absolute inset-0 bg-black/20"></div>
{/* Massive Background Logo Watermark */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none overflow-hidden opacity-[0.05]">
<Logo className="w-[120vh] h-[120vh] animate-bg-pulse" />
</div>
<div className={`z-10 flex flex-col items-center justify-center w-full max-w-5xl relative transition-all duration-1000 transform pb-20 ${showContent ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'}`}>
{/* Top Status Indicators */}
<div className="flex items-center gap-6 mb-16 px-8 py-3 rounded-full border border-white/5 animate-in fade-in slide-in-from-top-4 duration-1000">
<div className="flex items-center gap-2.5">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse shadow-[0_0_10px_#22c55e]"></div>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-gray-300">Identity Verified</span>
</div>
<div className="w-px h-3 bg-white/20"></div>
<div className="flex items-center gap-2.5">
<Activity size={12} className="text-red-500" />
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-gray-300">System Ready</span>
</div>
</div>
{/* Logo Section */}
<div className="relative mb-12 transform hover:scale-110 transition-transform duration-700 animate-in zoom-in duration-1000">
<div className="absolute inset-0 bg-red-600 blur-[80px] opacity-20 animate-pulse"></div>
<Logo className="w-28 h-28 md:w-48 md:h-48 relative z-10 drop-shadow-[0_0_50px_rgba(220,38,38,0.8)]" />
</div>
{/* Main Welcome Title */}
<h1 className="text-4xl md:text-[5rem] font-black text-white uppercase italic tracking-tighter mb-8 animate-in fade-in slide-in-from-bottom-2 duration-1000 delay-500 text-center leading-[0.9] drop-shadow-[0_10px_30px_rgba(0,0,0,0.8)]">
Welcome to <span className="text-red-500 drop-shadow-[0_0_30px_rgba(220,38,38,0.6)]">Rudraksha</span>
</h1>
{/* Dynamic Greeting Text - Clean (No Box) */}
<div className="text-center w-full max-w-4xl animate-in zoom-in duration-700 mb-10">
<div className="text-3xl md:text-6xl font-black tracking-tighter leading-tight text-white drop-shadow-[0_5px_15px_rgba(0,0,0,1)]">
<TypewriterLoop
words={[
`Namaste, ${firstName}`,
`नमस्ते, ${firstName}`, // Nepali
`ज्वजलपा, ${firstName}`, // Newari
`सेवारो, ${firstName}`, // Limbu
`टाशी देलेक, ${firstName}`, // Sherpa/Tibetan
`लसकुस, ${firstName}`, // Newari (Welcome)
"Success awaits.",
"Let's begin."
]}
className="inline-block"
typingSpeed={70}
deletingSpeed={35}
pauseDuration={1800}
/>
</div>
</div>
{/* Wish text */}
<div className={`transition-all duration-1000 absolute bottom-36 ${showWish ? 'opacity-100 scale-100' : 'opacity-0 scale-95'}`}>
{showWish && (
<div className="text-lg md:text-xl font-medium text-gray-300 tracking-[0.2em] uppercase drop-shadow-md">
<TextReveal text={wishText} delay={0} type="stagger" className="justify-center" />
</div>
)}
</div>
{/* Interaction Button - "START DAY" */}
<div className={`mt-8 transition-all duration-1000 transform ${showInteraction ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-16 scale-90 pointer-events-none'}`}>
<button
onClick={handleEnter}
className="group relative px-12 py-6 md:px-20 md:py-10 bg-white text-black rounded-[4rem] flex items-center gap-8 transition-all hover:scale-105 active:scale-95 shadow-[0_0_60px_rgba(255,255,255,0.3)] overflow-hidden border-4 border-transparent hover:border-red-600"
>
{/* Visual Effects */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-red-500/20 to-transparent -translate-x-full group-hover:animate-shine transition-all duration-1000"></div>
<span className="relative z-10 text-2xl md:text-4xl font-black tracking-[0.3em] uppercase italic">Start Day</span>
<div className="relative z-10 w-12 h-12 md:w-16 md:h-16 bg-black rounded-full flex items-center justify-center group-hover:rotate-12 transition-all shadow-xl">
<ArrowRight size={32} className="text-white group-hover:scale-125 transition-transform" />
</div>
</button>
{/* Interaction Hint */}
<div className="flex flex-col items-center mt-8 gap-3 opacity-50 animate-bounce">
<Zap size={16} className="fill-white text-white" />
</div>
</div>
</div>
<style>{`
@keyframes shine {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes bg-pulse {
0%, 100% { opacity: 0.05; transform: scale(1); }
50% { opacity: 0.1; transform: scale(1.05); }
}
.animate-bg-pulse {
animation: bg-pulse 12s ease-in-out infinite;
}
.group:hover .animate-shine {
animation: shine 1.2s infinite;
}
`}</style>
</div>
);
};
export default Greeting;

896
pages/Health.tsx Normal file
View File

@ -0,0 +1,896 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { StorageService } from '../services/storageService';
import { AirQualityService } from '../services/airQualityService';
import { interpretDream, createHealthAssistant } from '../services/geminiService';
import { HealthLog, AQIData, WeatherData, ChatMessage } from '../types';
import {
Heart, Droplets, Smile, Moon, Plus, Minus, Loader2, Wind,
CloudFog, MapPin, Sun, CloudRain, CloudLightning, Activity,
Leaf, Thermometer, Umbrella, Eye, Dumbbell, Flower2,
Sprout, Timer, Apple, Users, ShieldCheck, HeartPulse, Play,
Waves, Sparkles, Brain, Zap, BatteryMedium, CloudMoon, BookOpen, Quote,
ChevronRight, X, Send, BarChart, MessageSquare, Info, GripVertical, Pause, RotateCcw, CheckCircle, Languages, Mic
} from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import confetti from 'canvas-confetti';
import { Button } from '../components/ui/Button';
import { BarChart as RechartsBarChart, Bar, ResponsiveContainer, XAxis, Tooltip, Cell, YAxis } from 'recharts';
const QUOTES = [
{ en: "The earth does not belong to us: we belong to the earth.", ne: "पृथ्वी हाम्रो होइन, हामी पृथ्वीका हौं।" },
{ en: "A healthy body houses a healthy mind.", ne: "स्वस्थ शरीरमा नै स्वस्थ मनको बास हुन्छ।" },
{ en: "Look deep into nature, and then you will understand everything better.", ne: "प्रकृतिमा गहिरिएर हेर्नुहोस्, अनि सबै कुरा राम्ररी बुझ्नुहुनेछ।" },
];
const YOGA_POSES = [
{
id: 'yoga_1',
name: 'Surya Namaskar',
neName: 'सूर्य नमस्कार',
icon: Sun,
benefits: 'Full body workout, improves blood circulation.',
neBenefits: 'पूरा शरीरको व्यायाम, रक्तसञ्चारमा सुधार।',
steps: '12 steps combining 7 different asanas.',
neSteps: '७ विभिन्न आसनहरू मिलाएर १२ चरणहरू।',
detailedSteps: [
"Stand straight, palms folded in prayer pose. Breathe in.",
"Raise arms overhead, arch back slightly. Breathe out.",
"Bend forward, touch your feet. Keep knees straight.",
"Step right leg back, look up (Equestrian Pose).",
"Step left leg back into Plank pose. Keep body straight.",
"Lower knees, chest, and chin to floor (Ashtanga Namaskar).",
"Slide forward into Cobra pose. Look up.",
"Lift hips into Inverted V (Mountain pose).",
"Step right foot forward between hands.",
"Step left foot forward, bend down.",
"Raise arms overhead, stretch back.",
"Return to standing prayer pose."
],
detailedStepsNe: [
"सीधा उभिनुहोस्, हातहरू जोडेर प्रार्थना मुद्रामा। सास लिनुहोस्।",
"हातहरू टाउको माथि उठाउनुहोस्, अलिकति पछाडि ढल्किनुहोस्। सास छोड्नुहोस्।",
"अगाडि झुक्नुहोस्, खुट्टा छुनुहोस्। घुँडाहरू सीधा राख्नुहोस्।",
"दायाँ खुट्टा पछाडि सार्नुहोस्, माथि हेर्नुहोस् (अश्व सञ्चालन आसन)।",
"बायाँ खुट्टा पछाडि सार्नुहोस् र प्ल्याङ्क पोजमा जानुहोस्। शरीर सीधा राख्नुहोस्।",
"घुँडा, छाती र चिउँडो भुइँमा राख्नुहोस् (अष्टाङ्ग नमस्कार)।",
"अगाडि सर्दै कोब्रा पोज (भुजङ्गासन) मा जानुहोस्। माथि हेर्नुहोस्।",
"कम्मर माथि उठाउनुहोस् र उल्टो V आकार (पर्वतासन) बनाउनुहोस्।",
"दायाँ खुट्टा हातहरूको बीचमा अगाडि ल्याउनुहोस्।",
"बायाँ खुट्टा अगाडि ल्याउनुहोस्, तल झुक्नुहोस्।",
"हातहरू माथि उठाउनुहोस्, पछाडि तन्किनुहोस्।",
"प्रार्थना मुद्रामा फर्कनुहोस्।"
]
},
{
id: 'yoga_2',
name: 'Pranayama',
neName: 'प्राणायाम',
icon: Wind,
benefits: 'Reduces stress, improves lung capacity.',
neBenefits: 'तनाव कम गर्छ, फोक्सोको क्षमता बढाउँछ।',
steps: 'Breath awareness and controlled breathing.',
neSteps: 'श्वासप्रश्वासको जागरूकता र नियन्त्रित श्वास।',
detailedSteps: [
"Sit in a comfortable cross-legged position.",
"Close your eyes and relax your shoulders.",
"Inhale deeply through your nose for 4 seconds.",
"Hold your breath for 4 seconds.",
"Exhale slowly through your nose for 6 seconds.",
"Repeat this cycle for 5 minutes."
],
detailedStepsNe: [
"आरामदायी पलेँटी कसरे बस्नुहोस्।",
"आँखा बन्द गर्नुहोस् र काँधहरूलाई खुकुलो छोड्नुहोस्।",
"नाकबाट ४ सेकेन्डसम्म गहिरो सास लिनुहोस्।",
"४ सेकेन्डसम्म सास रोक्नुहोस्।",
"बिस्तारै ६ सेकेन्डसम्म नाकबाट सास छोड्नुहोस्।",
"यो प्रक्रिया ५ मिनेटसम्म दोहोर्याउनुहोस्।"
]
},
{
id: 'yoga_3',
name: 'Vrikshasana',
neName: 'वृक्षासन',
icon: Sprout,
benefits: 'Improves balance and leg strength.',
neBenefits: 'सन्तुलन र खुट्टाको बल सुधार गर्छ।',
steps: 'Stand on one leg, foot on inner thigh.',
neSteps: 'एउटा खुट्टामा उभिनुहोस्, अर्को पाइतला तिघ्रामा राख्नुहोस्।',
detailedSteps: [
"Stand tall with feet together.",
"Shift weight to left leg.",
"Place right foot on inner left thigh.",
"Bring hands to prayer position at chest.",
"Raise hands above head, keep elbows straight.",
"Focus on a point in front of you. Hold.",
"Slowly lower hands and leg. Repeat other side."
],
detailedStepsNe: [
"खुट्टाहरू जोडेर सीधा उभिनुहोस्।",
"तौल बायाँ खुट्टामा सार्नुहोस्।",
"दायाँ खुट्टाको पाइतला बायाँ तिघ्राको भित्री भागमा राख्नुहोस्।",
"हातहरू छातीको अगाडि नमस्कार मुद्रामा ल्याउनुहोस्।",
"हातहरू टाउको माथि उठाउनुहोस्, कुहिनो सीधा राख्नुहोस्।",
"अगाडि एउटा बिन्दुमा ध्यान केन्द्रित गर्नुहोस्। अडिनुहोस्।",
"बिस्तारै हात र खुट्टा तल झार्नुहोस्। अर्को तर्फ दोहोर्याउनुहोस्।"
]
}
];
const AYURVEDA_TIPS = [
{
title: 'Dinacharya (Daily Routine)',
neTitle: 'दिनचर्या',
icon: Timer,
tip: 'Wake up before sunrise (Brahma Muhurta) to synchronize with nature.',
neTip: 'प्रकृतिसँग तालमेल मिलाउन सूर्योदयभन्दा अगाडि (ब्रह्म मुहूर्त) उठ्नुहोस्।'
},
{
title: 'Warm Water Secret',
neTitle: 'तातो पानीको रहस्य',
icon: Droplets,
tip: 'Sip warm water throughout the day to boost metabolism and digestion.',
neTip: 'मेटाबोलिज्म र पाचन बढाउन दिनभरि तातो पानी पिउनुहोस्।'
},
{
title: 'Herbal Power',
neTitle: 'जडीबुटी शक्ति',
icon: Leaf,
tip: 'Chew a few Tulsi leaves daily to strengthen your immune system.',
neTip: 'रोग प्रतिरोधात्मक क्षमता बलियो बनाउन दिनहुँ तुलसीका पातहरू चपाउनुहोस्।'
}
];
const LONGEVITY_PILLARS = [
{ en: "Active Lifestyle", ne: "सक्रिय जीवनशैली", icon: Dumbbell, desc: "Walk at least 30 mins daily in nature.", neDesc: "प्रकृतिमा दैनिक कम्तीमा ३० मिनेट हिड्नुहोस्।" },
{ en: "Sattvic Diet", ne: "सात्त्विक आहार", icon: Apple, desc: "Eat fresh, seasonal, and plant-based foods.", neDesc: "ताजा, मौसमी र वनस्पतिमा आधारित खाना खानुहोस्।" },
{ en: "Social Connection", ne: "सामाजिक सम्बन्ध", icon: Users, desc: "Talk to loved ones daily to reduce cortisol.", neDesc: "तनाव कम गर्न दैनिक प्रियजनहरूसँग कुरा गर्नुहोस्।" },
{ en: "Consistent Sleep", ne: "नियमित निद्रा", icon: Moon, desc: "Rest is the best medicine. Sleep 7-8 hours.", neDesc: "आराम नै उत्तम औषधि हो। ७-८ घण्टा सुत्नुहोस्।" }
];
// --- YOGA SESSION OVERLAY COMPONENT ---
const YogaSession = ({ pose, onClose }: { pose: any, onClose: () => void }) => {
const { t } = useLanguage();
const [stepIndex, setStepIndex] = useState(0);
const [timer, setTimer] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isFinished, setIsFinished] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [sessionLang, setSessionLang] = useState<'en' | 'ne' | null>(null);
const [awardMessage, setAwardMessage] = useState<string>('');
const timerRef = useRef<any>(null);
const steps = sessionLang === 'ne' ? (pose.detailedStepsNe || pose.detailedSteps) : pose.detailedSteps;
// Enhanced Voice Feedback Function
const speakStep = useCallback((text: string) => {
if (!window.speechSynthesis) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 0.9;
utterance.pitch = 1;
utterance.volume = 1;
// Find best voice for Nepali
if (sessionLang === 'ne') {
const voices = window.speechSynthesis.getVoices();
// Try Nepali specific, then Hindi (often works for Devanagari), then generic
const neVoice = voices.find(v => v.lang.includes('ne')) ||
voices.find(v => v.lang.includes('hi')) ||
voices.find(v => v.lang.includes('IN'));
if (neVoice) utterance.voice = neVoice;
utterance.lang = 'hi-IN'; // Fallback hint
} else {
utterance.lang = 'en-US';
}
utterance.onstart = () => setIsSpeaking(true);
utterance.onend = () => setIsSpeaking(false);
window.speechSynthesis.speak(utterance);
}, [sessionLang]);
// Ensure voices are loaded (Chrome quirk)
useEffect(() => {
window.speechSynthesis.getVoices();
}, []);
// Handle initial start
useEffect(() => {
if (sessionLang && !isFinished && !isPlaying) {
setIsPlaying(true);
setTimer(0);
// Intro Voice Instruction
const intro = sessionLang === 'ne'
? `${pose.neName} सुरु हुँदैछ। तयार हुनुहोस्।`
: `Starting ${pose.name}. Get ready.`;
speakStep(intro);
// Wait for intro to finish approx before starting steps logic
setTimeout(() => {
const stepOne = sessionLang === 'ne'
? `चरण एक। ${steps[0]}`
: `Step 1. ${steps[0]}`;
speakStep(stepOne);
}, 3000);
}
}, [sessionLang, pose, speakStep, steps]);
// Voice Control Listener
useEffect(() => {
const handleVoiceControl = (e: CustomEvent) => {
if (!sessionLang || isFinished) return;
const { action } = e.detail;
if (action === 'next') {
if (stepIndex < steps.length - 1) {
setStepIndex(prev => prev + 1);
} else {
finishSession();
}
} else if (action === 'prev') {
if (stepIndex > 0) {
setStepIndex(prev => prev - 1);
}
} else if (action === 'repeat') {
const txt = sessionLang === 'ne'
? `फेरि भन्दै। ${steps[stepIndex]}`
: `Repeating. ${steps[stepIndex]}`;
speakStep(txt);
} else if (action === 'exit') {
onClose();
}
};
window.addEventListener('rudraksha-yoga-control' as any, handleVoiceControl);
return () => window.removeEventListener('rudraksha-yoga-control' as any, handleVoiceControl);
}, [sessionLang, stepIndex, steps, isFinished, onClose, speakStep]);
// Effect for step changes (Trigger speech when stepIndex updates)
useEffect(() => {
if (isPlaying && !isFinished && sessionLang) {
// Don't speak immediately on mount (handled by intro effect), only on change
if (timer > 0 || stepIndex > 0) {
const prefix = sessionLang === 'ne' ? 'चरण' : 'Step';
const num = sessionLang === 'ne'
? String(stepIndex + 1).replace(/[0-9]/g, d => "०१२३४५६७८९"[Number(d)])
: (stepIndex + 1);
speakStep(`${prefix} ${num}. ${steps[stepIndex]}`);
}
}
}, [stepIndex, sessionLang]);
// Timer Logic
useEffect(() => {
if (isPlaying && !isFinished) {
timerRef.current = setInterval(() => {
setTimer(prev => prev + 1);
}, 1000);
} else {
clearInterval(timerRef.current);
}
return () => clearInterval(timerRef.current);
}, [isPlaying, isFinished]);
// Cleanup speech on unmount
useEffect(() => {
return () => {
window.speechSynthesis.cancel();
};
}, []);
const nextStep = () => {
if (stepIndex < steps.length - 1) {
setStepIndex(prev => prev + 1);
} else {
finishSession();
}
};
const prevStep = () => {
if (stepIndex > 0) {
setStepIndex(prev => prev - 1);
}
};
const finishSession = async () => {
setIsFinished(true);
setIsPlaying(false);
const finalText = sessionLang === 'ne' ? "सत्र पूरा भयो। नमस्ते।" : "Session complete. Namaste.";
speakStep(finalText);
// Awarding Logic via StorageService
const result = await StorageService.trackYogaSession(pose.id);
setAwardMessage(result.message);
if (result.awarded) {
confetti({ particleCount: 150, spread: 70, origin: { y: 0.6 }, colors: ['#a855f7', '#6366f1'] });
}
};
if (!sessionLang) {
return (
<div className="fixed inset-0 z-[200] bg-black/95 flex flex-col items-center justify-center p-6 animate-in fade-in duration-500">
<div className="w-full max-w-md bg-white dark:bg-gray-900 rounded-[2rem] p-8 text-center space-y-8">
<h2 className="text-2xl font-black text-gray-900 dark:text-white uppercase tracking-tighter">Select Instruction Language</h2>
<div className="grid grid-cols-2 gap-4">
<Button onClick={() => setSessionLang('en')} className="h-16 text-lg font-black bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl">
English
</Button>
<Button onClick={() => setSessionLang('ne')} className="h-16 text-lg font-black bg-red-600 hover:bg-red-700 text-white rounded-2xl">
(Nepali)
</Button>
</div>
<Button variant="ghost" onClick={onClose} className="text-gray-500 hover:text-white">Cancel</Button>
</div>
</div>
);
}
const formatTimer = (t: number) => {
const m = Math.floor(t / 60);
const s = t % 60;
if (sessionLang === 'ne') {
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return timeStr.replace(/[0-9]/g, d => "०१२३४५६७८९"[Number(d)]);
}
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
return (
<div className="fixed inset-0 z-[200] bg-black/95 flex flex-col items-center justify-center p-6 animate-in fade-in duration-500">
<button onClick={onClose} className="absolute top-6 right-6 p-4 bg-white/10 rounded-full hover:bg-red-500 hover:text-white transition-all text-white"><X size={24}/></button>
{!isFinished ? (
<div className="w-full max-w-2xl text-center space-y-12">
<div className="space-y-4">
<div className="w-24 h-24 bg-indigo-500/20 rounded-full flex items-center justify-center mx-auto border-4 border-indigo-500 shadow-[0_0_40px_rgba(99,102,241,0.4)] animate-pulse">
<pose.icon size={48} className="text-indigo-400"/>
</div>
<h2 className="text-4xl font-black text-white uppercase italic tracking-tighter">{sessionLang === 'ne' ? pose.neName : pose.name}</h2>
<div className="text-indigo-300 font-bold uppercase tracking-widest text-sm">
{sessionLang === 'ne'
? `चरण ${String(stepIndex + 1).replace(/[0-9]/g, d => "०१२३४५६७८९"[Number(d)])} / ${String(steps.length).replace(/[0-9]/g, d => "०१२३४५६७८९"[Number(d)])}`
: `Step ${stepIndex + 1} / ${steps.length}`}
</div>
</div>
<div className="bg-white/5 border border-white/10 p-10 rounded-[3rem] relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-white/10">
<div className="h-full bg-indigo-500 transition-all duration-300" style={{ width: `${((stepIndex + 1) / steps.length) * 100}%` }}></div>
</div>
<p className="text-2xl md:text-4xl font-medium text-white leading-relaxed font-serif">"{steps[stepIndex]}"</p>
<div className="mt-8 flex justify-center items-center gap-2 text-indigo-400 font-mono text-xl">
<Timer size={20} className={isPlaying ? "animate-spin-slow" : ""}/>
<span>{formatTimer(timer)}</span>
</div>
{/* Voice Control Hint */}
<div className="absolute bottom-4 left-0 right-0 text-center opacity-40">
<p className="text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2">
<Mic size={12}/>
{sessionLang === 'ne'
? "भन्नुहोस्: अर्को, अघिल्लो, फेरि"
: 'Say "Next", "Back", "Repeat"'}
</p>
</div>
</div>
<div className="flex justify-center gap-6">
<Button onClick={prevStep} disabled={stepIndex === 0} variant="secondary" className="w-16 h-16 rounded-full flex items-center justify-center bg-white/10 text-white border-none hover:bg-white/20"><ChevronRight size={24} className="rotate-180"/></Button>
<Button onClick={() => setIsPlaying(!isPlaying)} className={`w-24 h-24 rounded-[2rem] flex items-center justify-center shadow-2xl transition-all ${isPlaying ? 'bg-yellow-500 text-black' : 'bg-green-600 text-white'}`}>
{isPlaying ? <Pause size={32} fill="currentColor"/> : <Play size={32} fill="currentColor"/>}
</Button>
<Button onClick={nextStep} variant="secondary" className="w-16 h-16 rounded-full flex items-center justify-center bg-white/10 text-white border-none hover:bg-white/20"><ChevronRight size={24}/></Button>
</div>
</div>
) : (
<div className="text-center space-y-8 animate-in zoom-in duration-500">
<div className="w-32 h-32 bg-green-500 rounded-full flex items-center justify-center mx-auto shadow-2xl shadow-green-500/40">
<CheckCircle size={64} className="text-white"/>
</div>
<div>
<h2 className="text-5xl font-black text-white uppercase italic tracking-tighter mb-2">{sessionLang === 'ne' ? "नमस्ते" : "Namaste"}</h2>
<p className="text-gray-400 text-lg">{sessionLang === 'ne' ? "सत्र सफलतापूर्वक सम्पन्न भयो।" : "Session Completed Successfully."}</p>
</div>
<div className="bg-white/10 p-6 rounded-3xl border border-white/10">
<div className="text-sm font-black text-yellow-400 uppercase tracking-widest mb-1">Status</div>
<div className="text-xl font-bold text-white">{awardMessage}</div>
</div>
<div className="flex gap-4 justify-center">
<Button onClick={() => { setIsFinished(false); setStepIndex(0); setTimer(0); setIsPlaying(true); }} variant="secondary" className="bg-white/10 text-white border-none">
<RotateCcw size={18} className="mr-2"/> Repeat
</Button>
<Button onClick={onClose} className="bg-white text-black font-black">
Return to Hub
</Button>
</div>
</div>
)}
</div>
);
};
const Health: React.FC = () => {
const today = new Date().toISOString().split('T')[0];
const { language, t } = useLanguage();
const [activeTab, setActiveTab] = useState<'climate' | 'personal' | 'yog' | 'ancient' | 'dream'>('climate');
const [log, setLog] = useState<HealthLog | null>(null);
const [aqiData, setAqiData] = useState<AQIData | null>(null);
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
const [loadingClimate, setLoadingClimate] = useState(false);
const [loadingLog, setLoadingLog] = useState(false);
const [quote, setQuote] = useState(QUOTES[0]);
const [dreamInput, setDreamInput] = useState('');
const [dreamResult, setDreamResult] = useState<{
folklore: { en: string, ne: string },
psychology: { en: string, ne: string },
symbol: string
} | null>(null);
const [isDreaming, setIsDreaming] = useState(false);
// Yoga Interaction State
const [isSelectingPose, setIsSelectingPose] = useState(false);
const [activeYogaPose, setActiveYogaPose] = useState<any>(null);
useEffect(() => {
const fetchClimate = async () => {
setLoadingClimate(true);
try {
const [aqi, weather] = await Promise.all([
AirQualityService.getAQI(),
AirQualityService.getWeather()
]);
setAqiData(aqi);
setWeatherData(weather);
} catch (e) {
console.error("Climate Fetch Error:", e);
} finally {
setLoadingClimate(false);
}
};
fetchClimate();
setQuote(QUOTES[Math.floor(Math.random() * QUOTES.length)]);
}, []);
useEffect(() => {
if (activeTab === 'personal' && !log) {
const fetchLog = async () => {
setLoadingLog(true);
try {
const data = await StorageService.getHealthLog(today);
setLog(data);
} finally {
setLoadingLog(false);
}
};
fetchLog();
}
}, [activeTab, log, today]);
const updateLog = async (newLog: HealthLog) => {
setLog(newLog);
await StorageService.saveHealthLog(newLog);
if (newLog.waterGlasses === 8) {
StorageService.addPoints(10);
confetti({
particleCount: 150,
spread: 70,
origin: { y: 0.6 },
colors: ['#3b82f6', '#60a5fa', '#93c5fd']
});
}
};
const handleDreamInterpret = async () => {
if (!dreamInput.trim()) return;
setIsDreaming(true);
setDreamResult(null);
try {
const result = await interpretDream(dreamInput);
setDreamResult(result);
} catch (e) {
console.error(e);
} finally {
setIsDreaming(false);
}
};
const getWeatherIcon = (condition: string) => {
switch(condition) {
case 'Sunny': return <Sun size={64} className="text-yellow-400 animate-float"/>;
case 'Rainy': return <CloudRain size={64} className="text-blue-400 animate-float"/>;
case 'Stormy': return <CloudLightning size={64} className="text-purple-500 animate-float"/>;
case 'Foggy': return <CloudFog size={64} className="text-gray-400 animate-float"/>;
default: return <Sun size={64} className="text-orange-400 animate-float"/>;
}
};
const getWeatherGradient = (condition: string) => {
switch(condition) {
case 'Sunny': return "from-blue-400 to-blue-200 dark:from-blue-800 dark:to-blue-600";
case 'Rainy': return "from-slate-700 to-slate-500 dark:from-gray-800 dark:to-gray-700";
case 'Foggy': return "from-gray-400 to-gray-200 dark:from-gray-700 dark:to-gray-600";
default: return "from-blue-500 to-cyan-400";
}
};
const getMoodEmoji = (mood: string) => {
switch(mood) {
case 'Happy': return { emoji: '😊', color: 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-600' };
case 'Neutral': return { emoji: '😐', color: 'bg-gray-100 dark:bg-gray-700 text-gray-500' };
case 'Stressed': return { emoji: '😫', color: 'bg-red-100 dark:bg-red-900/40 text-red-600' };
case 'Tired': return { emoji: '😴', color: 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600' };
default: return { emoji: '😐', color: 'bg-gray-100 dark:bg-gray-700 text-gray-500' };
}
};
const isWaterOverflowing = log && log.waterGlasses >= 8;
const handleStartFlow = () => {
setIsSelectingPose(true);
};
const handleSelectPose = (pose: any) => {
setIsSelectingPose(false);
setActiveYogaPose(pose);
};
return (
<div className="space-y-10 pb-20 relative">
{activeYogaPose && <YogaSession pose={activeYogaPose} onClose={() => setActiveYogaPose(null)} />}
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6">
<div>
<h1 className="text-4xl font-black text-gray-900 dark:text-white flex items-center gap-3 italic">
<HeartPulse className="text-teal-600 animate-pulse" size={36} /> {t("Wellness Centre", "Wellness Centre")}
</h1>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium mt-1">
{t("Harmonizing ancient wisdom with modern health analytics.", "Harmonizing ancient wisdom with modern health analytics.")}
</p>
</div>
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-xl p-2 rounded-[2rem] flex flex-wrap gap-2 shadow-xl border border-white dark:border-gray-700">
{[
{ id: 'climate', icon: Wind, label: 'Environment', color: 'text-teal-500' },
{ id: 'personal', icon: BatteryMedium, label: 'Daily Log', color: 'text-red-500' },
{ id: 'yog', icon: Waves, label: 'Yoga Flow', color: 'text-indigo-500' },
{ id: 'ancient', icon: Sparkles, label: 'Wisdom', color: 'text-amber-500' },
{ id: 'dream', icon: CloudMoon, label: 'Sapana', color: 'text-purple-500' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`px-6 py-3 rounded-2xl text-sm font-black flex items-center gap-3 transition-all ${activeTab === tab.id ? 'bg-white dark:bg-gray-700 shadow-xl scale-105 text-gray-900 dark:text-white' : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}`}
aria-pressed={activeTab === tab.id}
>
<tab.icon size={20} className={tab.color}/> {t(tab.label, tab.label)}
</button>
))}
</div>
</header>
<div className="min-h-[400px]">
{/* CLIMATE VIEW */}
{activeTab === 'climate' && (
<div className="animate-in fade-in slide-in-from-bottom-8 duration-700">
{loadingClimate ? (
<div className="flex flex-col items-center justify-center py-32 gap-4">
<Loader2 className="animate-spin text-teal-600 w-12 h-12" />
<p className="text-gray-500 font-bold animate-pulse uppercase tracking-widest text-xs">Locating Environment...</p>
</div>
) : weatherData && aqiData ? (
<div className="space-y-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className={`relative overflow-hidden rounded-[3rem] p-10 text-white shadow-2xl bg-gradient-to-br ${getWeatherGradient(weatherData.condition)} group`}>
<div className="absolute top-[-50px] right-[-50px] w-48 h-48 bg-white/10 rounded-full blur-3xl group-hover:bg-white/20 transition-all"></div>
<div className="relative z-10 flex justify-between items-start">
<div>
<h2 className="text-xl font-black opacity-90 flex items-center gap-2 uppercase tracking-widest"><MapPin size={20}/> {weatherData.location}</h2>
<p className="text-sm font-bold opacity-75 mt-1">{new Date().toDateString()}</p>
<div className="mt-12">
<h1 className="text-8xl font-black tracking-tighter drop-shadow-lg">{weatherData.temp}°</h1>
<p className="text-2xl font-bold mt-2 uppercase italic tracking-tighter">{t(weatherData.condition, weatherData.condition)}</p>
</div>
</div>
<div className="flex flex-col items-end gap-6">
{getWeatherIcon(weatherData.condition)}
<div className="bg-white/20 backdrop-blur-xl rounded-[2rem] p-6 text-sm font-black space-y-2 min-w-[160px] border border-white/20 shadow-2xl">
<div className="flex justify-between border-b border-white/10 pb-1"><span>{t("Humidity", "Humidity")}</span> <span>{weatherData.humidity}%</span></div>
<div className="flex justify-between border-b border-white/10 pb-1"><span>{t("Wind", "Wind")}</span> <span>{weatherData.windSpeed} km/h</span></div>
<div className="flex justify-between"><span>{t("Feels Like", "Feels Like")}</span> <span>{weatherData.feelsLike}°</span></div>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-[3rem] shadow-2xl border-4 border-gray-50 dark:border-gray-700 p-10 flex flex-col justify-between group">
<div className="flex justify-between items-start">
<div>
<h3 className="text-gray-400 dark:text-gray-500 font-black uppercase tracking-[0.3em] text-xs mb-2">{t("Air Quality Index", "Air Quality Index")}</h3>
<h2 className="text-6xl font-black text-gray-900 dark:text-white mt-1 tracking-tighter" style={{color: aqiData.color}}>{aqiData.aqi}</h2>
<span className="inline-block px-5 py-2 rounded-full text-white text-xs font-black uppercase tracking-widest mt-4 shadow-xl" style={{backgroundColor: aqiData.color}}>
{t(aqiData.status, aqiData.status)}
</span>
</div>
<div className="w-24 h-24 rounded-[2rem] flex items-center justify-center bg-gray-50 dark:bg-gray-700 shadow-inner group-hover:rotate-12 transition-transform duration-500">
<Wind className="text-teal-500" size={48}/>
</div>
</div>
<div className="mt-10 space-y-5">
<div className="p-6 bg-gray-50 dark:bg-gray-900/50 rounded-[2rem] flex gap-6 items-center border border-transparent group-hover:border-teal-500/20 transition-all">
<div className="p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-xl text-blue-500"><CloudFog size={28}/></div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1">{t("Dominant Pollutant", "Dominant Pollutant")}</p>
<p className="text-xl font-black text-gray-900 dark:text-white">{aqiData.pollutant}</p>
</div>
</div>
<div className="p-6 bg-blue-50/50 dark:bg-blue-900/10 rounded-[2rem] flex gap-6 items-center border-2 border-blue-100 dark:border-blue-900/30">
<div className="p-4 bg-white dark:bg-blue-800 rounded-2xl shadow-xl text-blue-500"><ShieldCheck size={28}/></div>
<div>
<p className="text-[10px] font-black text-blue-500 uppercase tracking-widest mb-1">{t("Health Advice", "Health Advice")}</p>
<p className="text-base font-bold text-blue-900 dark:text-blue-100 leading-tight italic">"{aqiData.advice}"</p>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="text-center py-32 opacity-40">
<MapPin size={48} className="mx-auto mb-4" />
<p className="font-bold">Weather data unavailable. Please check location permissions.</p>
</div>
)}
</div>
)}
{/* DREAM INTERPRETER VIEW */}
{activeTab === 'dream' && (
<div className="animate-in fade-in slide-in-from-right-8 duration-700">
<div className="bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900 rounded-[3.5rem] p-10 md:p-14 text-white shadow-2xl relative overflow-hidden">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/stardust.png')] opacity-20 animate-pulse-slow"></div>
<div className="absolute -right-20 -top-20 w-96 h-96 bg-purple-500/20 rounded-full blur-[120px]"></div>
<div className="relative z-10 max-w-4xl mx-auto space-y-10">
<div className="text-center space-y-4">
<CloudMoon size={64} className="mx-auto text-purple-300 animate-float"/>
<h2 className="text-5xl font-black uppercase italic tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200">
{t("Sapana Interpreter", "Sapana Interpreter")}
</h2>
<p className="text-indigo-200/80 font-medium text-lg max-w-xl mx-auto">
{t("Unlock the hidden messages of your subconscious through the lens of ancient Nepali folklore and modern psychology.", "Unlock the hidden messages of your subconscious through the lens of ancient Nepali folklore and modern psychology.")}
</p>
</div>
<div className="bg-white/10 backdrop-blur-xl rounded-[2.5rem] p-8 border border-white/10 shadow-2xl">
<textarea
value={dreamInput}
onChange={(e) => setDreamInput(e.target.value)}
className="w-full h-32 bg-transparent text-white placeholder-purple-200/50 text-xl font-medium outline-none resize-none text-center"
placeholder={t("Describe your dream here... (e.g. I saw a snake in a temple)", "Describe your dream here... (e.g. I saw a snake in a temple)")}
aria-label="Describe your dream"
/>
<div className="flex justify-center mt-6">
<Button
onClick={handleDreamInterpret}
disabled={!dreamInput.trim() || isDreaming}
className="bg-white text-purple-900 hover:bg-purple-100 font-black px-12 py-6 rounded-[2rem] text-xl shadow-lg shadow-purple-500/30 transition-all hover:scale-105 active:scale-95 flex items-center gap-3"
>
{isDreaming ? <Loader2 className="animate-spin"/> : <Sparkles className="fill-purple-600"/>}
{t("REVEAL MEANING", "REVEAL MEANING")}
</Button>
</div>
</div>
{dreamResult && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in slide-in-from-bottom-8 duration-700">
<div className="bg-amber-900/40 backdrop-blur-md rounded-[2.5rem] p-8 border border-amber-500/30 hover:border-amber-500/60 transition-colors group">
<h3 className="text-amber-400 font-black uppercase tracking-widest text-xs mb-4 flex items-center gap-2">
<BookOpen size={16}/> {t("Traditional Folklore", "Traditional Folklore")}
</h3>
<p className="text-amber-100 font-medium text-lg leading-relaxed italic">
"{language === 'ne' ? dreamResult.folklore.ne : dreamResult.folklore.en}"
</p>
</div>
<div className="bg-cyan-900/40 backdrop-blur-md rounded-[2.5rem] p-8 border border-cyan-500/30 hover:border-cyan-500/60 transition-colors group">
<h3 className="text-cyan-400 font-black uppercase tracking-widest text-xs mb-4 flex items-center gap-2">
<Brain size={16}/> {t("Psychological View", "Psychological View")}
</h3>
<p className="text-cyan-100 font-medium text-lg leading-relaxed italic">
"{language === 'ne' ? dreamResult.psychology.ne : dreamResult.psychology.en}"
</p>
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* PERSONAL HEALTH VIEW */}
{activeTab === 'personal' && (
<div className="animate-in fade-in slide-in-from-right-8 duration-700">
{loadingLog ? (
<div className="flex flex-col items-center justify-center py-32 gap-4">
<Loader2 className="animate-spin text-red-600 w-12 h-12" />
<p className="text-gray-500 font-bold animate-pulse uppercase tracking-widest text-xs">Accessing Ritual Logs...</p>
</div>
) : log ? (
<div className="space-y-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="bg-white dark:bg-gray-800 p-10 rounded-[3rem] shadow-2xl border-2 border-blue-50 dark:border-blue-900/30 flex flex-col items-center group relative overflow-visible">
<div className="relative mb-8 pt-4">
{isWaterOverflowing && (
<>
<div className="absolute top-4 -left-2 w-1.5 h-20 bg-blue-400/40 rounded-full blur-[1px] animate-[slideDown_1.5s_infinite] origin-top"></div>
<div className="absolute top-4 -right-2 w-1.5 h-24 bg-blue-400/40 rounded-full blur-[1px] animate-[slideDown_1.8s_infinite] origin-top"></div>
</>
)}
<div className={`w-28 h-40 relative rounded-b-[2rem] border-x-4 border-b-8 border-blue-100/30 dark:border-blue-900/40 overflow-hidden bg-blue-50/10 dark:bg-gray-950 shadow-2xl transition-transform duration-500 group-hover:scale-105 z-10 ${isWaterOverflowing ? 'ring-2 ring-blue-400/20' : ''}`}>
<div className="absolute top-0 left-0 right-0 h-2 bg-white/20 border-b border-white/10 z-20"></div>
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-blue-600 to-blue-400 transition-all duration-1000 ease-out`}
style={{ height: `${Math.min(100, (log.waterGlasses / 8) * 100)}%` }}
>
<div className={`absolute top-0 left-0 w-[200%] h-4 bg-white/30 animate-wave ${isWaterOverflowing ? 'opacity-90 scale-y-125' : 'opacity-40'}`}></div>
{isWaterOverflowing && <div className="absolute top-0 left-0 w-full h-full bg-blue-300/10 animate-pulse"></div>}
</div>
<div className="absolute top-0 left-2 w-3 h-full bg-white/10 blur-[2px] rounded-full pointer-events-none z-30"></div>
</div>
</div>
<h2 className="font-black text-gray-900 dark:text-white mb-6 text-xl uppercase tracking-tighter relative z-10">{t("Hydration Track", "Hydration Track")}</h2>
<div className="flex items-center gap-8 relative z-10">
<button onClick={() => updateLog({ ...log, waterGlasses: Math.max(0, log.waterGlasses - 1) })} className="w-14 h-14 rounded-2xl bg-gray-100 dark:bg-gray-700 hover:bg-red-50 dark:hover:bg-red-900/30 text-gray-500 hover:text-red-500 flex items-center justify-center transition-all active:scale-90 shadow-sm" aria-label="Decrease water"><Minus size={28}/></button>
<div className="text-center">
<span className={`text-7xl font-black drop-shadow-sm transition-colors ${isWaterOverflowing ? 'text-blue-500' : 'text-blue-600 dark:text-blue-400'}`}>{log.waterGlasses}</span>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-[0.2em] mt-2">Glasses / 8</p>
</div>
<button onClick={() => updateLog({ ...log, waterGlasses: log.waterGlasses + 1 })} className="w-14 h-14 rounded-2xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center shadow-2xl active:scale-90 transition-all" aria-label="Increase water"><Plus size={28}/></button>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-10 rounded-[3rem] shadow-2xl border-2 border-yellow-50 dark:border-yellow-900/30 flex flex-col items-center">
<div className={`w-24 h-24 ${getMoodEmoji(log.mood).color} rounded-[2rem] flex items-center justify-center mb-8 shadow-xl rotate-3 animate-float text-5xl transition-all duration-500`} aria-hidden="true">{getMoodEmoji(log.mood).emoji}</div>
<h2 className="font-black text-gray-900 dark:text-white mb-8 text-xl uppercase tracking-tighter">{t("Mood Ritual", "Mood Ritual")}</h2>
<div className="grid grid-cols-2 gap-4 w-full">
{['Happy', 'Neutral', 'Stressed', 'Tired'].map((m: any) => (
<button key={m} onClick={() => updateLog({...log, mood: m})} className={`py-4 rounded-2xl text-sm font-black uppercase tracking-widest transition-all ${log.mood === m ? 'bg-yellow-500 text-white shadow-xl scale-105' : 'bg-gray-50 dark:bg-gray-700 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-600'}`}>{t(m, m)}</button>
))}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-10 rounded-[3rem] shadow-2xl border-2 border-indigo-50 dark:border-indigo-900/30 flex flex-col items-center">
<div className="w-24 h-24 bg-indigo-100 dark:bg-indigo-900/40 rounded-full flex items-center justify-center text-indigo-600 mb-8 shadow-xl border-4 border-indigo-200 dark:border-indigo-800"><Moon size={48} /></div>
<h2 className="font-black text-gray-900 dark:text-white mb-6 text-xl uppercase tracking-tighter">{t("Recovery Sleep", "Recovery Sleep")}</h2>
<div className="flex items-center gap-8">
<button onClick={() => updateLog({ ...log, sleepHours: Math.max(0, log.sleepHours - 1) })} className="w-14 h-14 rounded-2xl bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 flex items-center justify-center active:scale-90 transition-all shadow-sm" aria-label="Decrease sleep"><Minus size={28}/></button>
<div className="text-center">
<span className="text-7xl font-black text-indigo-600 dark:text-indigo-400 drop-shadow-sm">{log.sleepHours}</span>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-[0.2em] mt-2">Hours / 8</p>
</div>
<button onClick={() => updateLog({ ...log, sleepHours: log.sleepHours + 1 })} className="w-14 h-14 rounded-2xl bg-indigo-600 text-white flex items-center justify-center shadow-2xl active:scale-90 transition-all" aria-label="Increase sleep"><Plus size={28}/></button>
</div>
</div>
</div>
</div>
) : (
<div className="text-center py-32 opacity-40">
<BatteryMedium size={48} className="mx-auto mb-4" />
<p className="font-bold">Daily log failed to initialize.</p>
</div>
)}
</div>
)}
{/* YOGA & EXERCISE VIEW */}
{activeTab === 'yog' && (
<div className="animate-in fade-in slide-up duration-700 space-y-12">
<div className="bg-gradient-to-r from-indigo-700 via-indigo-600 to-blue-800 rounded-[3.5rem] p-12 text-white shadow-2xl relative overflow-hidden group">
<div className="relative z-10 max-w-2xl space-y-6">
<h2 className="text-5xl font-black italic mb-2 uppercase tracking-tighter">{t("Yog Flow & Vitality", "Yog Flow & Vitality")}</h2>
<p className="text-indigo-100 text-xl font-medium leading-relaxed opacity-90">
{t("Connect with your inner self through traditional Nepali asanas. Balance the elements within.", "Connect with your inner self through traditional Nepali asanas. Balance the elements within.")}
</p>
<button onClick={handleStartFlow} className="px-10 py-5 bg-white text-indigo-900 rounded-[1.5rem] font-black text-lg shadow-2xl transform hover:scale-105 active:scale-95 transition-all flex items-center gap-4 group">
<Play size={24} className="fill-indigo-900 group-hover:scale-110 transition-transform"/> {t("Start Flow", "Start Flow")}
</button>
</div>
<Waves className="absolute -right-20 -bottom-20 text-white/5 w-[500px] h-[500px] rotate-12 group-hover:rotate-45 transition-transform duration-1000" />
</div>
{isSelectingPose && (
<div className="text-center animate-in fade-in slide-in-from-top-4 mb-4">
<p className="text-indigo-600 dark:text-indigo-400 font-black uppercase tracking-widest text-lg">Select a pose to begin session</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{YOGA_POSES.map(pose => (
<div
key={pose.id}
onClick={() => isSelectingPose ? handleSelectPose(pose) : null}
className={`bg-white dark:bg-gray-800 p-10 rounded-[3rem] shadow-sm border-2 border-gray-50 dark:border-gray-700 hover:shadow-2xl transition-all group cursor-pointer hover:-translate-y-2 ${isSelectingPose ? 'ring-4 ring-indigo-500/30 scale-105' : ''}`}
>
<div className="w-20 h-20 bg-indigo-50 dark:bg-indigo-900/30 rounded-[2rem] flex items-center justify-center text-indigo-600 mb-8 group-hover:scale-110 group-hover:rotate-12 transition-all shadow-xl">
<pose.icon size={44} />
</div>
<h3 className="text-3xl font-black text-gray-900 dark:text-white tracking-tighter mb-6">{language === 'en' ? pose.name : pose.neName}</h3>
<div className="space-y-6">
<div className="bg-gray-50 dark:bg-gray-900/50 p-4 rounded-2xl border-l-4 border-indigo-500">
<p className="text-[10px] uppercase font-black text-indigo-500 tracking-[0.3em] mb-2">{t("Core Benefit", "Core Benefit")}</p>
<p className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-snug italic">{language === 'en' ? pose.benefits : pose.neBenefits}</p>
</div>
<div className="px-4">
<p className="text-[10px] uppercase font-black text-gray-400 tracking-[0.3em] mb-2">{t("Movement Guide", "Movement Guide")}</p>
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed font-medium">{language === 'en' ? pose.steps : pose.neSteps}</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* ANCIENT WISDOM & LONGEVITY VIEW */}
{activeTab === 'ancient' && (
<div className="animate-in fade-in slide-in-from-left-8 duration-700 space-y-12">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div className="space-y-8">
<h2 className="text-3xl font-black text-amber-700 dark:text-amber-50 flex items-center gap-4 uppercase tracking-tighter italic">
<div className="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-2xl"><Sprout size={32}/></div> {t("Ayurvedic Rituals", "Ayurvedic Rituals")}
</h2>
<div className="space-y-6">
{AYURVEDA_TIPS.map((item, idx) => (
<div key={idx} className="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border-2 border-amber-50 dark:border-gray-700 flex gap-8 items-start hover:shadow-xl hover:border-amber-200 transition-all group">
<div className="p-5 bg-amber-50 dark:bg-amber-900/20 rounded-[1.5rem] shadow-xl text-amber-600 shrink-0 group-hover:scale-110 transition-transform">
<item.icon size={32}/>
</div>
<div>
<h3 className="font-black text-amber-950 dark:text-amber-100 text-2xl uppercase tracking-tighter mb-2">{language === 'en' ? item.title : item.neTitle}</h3>
<p className="text-lg text-amber-800/80 dark:text-gray-400 leading-relaxed font-medium italic">
"{language === 'en' ? item.tip : item.neTip}"
</p>
</div>
</div>
))}
</div>
</div>
<div className="space-y-8">
<h2 className="text-3xl font-black text-emerald-700 dark:text-emerald-50 flex items-center gap-4 uppercase tracking-tighter italic">
<div className="p-3 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl"><ShieldCheck size={32}/></div> {t("Longevity Secrets", "Longevity Secrets")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{LONGEVITY_PILLARS.map((p, idx) => (
<div key={idx} className="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border-2 border-emerald-50 dark:border-gray-700 flex flex-col items-center text-center group hover:shadow-xl transition-all">
<div className="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/40 rounded-3xl flex items-center justify-center text-emerald-600 mb-6 group-hover:rotate-12 transition-transform shadow-lg">
<p.icon size={32}/>
</div>
<h3 className="text-xl font-black text-gray-900 dark:text-white mb-3 uppercase tracking-tighter">{language === 'en' ? p.en : p.ne}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed font-medium">{language === 'en' ? p.desc : p.neDesc}</p>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
<style>{`
@keyframes slideDown {
0% { transform: scaleY(0); opacity: 0.8; top: 1rem; }
50% { opacity: 0.6; }
100% { transform: scaleY(1); opacity: 0; top: 11rem; }
}
`}</style>
</div>
);
};
export default Health;

435
pages/HeritageMap.tsx Normal file
View File

@ -0,0 +1,435 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../components/ui/Button';
import { Map as MapIcon, Loader2, Navigation, Compass, Search, List, X, MapPin, History, BookOpen, Star, MessageSquare, ArrowLeft, LocateFixed, Mountain, Landmark, Droplets, User, Info, Layout, Layers, Bot, Route, ChevronUp, ChevronDown, Satellite } from 'lucide-react';
import { HeritageService } from '../services/heritageService';
import { StorageService } from '../services/storageService';
import { HeritageSite, Review, ProvinceData } from '../types';
import { PROVINCES } from '../data/staticData';
import { useLanguage } from '../contexts/LanguageContext';
declare const L: any; // Leaflet global
// Satellite Map Tile URL (Esri World Imagery)
const SATELLITE_TILE_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
const SATELLITE_ATTRIBUTION = 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community';
const HeritageMap: React.FC = () => {
const { t, language } = useLanguage();
const [sites, setSites] = useState<HeritageSite[]>([]);
const [filteredSites, setFilteredSites] = useState<HeritageSite[]>([]);
const [filteredProvinces, setFilteredProvinces] = useState<ProvinceData[]>(PROVINCES);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('All');
// Selection State
const [selectedSite, setSelectedSite] = useState<HeritageSite | null>(null);
const [selectedProvince, setSelectedProvince] = useState<ProvinceData | null>(null);
const [loading, setLoading] = useState(true);
// Location
const [userPos, setUserPos] = useState<[number, number] | null>(null);
// Mode State
const [activeMode, setActiveMode] = useState<'heritage' | 'provinces'>('heritage');
const [isMobileListExpanded, setIsMobileListExpanded] = useState(false);
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const markerClusterGroupRef = useRef<any>(null);
const provinceLayerRef = useRef<any>(null);
const userMarkerRef = useRef<any>(null);
const tileLayerRef = useRef<any>(null);
// --- LIVE LOCATION TRACKING ---
useEffect(() => {
if (navigator.geolocation) {
const watchId = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude } = position.coords;
setUserPos([latitude, longitude]);
if (mapInstanceRef.current) {
if (userMarkerRef.current) {
userMarkerRef.current.setLatLng([latitude, longitude]);
} else {
const icon = L.divIcon({
className: 'user-location-marker',
html: `<div class="relative w-4 h-4"><div class="absolute inset-0 bg-blue-500 rounded-full animate-ping opacity-75"></div><div class="relative w-4 h-4 bg-blue-500 rounded-full border-2 border-white shadow-lg"></div></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
userMarkerRef.current = L.marker([latitude, longitude], { icon, zIndexOffset: 1000 }).addTo(mapInstanceRef.current);
}
}
},
(error) => console.warn("Location error:", error),
{ enableHighAccuracy: true, timeout: 20000, maximumAge: 1000 }
);
return () => navigator.geolocation.clearWatch(watchId);
}
}, []);
// --- RUDRA CONTROL ---
useEffect(() => {
const handleRemoteControl = (e: any) => {
const { type, targetName } = e.detail;
if (type === 'province') {
const province = PROVINCES.find(p => p.name.toLowerCase().includes(targetName.toLowerCase()));
if (province) {
handleSelectProvince(province);
// Force open info tab if handled by AI
setSelectedProvince(province);
}
} else if (type === 'site') {
const site = sites.find(s => s.name.toLowerCase().includes(targetName.toLowerCase()));
if (site) {
handleSelectSite(site);
setSelectedSite(site);
}
} else if (type === 'reset') {
mapInstanceRef.current?.flyTo([28.3949, 84.1240], 7);
setSelectedSite(null);
setSelectedProvince(null);
}
};
window.addEventListener('rudraksha-map-control', handleRemoteControl);
return () => window.removeEventListener('rudraksha-map-control', handleRemoteControl);
}, [sites]);
useEffect(() => {
const loadSites = async () => {
setLoading(true);
const data = await HeritageService.getAllSites();
setSites(data);
setFilteredSites(data);
setLoading(false);
setTimeout(() => initMap(data), 100);
};
loadSites();
}, []);
// Filtering Logic
useEffect(() => {
const query = searchQuery.toLowerCase();
if (activeMode === 'heritage') {
let result = sites;
if (query) {
result = result.filter(site => (language === 'ne' ? site.nameNe : site.name).toLowerCase().includes(query));
}
if (selectedCategory !== 'All') {
result = result.filter(site => site.category === selectedCategory);
}
setFilteredSites(result);
if (mapInstanceRef.current) updateHeritageMarkers(result);
// Hide province markers
if (provinceLayerRef.current) provinceLayerRef.current.clearLayers();
} else {
// Province Mode
let result = PROVINCES;
if (query) {
result = result.filter(prov => (language === 'ne' ? prov.nepaliName : prov.name).toLowerCase().includes(query));
}
setFilteredProvinces(result);
if (mapInstanceRef.current) updateProvinceMarkers(result);
// Hide heritage markers
if (markerClusterGroupRef.current) markerClusterGroupRef.current.clearLayers();
}
}, [searchQuery, selectedCategory, sites, activeMode, language]);
const initMap = (initialSites: HeritageSite[]) => {
if (!mapContainerRef.current || mapInstanceRef.current || typeof L === 'undefined') return;
const map = L.map(mapContainerRef.current, { zoomControl: false }).setView([28.3949, 84.1240], 7);
mapInstanceRef.current = map;
L.control.zoom({ position: 'bottomright' }).addTo(map);
// Set Satellite Tile Layer
tileLayerRef.current = L.tileLayer(SATELLITE_TILE_URL, {
maxZoom: 19,
attribution: SATELLITE_ATTRIBUTION
}).addTo(map);
// Add Layer Groups
if (L.markerClusterGroup) {
markerClusterGroupRef.current = L.markerClusterGroup({ showCoverageOnHover: false, zoomToBoundsOnClick: true, removeOutsideVisibleBounds: true });
map.addLayer(markerClusterGroupRef.current);
} else {
markerClusterGroupRef.current = L.layerGroup().addTo(map);
}
provinceLayerRef.current = L.layerGroup().addTo(map);
// Initial Load
if (activeMode === 'heritage') updateHeritageMarkers(initialSites);
else updateProvinceMarkers(PROVINCES);
};
const handleSelectSite = (site: HeritageSite) => {
setSelectedProvince(null);
setSelectedSite(site);
setActiveMode('heritage');
setIsMobileListExpanded(false);
mapInstanceRef.current?.flyTo([site.latitude, site.longitude], 16, { duration: 1.5 });
};
const handleSelectProvince = (prov: ProvinceData) => {
setSelectedSite(null);
setSelectedProvince(prov);
setActiveMode('provinces');
setIsMobileListExpanded(false);
mapInstanceRef.current?.flyTo([prov.lat, prov.lng], 9, { duration: 1.5 });
};
const updateHeritageMarkers = (sitesData: HeritageSite[]) => {
if (!markerClusterGroupRef.current) return;
markerClusterGroupRef.current.clearLayers();
const newMarkers: any[] = [];
sitesData.forEach(site => {
const icon = L.divIcon({
className: 'custom-icon',
html: `<div class="w-12 h-12 rounded-full border-2 border-white shadow-lg overflow-hidden bg-white hover:scale-110 transition-transform"><img src="${site.imageUrl}" class="w-full h-full object-cover"/></div>`,
iconSize: [48, 48],
iconAnchor: [24, 24]
});
const marker = L.marker([site.latitude, site.longitude], { icon });
marker.on('click', () => handleSelectSite(site));
newMarkers.push(marker);
});
if (markerClusterGroupRef.current.addLayers) {
markerClusterGroupRef.current.addLayers(newMarkers);
} else {
newMarkers.forEach(m => markerClusterGroupRef.current.addLayer(m));
}
};
const updateProvinceMarkers = (provincesData: ProvinceData[]) => {
if (!provinceLayerRef.current) return;
provinceLayerRef.current.clearLayers();
provincesData.forEach(prov => {
// Same style as heritage markers now
const icon = L.divIcon({
html: `<div class="relative group cursor-pointer hover:scale-110 transition-transform"><div class="w-12 h-12 rounded-full border-2 border-white shadow-xl overflow-hidden bg-gradient-to-br ${prov.color} p-0.5"><img src="${prov.image}" class="w-full h-full object-cover rounded-full" /></div><div class="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-white text-[10px] font-black px-2 py-0.5 rounded shadow-md whitespace-nowrap text-gray-900">${prov.name}</div></div>`,
className: 'bg-transparent',
iconSize: [48, 48],
iconAnchor: [24, 24]
});
const marker = L.marker([prov.lat, prov.lng], { icon });
marker.on('click', () => handleSelectProvince(prov));
marker.addTo(provinceLayerRef.current);
});
};
const categories = ['All', 'Temple', 'Stupa', 'Palace', 'Nature', 'Other'];
const localizeNumber = (value: string | number): string => {
if (language === 'en') return value.toString();
const str = value.toString();
const digits = ['', '१', '२', '३', '४', '५', '६', '७', '८', '९'];
return str.replace(/[0-9]/g, (m) => digits[parseInt(m)]);
};
return (
<div className="relative h-[calc(100dvh-85px)] md:h-[calc(100vh-85px)] w-full rounded-[2.5rem] overflow-hidden shadow-2xl border border-gray-200 dark:border-gray-800 bg-gray-900">
{/* MAP CONTAINER */}
<div ref={mapContainerRef} className="absolute inset-0 z-0" />
{/* LOADING OVERLAY */}
{loading && (
<div className="absolute inset-0 z-[500] bg-black/20 backdrop-blur-sm flex items-center justify-center">
<Loader2 className="animate-spin text-white w-12 h-12"/>
</div>
)}
{/* TOP CONTROLS (Floating Mode Toggle) */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[400] flex gap-1 p-1 bg-black/60 backdrop-blur-xl rounded-2xl shadow-xl border border-white/20 scale-90 md:scale-100 origin-top">
<button onClick={() => { setActiveMode('heritage'); setSearchQuery(''); setSelectedProvince(null); }} className={`px-5 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all ${activeMode === 'heritage' ? 'bg-red-600 text-white shadow-lg' : 'text-gray-300 hover:text-white'}`}>
<Compass size={16}/> Heritage
</button>
<button onClick={() => { setActiveMode('provinces'); setSearchQuery(''); setSelectedSite(null); }} className={`px-5 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all ${activeMode === 'provinces' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-300 hover:text-white'}`}>
<Layout size={16}/> Provinces
</button>
</div>
{/* SEARCH & LIST PANEL (Responsive: Sidebar on Desktop, Bottom Sheet on Mobile) */}
<div className={`
z-[400] flex flex-col gap-3 pointer-events-none transition-all duration-500 ease-out
fixed bottom-0 left-0 right-0 p-4 pb-20 md:pb-4
md:absolute md:top-6 md:left-6 md:bottom-6 md:w-80 md:p-0
${isMobileListExpanded ? 'h-[60dvh]' : 'h-auto'} md:h-auto
`}>
{/* Search Box */}
<div className="bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl p-4 rounded-[2rem] shadow-2xl border border-white/20 pointer-events-auto space-y-3 shrink-0">
{/* Mobile Toggle Handle */}
<div className="w-12 h-1 bg-gray-300 dark:bg-gray-700 rounded-full mx-auto md:hidden" onClick={() => setIsMobileListExpanded(!isMobileListExpanded)}></div>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={18}/>
<input
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setIsMobileListExpanded(true); }}
onFocus={() => setIsMobileListExpanded(true)}
placeholder={activeMode === 'heritage' ? t("Search heritage...", "Search heritage...") : t("Search provinces...", "Search provinces...")}
className="w-full pl-10 pr-4 py-3 bg-gray-100 dark:bg-gray-800 rounded-xl text-sm font-bold outline-none focus:ring-2 ring-red-500/50 dark:text-white transition-all"
/>
</div>
{activeMode === 'heritage' && (
<div className="flex gap-2 overflow-x-auto scrollbar-none pb-1">
{categories.map(cat => (
<button key={cat} onClick={() => setSelectedCategory(cat)} className={`px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider whitespace-nowrap transition-all border ${selectedCategory === cat ? 'bg-red-500 text-white border-red-500' : 'bg-gray-50 dark:bg-gray-800 text-gray-500 border-gray-200 dark:border-gray-700'}`}>
{cat}
</button>
))}
</div>
)}
</div>
{/* Results List */}
<div className={`flex-1 overflow-hidden pointer-events-none transition-opacity duration-300 ${isMobileListExpanded ? 'opacity-100' : 'opacity-0 md:opacity-100'}`}>
<div className="h-full bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl rounded-[2rem] shadow-2xl border border-white/20 pointer-events-auto overflow-y-auto custom-scrollbar p-3 space-y-2">
{activeMode === 'heritage' ? (
filteredSites.map(site => (
<div key={site.id} onClick={() => handleSelectSite(site)} className={`flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all border-2 ${selectedSite?.id === site.id ? 'bg-red-50 dark:bg-red-900/20 border-red-500' : 'bg-transparent border-transparent hover:bg-gray-100 dark:hover:bg-gray-800'}`}>
<img src={site.imageUrl} className="w-12 h-12 rounded-lg object-cover bg-gray-200" alt=""/>
<div className="min-w-0">
<h4 className="text-xs font-black uppercase text-gray-900 dark:text-white truncate">{language === 'ne' ? (site.nameNe || site.name) : site.name}</h4>
<p className="text-[10px] text-gray-500 truncate">{site.category} {site.region}</p>
</div>
</div>
))
) : (
filteredProvinces.map(prov => (
<div key={prov.id} onClick={() => handleSelectProvince(prov)} className={`flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all border-2 ${selectedProvince?.id === prov.id ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500' : 'bg-transparent border-transparent hover:bg-gray-100 dark:hover:bg-gray-800'}`}>
<div className={`w-12 h-12 rounded-lg bg-gradient-to-br ${prov.color} p-0.5`}><img src={prov.image} className="w-full h-full object-cover rounded-md"/></div>
<div className="min-w-0">
<h4 className="text-xs font-black uppercase text-gray-900 dark:text-white truncate">{language === 'ne' ? prov.nepaliName : prov.name}</h4>
<p className="text-[10px] text-gray-500 truncate">{localizeNumber(prov.districts)} Districts</p>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* DETAILS PANEL (Unified for both Modes) */}
{(selectedSite || selectedProvince) && (
<div className={`
fixed inset-0 z-[500] md:absolute md:inset-auto md:top-6 md:right-6 md:bottom-6 md:w-[400px] md:z-[401]
pointer-events-none flex flex-col items-end
`}>
<div className="h-full w-full bg-white/95 dark:bg-gray-950/95 backdrop-blur-2xl md:rounded-[2.5rem] shadow-[0_20px_60px_rgba(0,0,0,0.5)] border-0 md:border border-white/20 pointer-events-auto overflow-hidden flex flex-col animate-in slide-in-from-bottom-10 md:slide-in-from-right-20 duration-300">
{/* Panel Header Image */}
<div className="relative h-48 md:h-48 shrink-0">
<img
src={selectedSite?.imageUrl || selectedProvince?.image}
className="w-full h-full object-cover"
alt="Header"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
<button onClick={() => { setSelectedSite(null); setSelectedProvince(null); }} className="absolute top-4 right-4 p-2 bg-black/40 text-white rounded-full hover:bg-red-600 transition-colors backdrop-blur-md z-10"><X size={20}/></button>
<div className="absolute bottom-4 left-6 right-6">
<span className={`inline-block px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest text-white mb-2 shadow-lg ${selectedSite ? 'bg-red-600' : 'bg-blue-600'}`}>
{selectedSite ? selectedSite.category : `Province ${localizeNumber(selectedProvince!.id)}`}
</span>
<h2 className="text-2xl font-black text-white leading-none italic uppercase tracking-tighter drop-shadow-xl">
{selectedSite ? (language === 'ne' ? selectedSite.nameNe : selectedSite.name) : (language === 'ne' ? selectedProvince?.nepaliName : selectedProvince?.name)}
</h2>
</div>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto custom-scrollbar bg-gray-50/50 dark:bg-black/20 flex flex-col">
<div className="p-6 space-y-6">
{/* Province Specific Stats Grid */}
{selectedProvince ? (
<>
<div className="grid grid-cols-2 gap-3">
<div className="bg-white dark:bg-gray-900 p-3 rounded-2xl border border-gray-100 dark:border-gray-800 shadow-sm">
<p className="text-[10px] text-gray-400 font-bold uppercase">Capital</p>
<p className="font-black text-sm dark:text-white">{language === 'ne' ? selectedProvince.capitalNe : selectedProvince.capital}</p>
</div>
<div className="bg-white dark:bg-gray-900 p-3 rounded-2xl border border-gray-100 dark:border-gray-800 shadow-sm">
<p className="text-[10px] text-gray-400 font-bold uppercase">Area</p>
<p className="font-black text-sm dark:text-white">{localizeNumber(selectedProvince.area)}</p>
</div>
</div>
{/* Capital Satellite Uplink (Static Visual Placeholder) */}
<div className="bg-black rounded-2xl overflow-hidden shadow-lg border-2 border-gray-800 relative group">
<div className="absolute top-2 left-2 z-10 bg-black/60 backdrop-blur-sm px-2 py-1 rounded text-[9px] font-black text-green-400 uppercase tracking-widest flex items-center gap-1 border border-green-500/30">
<Satellite size={10} className="animate-pulse"/> Capital Uplink
</div>
<div className="h-32 bg-gray-800 relative">
{/* Using a generic high-res satellite looking image for visual effect since real-time requires key */}
<img
src="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/12/1744/3028"
className="w-full h-full object-cover opacity-80 group-hover:scale-110 transition-transform duration-1000"
alt="Satellite View"
/>
<div className="absolute inset-0 bg-scanline pointer-events-none"></div>
</div>
</div>
</>
) : (
<div className="flex items-center gap-2 bg-white dark:bg-gray-900 p-3 rounded-2xl border border-gray-100 dark:border-gray-800 shadow-sm">
<MapPin size={16} className="text-red-500"/>
<p className="font-black text-sm dark:text-white">{selectedSite?.region}</p>
</div>
)}
<div className="bg-white dark:bg-gray-900 p-5 rounded-[2rem] border border-gray-100 dark:border-gray-800 shadow-sm">
<h3 className="text-xs font-black uppercase text-gray-400 mb-2 tracking-widest flex items-center gap-2"><Info size={14}/> About</h3>
<p className="text-sm font-medium text-gray-600 dark:text-gray-300 leading-relaxed">
{selectedSite ? (language === 'ne' ? selectedSite.descriptionNe : selectedSite.description) : (language === 'ne' ? selectedProvince?.descriptionNe : selectedProvince?.description)}
</p>
</div>
{selectedSite ? (
<Button
onClick={() => window.open(`https://www.google.com/maps/dir/?api=1&destination=${selectedSite.latitude},${selectedSite.longitude}`, '_blank')}
className="w-full h-12 bg-red-600 hover:bg-red-700 text-white rounded-xl font-black uppercase tracking-widest text-xs shadow-xl shadow-red-600/20"
>
<Navigation size={16} className="mr-2"/> Get Directions
</Button>
) : (
<div className="space-y-4">
<div className="bg-white dark:bg-gray-900 p-5 rounded-[2rem] border border-gray-100 dark:border-gray-800 shadow-sm">
<h3 className="text-xs font-black uppercase text-gray-400 mb-3 tracking-widest flex items-center gap-2"><Mountain size={14} className="text-green-500"/> Attractions</h3>
<ul className="space-y-2">
{(language === 'en' ? selectedProvince!.attractions : selectedProvince!.attractionsNe).map((attr, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm font-bold text-gray-700 dark:text-gray-300">
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div> {attr}
</li>
))}
</ul>
</div>
<div className="bg-white dark:bg-gray-900 p-5 rounded-[2rem] border border-gray-100 dark:border-gray-800 shadow-sm">
<h3 className="text-xs font-black uppercase text-gray-400 mb-2 tracking-widest flex items-center gap-2"><User size={14} className="text-purple-500"/> Languages</h3>
<p className="text-sm font-bold text-gray-700 dark:text-gray-300">{language === 'en' ? selectedProvince!.mainLanguages : selectedProvince!.mainLanguagesNe}</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Locate Button */}
<button onClick={() => { if(userPos && mapInstanceRef.current) mapInstanceRef.current.flyTo(userPos, 14); }} className="absolute bottom-20 md:bottom-6 right-6 md:right-[440px] z-[390] p-3 bg-black/60 backdrop-blur-md rounded-full shadow-xl hover:scale-110 transition-transform text-white border border-white/20">
<LocateFixed size={24}/>
</button>
<style>{`
.bg-scanline {
background: linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,0) 50%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.2));
background-size: 100% 4px;
}
`}</style>
</div>
);
};
export default HeritageMap;

178
pages/Leaderboard.tsx Normal file
View File

@ -0,0 +1,178 @@
import React, { useEffect, useState } from 'react';
import { StorageService } from '../services/storageService';
import { UserProfile } from '../types';
import { Trophy, Medal, User, GraduationCap, Briefcase, BookOpen, Crown, Loader2 } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
const Leaderboard: React.FC = () => {
const { t } = useLanguage();
const [leaders, setLeaders] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(true);
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
const [allUsers, profile] = await Promise.all([
StorageService.getLeaderboard(50),
StorageService.getProfile()
]);
setLeaders(allUsers);
setCurrentUser(profile);
setLoading(false);
};
fetchData();
}, []);
const getRankStyle = (index: number) => {
switch(index) {
case 0: return "bg-yellow-100 border-yellow-300 text-yellow-800 dark:bg-yellow-900/30 dark:border-yellow-700 dark:text-yellow-400"; // Gold
case 1: return "bg-gray-100 border-gray-300 text-gray-800 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300"; // Silver
case 2: return "bg-orange-100 border-orange-300 text-orange-800 dark:bg-orange-900/30 dark:border-orange-700 dark:text-orange-400"; // Bronze
default: return "bg-white dark:bg-gray-800 border-gray-100 dark:border-gray-700 text-gray-700 dark:text-gray-200";
}
};
const getRoleIcon = (role: string) => {
switch(role) {
case 'student': return <GraduationCap size={14} />;
case 'teacher': return <BookOpen size={14} />;
default: return <Briefcase size={14} />;
}
};
if (loading) return (
<div className="flex justify-center items-center h-[60vh]">
<Loader2 className="animate-spin text-yellow-500 w-12 h-12" />
</div>
);
return (
<div className="space-y-8 pb-20">
<header className="text-center md:text-left">
<h1 className="text-3xl font-black text-gray-900 dark:text-white flex items-center justify-center md:justify-start gap-3">
<Trophy className="text-yellow-500 fill-yellow-400" size={32} /> {t("Leaderboard", "Leaderboard")}
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-2">{t("Top achievers in the Rudraksha community.", "Top achievers in the Rudraksha community.")}</p>
</header>
{/* Podium Section (Top 3) */}
{leaders.length >= 3 && (
<div className="flex flex-wrap justify-center items-end gap-4 md:gap-8 mb-12 pt-8">
{/* 2nd Place */}
<div className="flex flex-col items-center animate-in slide-in-from-bottom-8 duration-700 delay-100 order-1 md:order-none">
<div className="relative">
<img
src={leaders[1].avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${leaders[1].name}`}
alt={leaders[1].name}
className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-gray-300 shadow-xl object-cover"
/>
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-gray-300 text-gray-800 font-bold px-3 py-1 rounded-full text-sm shadow-md border-2 border-white">2nd</div>
</div>
<div className="mt-4 text-center">
<h3 className="font-bold text-gray-900 dark:text-white text-lg truncate w-32">{leaders[1].name}</h3>
<p className="text-gray-500 text-xs font-medium uppercase tracking-wider">{leaders[1].points} pts</p>
</div>
<div className="h-24 w-24 md:w-32 bg-gradient-to-t from-gray-200 to-gray-100 dark:from-gray-800 dark:to-gray-700 rounded-t-lg mt-2 opacity-50"></div>
</div>
{/* 1st Place */}
<div className="flex flex-col items-center animate-in slide-in-from-bottom-8 duration-700 -mt-8 order-2 md:order-none z-10">
<div className="relative">
<Crown className="absolute -top-8 left-1/2 -translate-x-1/2 text-yellow-500 fill-yellow-400 animate-bounce" size={32} />
<img
src={leaders[0].avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${leaders[0].name}`}
alt={leaders[0].name}
className="w-24 h-24 md:w-32 md:h-32 rounded-full border-4 border-yellow-400 shadow-2xl object-cover ring-4 ring-yellow-400/30"
/>
<div className="absolute -bottom-4 left-1/2 -translate-x-1/2 bg-yellow-400 text-yellow-900 font-black px-4 py-1 rounded-full text-base shadow-lg border-2 border-white">1st</div>
</div>
<div className="mt-5 text-center">
<h3 className="font-bold text-gray-900 dark:text-white text-xl truncate w-40">{leaders[0].name}</h3>
<p className="text-yellow-600 dark:text-yellow-400 font-bold uppercase tracking-wider">{leaders[0].points} pts</p>
</div>
<div className="h-32 w-28 md:w-40 bg-gradient-to-t from-yellow-100 to-yellow-50 dark:from-yellow-900/30 dark:to-yellow-800/10 rounded-t-lg mt-2 opacity-60"></div>
</div>
{/* 3rd Place */}
<div className="flex flex-col items-center animate-in slide-in-from-bottom-8 duration-700 delay-200 order-3 md:order-none">
<div className="relative">
<img
src={leaders[2].avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${leaders[2].name}`}
alt={leaders[2].name}
className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-orange-300 shadow-xl object-cover"
/>
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-orange-300 text-orange-900 font-bold px-3 py-1 rounded-full text-sm shadow-md border-2 border-white">3rd</div>
</div>
<div className="mt-4 text-center">
<h3 className="font-bold text-gray-900 dark:text-white text-lg truncate w-32">{leaders[2].name}</h3>
<p className="text-gray-500 text-xs font-medium uppercase tracking-wider">{leaders[2].points} pts</p>
</div>
<div className="h-16 w-24 md:w-32 bg-gradient-to-t from-orange-100 to-orange-50 dark:from-orange-900/30 dark:to-orange-800/10 rounded-t-lg mt-2 opacity-50"></div>
</div>
</div>
)}
{/* List Section */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 dark:bg-gray-900/50 text-gray-500 dark:text-gray-400 text-xs uppercase font-bold tracking-wider">
<tr>
<th className="p-4 w-16 text-center">Rank</th>
<th className="p-4">User</th>
<th className="p-4 hidden sm:table-cell">Role</th>
<th className="p-4 text-right">Karma Points</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{leaders.map((user, index) => {
const isMe = currentUser?.id === user.id;
return (
<tr
key={user.id}
className={`transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50 ${isMe ? 'bg-blue-50 dark:bg-blue-900/20' : ''} ${index < 3 ? 'font-medium' : ''}`}
>
<td className="p-4 text-center">
<span className={`inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold ${index < 3 ? getRankStyle(index) : 'text-gray-500'}`}>
{index + 1}
</span>
</td>
<td className="p-4">
<div className="flex items-center gap-3">
<img
src={user.avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`}
className="w-10 h-10 rounded-full bg-gray-200 object-cover border border-gray-100 dark:border-gray-600"
alt={user.name}
/>
<div>
<p className={`font-bold text-sm ${isMe ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-white'}`}>
{user.name} {isMe && "(You)"}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 sm:hidden capitalize">
{user.role}
</p>
</div>
</div>
</td>
<td className="p-4 hidden sm:table-cell">
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 capitalize border border-gray-200 dark:border-gray-600">
{getRoleIcon(user.role)} {user.role}
</span>
</td>
<td className="p-4 text-right">
<span className="font-mono font-bold text-gray-900 dark:text-white">{user.points.toLocaleString()}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Leaderboard;

150
pages/Library.tsx Normal file
View File

@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { Library as LibraryIcon, Lock, FileText, CheckCircle2, Bell, ShieldAlert, BookOpen, GraduationCap, Building2 } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { Button } from '../components/ui/Button';
const Library: React.FC = () => {
const { t } = useLanguage();
const [notified, setNotified] = useState(false);
const handleNotify = () => {
setNotified(true);
};
const timeline = [
{ title: "System Architecture", status: "completed", date: "Ready" },
{ title: "CDC Content Request", status: "current", date: "Pending Submission" },
{ title: "Government Approval", status: "waiting", date: "TBD" },
{ title: "Public Deployment", status: "waiting", date: "Q2 2025" }
];
return (
<div className="min-h-screen pb-20 animate-in fade-in duration-500">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-12">
<div>
<h1 className="text-4xl font-black text-gray-900 dark:text-white flex items-center gap-3 italic tracking-tighter uppercase">
<div className="p-3 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl shadow-sm">
<LibraryIcon className="text-emerald-600" size={32} />
</div>
{t("Digital Library", "Digital Library")}
</h1>
<p className="text-gray-500 dark:text-gray-400 font-medium text-lg mt-2 ml-1">
{t("National Curriculum & Resources", "National Curriculum & Resources")}
</p>
</div>
<div className="px-5 py-2 bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-full text-xs font-black text-yellow-700 dark:text-yellow-500 uppercase tracking-widest flex items-center gap-2">
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
Pending Approval
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Left Column: Status Card */}
<div className="bg-white dark:bg-gray-800 rounded-[3rem] p-10 border-2 border-gray-100 dark:border-gray-700 shadow-2xl relative overflow-hidden flex flex-col justify-between">
<div className="absolute top-0 right-0 w-64 h-64 bg-emerald-500/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 pointer-events-none"></div>
<div className="relative z-10">
<div className="w-24 h-24 bg-gray-100 dark:bg-gray-700 rounded-[2rem] flex items-center justify-center mb-8 shadow-inner">
<Lock size={40} className="text-gray-400" />
</div>
<h2 className="text-4xl font-black text-gray-900 dark:text-white uppercase italic tracking-tighter mb-4">
Content Locked
</h2>
<div className="bg-yellow-50 dark:bg-yellow-900/10 border-l-4 border-yellow-500 p-6 rounded-r-2xl mb-8">
<h3 className="text-sm font-black text-yellow-700 dark:text-yellow-500 uppercase tracking-widest mb-2 flex items-center gap-2">
<Building2 size={16}/> CDC Compliance
</h3>
<p className="text-gray-700 dark:text-gray-300 font-medium leading-relaxed">
To ensure educational integrity, we are waiting for official API access from the <span className="font-bold">Curriculum Development Centre (CDC)</span>. This ensures all textbooks and materials are up-to-date with the National Syllabus.
</p>
</div>
<div className="space-y-6">
{timeline.map((step, i) => (
<div key={i} className="flex items-center gap-4">
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 shrink-0 ${step.status === 'completed' ? 'bg-emerald-500 border-emerald-500 text-white' : step.status === 'current' ? 'bg-white dark:bg-gray-800 border-emerald-500 text-emerald-500' : 'border-gray-200 dark:border-gray-700 text-gray-300'}`}>
{step.status === 'completed' ? <CheckCircle2 size={16}/> : <div className={`w-2.5 h-2.5 rounded-full ${step.status === 'current' ? 'bg-emerald-500' : 'bg-gray-300'}`}></div>}
</div>
<div>
<p className={`text-sm font-bold uppercase tracking-wide ${step.status === 'waiting' ? 'text-gray-400' : 'text-gray-900 dark:text-white'}`}>{step.title}</p>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{step.date}</p>
</div>
</div>
))}
</div>
</div>
<div className="mt-12">
{notified ? (
<div className="w-full h-16 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 rounded-2xl flex items-center justify-center font-black uppercase tracking-widest border border-emerald-100 dark:border-emerald-800">
<CheckCircle2 size={20} className="mr-2"/> You're on the list
</div>
) : (
<Button onClick={handleNotify} className="w-full h-16 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-2xl font-black uppercase tracking-widest hover:scale-[1.02] transition-transform shadow-xl">
<Bell size={20} className="mr-2"/> Notify on Launch
</Button>
)}
</div>
</div>
{/* Right Column: Preview Placeholder */}
<div className="space-y-6">
<div className="bg-gradient-to-br from-indigo-600 to-blue-700 rounded-[3rem] p-10 text-white shadow-2xl relative overflow-hidden group">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10"></div>
<BookOpen size={180} className="absolute -bottom-10 -right-10 text-white opacity-10 rotate-12" />
<div className="relative z-10">
<div className="inline-flex items-center gap-2 bg-white/20 backdrop-blur-md px-3 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest mb-6 border border-white/20">
<GraduationCap size={12}/> Planned Features
</div>
<h2 className="text-3xl font-black italic uppercase tracking-tighter mb-4">The Future of Learning</h2>
<p className="text-indigo-100 font-medium leading-relaxed mb-8">
Once approved, Rudraksha will host the complete repository of Grade 1-12 textbooks, interactive video lessons, and AI-powered doubt resolution tailored to the Nepali curriculum.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/10 p-4 rounded-2xl border border-white/10 backdrop-blur-sm">
<h4 className="font-bold text-lg">500+</h4>
<p className="text-[10px] uppercase font-black opacity-70">Textbooks</p>
</div>
<div className="bg-white/10 p-4 rounded-2xl border border-white/10 backdrop-blur-sm">
<h4 className="font-bold text-lg">AI</h4>
<p className="text-[10px] uppercase font-black opacity-70">Tutor Support</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-900 rounded-[2.5rem] p-8 border border-gray-100 dark:border-gray-800 flex items-center gap-6 opacity-60 grayscale cursor-not-allowed">
<div className="w-16 h-16 bg-white dark:bg-gray-800 rounded-2xl flex items-center justify-center shadow-sm">
<FileText size={32} className="text-gray-400"/>
</div>
<div>
<h3 className="font-black text-gray-900 dark:text-white uppercase">Model Question Sets</h3>
<p className="text-xs font-bold text-gray-500 uppercase tracking-wider">Locked</p>
</div>
<Lock size={20} className="ml-auto text-gray-400"/>
</div>
<div className="bg-gray-50 dark:bg-gray-900 rounded-[2.5rem] p-8 border border-gray-100 dark:border-gray-800 flex items-center gap-6 opacity-60 grayscale cursor-not-allowed">
<div className="w-16 h-16 bg-white dark:bg-gray-800 rounded-2xl flex items-center justify-center shadow-sm">
<ShieldAlert size={32} className="text-gray-400"/>
</div>
<div>
<h3 className="font-black text-gray-900 dark:text-white uppercase">Exam Alerts</h3>
<p className="text-xs font-bold text-gray-500 uppercase tracking-wider">Locked</p>
</div>
<Lock size={20} className="ml-auto text-gray-400"/>
</div>
</div>
</div>
</div>
);
};
export default Library;

493
pages/Planner.tsx Normal file
View File

@ -0,0 +1,493 @@
import React, { useEffect, useState } from 'react';
import { StorageService } from '../services/storageService';
import { Task, TaskStatus, Priority, Subtask, UserProfile, StudyNote } from '../types';
import { Button } from '../components/ui/Button';
import { Plus, Trash2, Calendar, Tag, CheckCircle, Circle, CheckSquare, Loader2, ChevronDown, ChevronRight, X, Clock, StickyNote, PenTool, Layout as LayoutIcon, GraduationCap, Users } from 'lucide-react';
import confetti from 'canvas-confetti';
import { useLanguage } from '../contexts/LanguageContext';
const CLASSES = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', 'General'];
const Planner: React.FC = () => {
const { t } = useLanguage();
const [activeTab, setActiveTab] = useState<'assignments' | 'vault'>('assignments');
const [tasks, setTasks] = useState<Task[]>([]);
const [notes, setNotes] = useState<StudyNote[]>([]);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [newSubtaskInput, setNewSubtaskInput] = useState('');
// Assignment State
const [newTask, setNewTask] = useState<Partial<Task>>({
title: '',
subject: 'General',
priority: Priority.MEDIUM,
dueDate: new Date().toISOString().split('T')[0],
description: '',
subtasks: [],
estimatedMinutes: 45,
targetClass: '10'
});
const [newNote, setNewNote] = useState<Partial<StudyNote>>({
title: '', content: '', color: 'bg-yellow-100', textColor: 'text-gray-900', fontFamily: 'sans'
});
const loadData = async () => {
setLoading(true);
const [t_list, p, n] = await Promise.all([
StorageService.getTasks(),
StorageService.getProfile(),
StorageService.getNotes()
]);
setTasks(t_list);
setProfile(p);
setNotes(n);
setLoading(false);
};
useEffect(() => {
loadData();
}, []);
const handleSaveTask = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTask.title) return;
const isTeacher = profile?.role === 'teacher';
const taskToSave: Task = {
id: Date.now().toString(),
userId: profile?.id || 'anon',
title: newTask.title,
subject: newTask.subject || 'General',
priority: newTask.priority || Priority.MEDIUM,
status: TaskStatus.TODO,
dueDate: newTask.dueDate || new Date().toISOString(),
description: newTask.description,
subtasks: newTask.subtasks || [],
estimatedMinutes: newTask.estimatedMinutes || 45,
isAssignment: isTeacher,
targetClass: isTeacher ? newTask.targetClass : profile?.grade
};
await StorageService.saveTask(taskToSave);
await loadData();
setIsTaskModalOpen(false);
setNewTask({ title: '', subject: 'General', priority: Priority.MEDIUM, dueDate: new Date().toISOString().split('T')[0], description: '', subtasks: [], estimatedMinutes: 45, targetClass: '10' });
if (isTeacher) confetti({ particleCount: 40, spread: 60 });
};
const handleSaveNote = async (e: React.FormEvent) => {
e.preventDefault();
if (!newNote.title || !newNote.content) return;
await StorageService.saveNote(newNote);
await loadData();
setIsNoteModalOpen(false);
setNewNote({ title: '', content: '', color: 'bg-yellow-100', textColor: 'text-gray-900', fontFamily: 'sans' });
};
const deleteNote = async (id: string) => {
if (confirm('Delete this note?')) {
await StorageService.deleteNote(id);
await loadData();
}
};
const toggleStatus = async (task: Task) => {
let updatedStatus: TaskStatus;
if (task.status === TaskStatus.COMPLETED) {
updatedStatus = TaskStatus.TODO;
} else if (task.status === TaskStatus.SUBMITTED) {
updatedStatus = TaskStatus.TODO;
} else {
if (profile?.role === 'teacher') {
updatedStatus = TaskStatus.COMPLETED;
confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
StorageService.addPoints(10, 50);
} else {
updatedStatus = TaskStatus.SUBMITTED;
}
}
const updatedTasks = tasks.map(t => t.id === task.id ? { ...t, status: updatedStatus } : t);
setTasks(updatedTasks);
await StorageService.saveTask({ ...task, status: updatedStatus });
};
const deleteTask = async (id: string) => {
if (confirm('Permanently remove this task?')) {
setTasks(prev => prev.filter(t => t.id !== id));
await StorageService.deleteTask(id);
}
};
const handleToggleExpand = (taskId: string) => {
setExpandedTaskId(expandedTaskId === taskId ? null : taskId);
setNewSubtaskInput('');
};
const addSubtask = async (taskId: string) => {
if (!newSubtaskInput.trim()) return;
const task = tasks.find(t => t.id === taskId);
if (!task) return;
const newS: Subtask = { id: Date.now().toString(), title: newSubtaskInput, completed: false };
const updatedTask = { ...task, subtasks: [...(task.subtasks || []), newS] };
setTasks(prev => prev.map(t => t.id === taskId ? updatedTask : t));
setNewSubtaskInput('');
await StorageService.saveTask(updatedTask);
};
const toggleSubtask = async (taskId: string, subtaskId: string) => {
const task = tasks.find(t => t.id === taskId);
if (!task) return;
const updatedS = (task.subtasks || []).map(s => s.id === subtaskId ? { ...s, completed: !s.completed } : s);
const updatedTask = { ...task, subtasks: updatedS };
setTasks(prev => prev.map(t => t.id === taskId ? updatedTask : t));
await StorageService.saveTask(updatedTask);
};
const getGroupedTasks = () => {
const today = new Date().toISOString().split('T')[0];
const overdue: Task[] = [];
const dueToday: Task[] = [];
const upcoming: Task[] = [];
const completed: Task[] = [];
const submitted: Task[] = [];
tasks.forEach(t => {
if (t.status === TaskStatus.COMPLETED) completed.push(t);
else if (t.status === TaskStatus.SUBMITTED) submitted.push(t);
else {
const tDate = t.dueDate.split('T')[0];
if (tDate < today) overdue.push(t);
else if (tDate === today) dueToday.push(t);
else upcoming.push(t);
}
});
return { overdue, dueToday, upcoming, completed, submitted };
};
const { overdue, dueToday, upcoming, completed, submitted } = getGroupedTasks();
const isTeacher = profile?.role === 'teacher';
return (
<div className="space-y-6 pb-20">
<div className="flex flex-col gap-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-4xl font-black text-gray-900 dark:text-white flex items-center gap-3 italic tracking-tighter uppercase">
<CheckSquare className="text-red-600"/> {activeTab === 'assignments' ? t("Assignments", "Assignments") : t("Personal Notes", "Personal Notes")}
</h1>
<p className="text-gray-500 dark:text-gray-400 font-medium text-lg ml-1">
{activeTab === 'assignments' ? t("Distribute academic tasks and track progress.", "Distribute academic tasks and track progress.") : t("Archive intellectual sparks with custom aesthetics.", "Archive intellectual sparks with custom aesthetics.")}
</p>
</div>
<div className="bg-white/10 dark:bg-gray-800/50 backdrop-blur-md p-1.5 rounded-2xl flex gap-1 border border-white/20 shadow-lg">
<button
onClick={() => setActiveTab('assignments')}
className={`px-6 py-2.5 rounded-xl text-sm font-black flex items-center gap-2 transition-all uppercase tracking-widest ${activeTab === 'assignments' ? 'bg-white text-gray-900 shadow-xl' : 'text-white hover:bg-white/10'}`}
>
<GraduationCap size={16}/> {t("Assignments", "Assignments")}
</button>
<button
onClick={() => setActiveTab('vault')}
className={`px-6 py-2.5 rounded-xl text-sm font-black flex items-center gap-2 transition-all uppercase tracking-widest ${activeTab === 'vault' ? 'bg-white text-gray-900 shadow-xl' : 'text-white hover:bg-white/10'}`}
>
<StickyNote size={16}/> {t("Notes", "Notes")}
</button>
</div>
</div>
<div className="flex justify-end gap-3">
{activeTab === 'assignments' && isTeacher && (
<Button onClick={() => setIsTaskModalOpen(true)} className="bg-red-600 hover:bg-red-700 rounded-2xl h-14 px-10 text-lg font-black italic shadow-xl shadow-red-200 dark:shadow-none">
<Plus size={22} className="mr-2" />
{t("Assign Homework", "Assign Homework")}
</Button>
)}
{activeTab === 'vault' && (
<Button onClick={() => setIsNoteModalOpen(true)} className="bg-yellow-500 hover:bg-yellow-600 text-white border-transparent rounded-2xl h-14 px-10 text-lg font-black italic shadow-xl shadow-yellow-200 dark:shadow-none">
<PenTool size={22} className="mr-2" />
{t("Log Note", "Log Note")}
</Button>
)}
</div>
</div>
{loading ? (
<div className="flex justify-center items-center h-64"><Loader2 className="animate-spin text-red-500 w-12 h-12" /></div>
) : activeTab === 'assignments' ? (
tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-32 text-center space-y-6 bg-white/40 dark:bg-gray-800/40 backdrop-blur-xl rounded-[3rem] border border-white/20 dark:border-gray-700">
<div className="w-48 h-48 bg-emerald-100 dark:bg-gray-700 rounded-full flex items-center justify-center relative overflow-hidden shadow-inner border-4 border-emerald-50 dark:border-gray-800">
<img src="https://img.freepik.com/free-vector/homework-concept-illustration_114360-1077.jpg" alt="" className="w-full h-full object-cover opacity-80" />
</div>
<div>
<h3 className="text-3xl font-black text-gray-800 dark:text-white uppercase italic tracking-tighter">{t("Zero Pending", "Zero Pending")}</h3>
<p className="text-gray-500 dark:text-gray-400 mt-2 max-w-sm mx-auto font-medium text-lg leading-relaxed">
{t("No homework assigned or goals set. Enjoy your leisure or set a new milestone.", "No homework assigned or goals set. Enjoy your leisure or set a new milestone.")}
</p>
</div>
</div>
) : (
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
{overdue.length > 0 && <TaskGroup title="Overdue" tasks={overdue} color="red" toggleStatus={toggleStatus} deleteTask={deleteTask} calculateProgress={p => Math.round(((p.subtasks?.filter(s => s.completed).length || 0) / (p.subtasks?.length || 1)) * 100)} expandedTaskId={expandedTaskId} onToggleExpand={handleToggleExpand} addSubtask={addSubtask} toggleSubtask={toggleSubtask} newSubtaskInput={newSubtaskInput} setNewSubtaskInput={setNewSubtaskInput} isTeacher={isTeacher}/>}
{dueToday.length > 0 && <TaskGroup title="Due Today" tasks={dueToday} color="blue" toggleStatus={toggleStatus} deleteTask={deleteTask} calculateProgress={p => Math.round(((p.subtasks?.filter(s => s.completed).length || 0) / (p.subtasks?.length || 1)) * 100)} expandedTaskId={expandedTaskId} onToggleExpand={handleToggleExpand} addSubtask={addSubtask} toggleSubtask={toggleSubtask} newSubtaskInput={newSubtaskInput} setNewSubtaskInput={setNewSubtaskInput} isTeacher={isTeacher}/>}
{upcoming.length > 0 && <TaskGroup title="Upcoming" tasks={upcoming} color="green" toggleStatus={toggleStatus} deleteTask={deleteTask} calculateProgress={p => Math.round(((p.subtasks?.filter(s => s.completed).length || 0) / (p.subtasks?.length || 1)) * 100)} expandedTaskId={expandedTaskId} onToggleExpand={handleToggleExpand} addSubtask={addSubtask} toggleSubtask={toggleSubtask} newSubtaskInput={newSubtaskInput} setNewSubtaskInput={setNewSubtaskInput} isTeacher={isTeacher}/>}
{submitted.length > 0 && <TaskGroup title="Review Pending" tasks={submitted} color="yellow" toggleStatus={toggleStatus} deleteTask={deleteTask} calculateProgress={p => 100} expandedTaskId={expandedTaskId} onToggleExpand={handleToggleExpand} addSubtask={addSubtask} toggleSubtask={toggleSubtask} newSubtaskInput={newSubtaskInput} setNewSubtaskInput={setNewSubtaskInput} isTeacher={isTeacher}/>}
{completed.length > 0 && <TaskGroup title="Completed" tasks={completed} color="gray" toggleStatus={toggleStatus} deleteTask={deleteTask} calculateProgress={p => 100} expandedTaskId={expandedTaskId} onToggleExpand={handleToggleExpand} addSubtask={addSubtask} toggleSubtask={toggleSubtask} newSubtaskInput={newSubtaskInput} setNewSubtaskInput={setNewSubtaskInput} isTeacher={isTeacher}/>}
</div>
)
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 animate-in fade-in duration-700">
{notes.length === 0 ? (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-gray-400">
<StickyNote size={64} className="mb-4 opacity-20"/>
<p className="text-xl font-black uppercase tracking-widest">No notes in the vault</p>
</div>
) : (
notes.map((note) => (
<div key={note.id} className={`relative p-8 rounded-[2.5rem] shadow-xl border border-black/5 transition-all hover:-translate-y-2 hover:shadow-2xl ${note.color || 'bg-yellow-100'} ${note.fontFamily === 'serif' ? 'font-serif' : note.fontFamily === 'mono' ? 'font-mono' : 'font-sans'}`}>
<button onClick={() => deleteNote(note.id)} className="absolute top-6 right-6 p-2 bg-white/50 hover:bg-red-500 hover:text-white rounded-full transition-colors text-gray-500 shadow-sm"><Trash2 size={16}/></button>
<h3 className={`font-black text-2xl mb-4 pr-10 uppercase italic tracking-tighter ${note.textColor || 'text-gray-900'}`}>{note.title}</h3>
<p className={`text-lg whitespace-pre-wrap leading-relaxed min-h-[120px] font-medium italic ${note.textColor || 'text-gray-700'}`}>"{note.content}"</p>
<div className="mt-8 pt-6 border-t border-black/10 flex justify-between items-center text-[10px] font-black uppercase tracking-widest text-gray-500">
<span>{new Date(note.timestamp).toLocaleDateString()}</span>
</div>
</div>
))
)}
</div>
)}
{/* Task Modal */}
{isTaskModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-xl" onClick={() => setIsTaskModalOpen(false)}></div>
<div className="relative bg-white dark:bg-gray-900 rounded-[3rem] shadow-2xl max-w-xl w-full p-10 animate-in zoom-in duration-300 border-4 border-gray-900 flex flex-col max-h-[90vh]">
<header className="flex justify-between items-center mb-8 shrink-0">
<div className="flex items-center gap-4">
<div className="p-3 bg-red-100 dark:bg-red-900/30 rounded-2xl text-red-600"><GraduationCap size={28}/></div>
<div>
<h2 className="text-2xl font-black italic uppercase tracking-tighter dark:text-white">{t("Assign Homework", "Assign Homework")}</h2>
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Class distribution Portal</p>
</div>
</div>
<button onClick={() => setIsTaskModalOpen(false)} className="p-2 text-gray-400 hover:text-red-500 transition-colors"><X size={32}/></button>
</header>
<form onSubmit={handleSaveTask} className="space-y-6 overflow-y-auto pr-2 custom-scrollbar">
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Target Class</label>
<div className="relative">
<Users size={20} className="absolute left-4 top-4 text-gray-400"/>
<select
required
className="w-full pl-12 pr-4 h-14 bg-gray-50 dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-2xl focus:border-red-500 outline-none dark:text-white font-bold text-sm appearance-none"
value={newTask.targetClass}
onChange={e => setNewTask({...newTask, targetClass: e.target.value})}
>
{CLASSES.map(cls => <option key={cls} value={cls}>Class {cls}</option>)}
</select>
</div>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Title</label>
<input
type="text" required
className="w-full px-6 h-14 bg-gray-50 dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-2xl focus:border-red-500 outline-none dark:text-white font-bold text-lg"
placeholder="e.g. History Chapter 4 Summary"
value={newTask.title}
onChange={e => setNewTask({...newTask, title: e.target.value})}
/>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Subject</label>
<select
className="w-full px-6 h-14 bg-gray-50 dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-2xl focus:border-red-500 outline-none dark:text-white font-bold text-sm"
value={newTask.subject}
onChange={e => setNewTask({...newTask, subject: e.target.value})}
>
{['Math', 'Physics', 'Chemistry', 'History', 'English', 'Nepali', 'Social', 'CS', 'General'].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Priority</label>
<select
className="w-full px-6 h-14 bg-gray-50 dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-2xl focus:border-red-500 outline-none dark:text-white font-bold text-sm"
value={newTask.priority}
onChange={e => setNewTask({...newTask, priority: e.target.value as Priority})}
>
<option value={Priority.LOW}>Standard</option>
<option value={Priority.MEDIUM}>Important</option>
<option value={Priority.HIGH}>Urgent</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Deadline</label>
<input
type="date"
className="w-full px-6 h-14 bg-gray-50 dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-2xl focus:border-red-500 outline-none dark:text-white font-bold"
value={newTask.dueDate}
onChange={e => setNewTask({...newTask, dueDate: e.target.value})}
/>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Est. Mins</label>
<input
type="number"
className="w-full px-6 h-14 bg-gray-50 dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-2xl focus:border-red-500 outline-none dark:text-white font-bold"
value={newTask.estimatedMinutes}
onChange={e => setNewTask({...newTask, estimatedMinutes: parseInt(e.target.value)})}
/>
</div>
</div>
<div className="flex flex-col gap-3 pt-6 shrink-0">
<Button type="submit" className="w-full h-18 rounded-[1.5rem] font-black uppercase text-xl italic shadow-2xl shadow-red-600/40">Assign Homework</Button>
<Button type="button" variant="ghost" onClick={() => setIsTaskModalOpen(false)} className="h-12 font-bold uppercase text-[10px] tracking-[0.3em] opacity-40">Cancel</Button>
</div>
</form>
</div>
</div>
)}
{isNoteModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-xl" onClick={() => setIsNoteModalOpen(false)}></div>
<div className="relative bg-white rounded-[2.5rem] shadow-2xl max-w-lg w-full p-10 animate-in zoom-in duration-300 border-4 border-gray-900">
<header className="flex justify-between items-center mb-8">
<h2 className="text-3xl font-black italic uppercase tracking-tighter">Vault Entry</h2>
<button onClick={() => setIsNoteModalOpen(false)}><X size={32} className="text-gray-400 hover:text-red-500 transition-colors"/></button>
</header>
<form onSubmit={handleSaveNote} className="space-y-6">
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Note Aesthetic</label>
<div className="flex gap-2">
{['bg-yellow-100', 'bg-blue-100', 'bg-green-100', 'bg-purple-100', 'bg-red-100'].map(c => (
<button key={c} type="button" onClick={() => setNewNote({...newNote, color: c})} className={`w-8 h-8 rounded-full border-2 transition-all ${c} ${newNote.color === c ? 'border-black scale-110 shadow-lg' : 'border-transparent hover:scale-105'}`}></button>
))}
</div>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Typography</label>
<div className="flex gap-2">
{(['sans', 'serif', 'mono'] as const).map(f => (
<button key={f} type="button" onClick={() => setNewNote({...newNote, fontFamily: f})} className={`px-4 py-2 rounded-xl text-xs font-black uppercase border-2 transition-all ${newNote.fontFamily === f ? 'bg-black text-white border-black' : 'bg-gray-50 text-gray-400 border-transparent hover:border-gray-200'}`}>
{f}
</button>
))}
</div>
</div>
<input required className="w-full px-6 h-14 bg-gray-50 border-2 border-gray-100 rounded-2xl focus:border-yellow-500 outline-none font-black text-xl italic uppercase tracking-tighter" value={newNote.title} onChange={e => setNewNote({...newNote, title: e.target.value})} placeholder="Title" />
<textarea required rows={5} className={`w-full px-6 py-4 bg-gray-50 border-2 border-gray-100 rounded-2xl focus:border-yellow-500 outline-none font-medium text-lg leading-relaxed resize-none ${newNote.fontFamily === 'serif' ? 'font-serif' : newNote.fontFamily === 'mono' ? 'font-mono' : 'font-sans'}`} value={newNote.content} onChange={e => setNewNote({...newNote, content: e.target.value})} placeholder="Log your thoughts..."></textarea>
<Button type="submit" className="w-full h-16 rounded-2xl bg-yellow-500 font-black uppercase text-lg">Save Note</Button>
</form>
</div>
</div>
)}
</div>
);
};
const TaskGroup = ({ title, tasks, color, toggleStatus, deleteTask, calculateProgress, expandedTaskId, onToggleExpand, addSubtask, toggleSubtask, newSubtaskInput, setNewSubtaskInput, isTeacher }: any) => {
const colorClasses: Record<string, string> = {
red: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800",
blue: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800",
green: "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800",
gray: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-400 border-gray-200 dark:border-gray-700",
yellow: "bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800"
};
return (
<div className="space-y-6">
<div className={`px-8 py-4 rounded-[1.5rem] border-l-[12px] font-black uppercase italic tracking-tighter flex justify-between items-center shadow-lg ${colorClasses[color]}`}>
<span className="text-xl">{title}</span>
<span className="text-sm bg-white dark:bg-gray-900 px-4 py-1 rounded-full border border-black/5 shadow-sm">{tasks.length}</span>
</div>
<div className="grid gap-6">
{tasks.map((task: Task, idx: number) => {
const prog = calculateProgress(task);
return (
<div key={task.id} className="bg-white dark:bg-gray-800 rounded-[2.5rem] shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden transition-all hover:shadow-2xl animate-in slide-in-from-left-4 fade-in" style={{ animationDelay: `${idx * 80}ms` }}>
<div className="p-8 flex items-start gap-6">
<button onClick={() => toggleStatus(task)} className={`mt-1 flex-shrink-0 transition-all hover:scale-110 active:scale-95 ${task.status === TaskStatus.COMPLETED ? 'text-green-500' : task.status === TaskStatus.SUBMITTED ? 'text-yellow-500' : 'text-gray-300 hover:text-red-500'}`}>
{task.status === TaskStatus.COMPLETED ? <CheckCircle size={40} /> : task.status === TaskStatus.SUBMITTED ? <Loader2 className="animate-spin" size={40}/> : <Circle size={40} />}
</button>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start gap-4">
<div>
<div className="flex items-center gap-2 mb-2 flex-wrap">
{task.targetClass && <span className="px-3 py-1 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300 rounded-lg text-[9px] font-black uppercase tracking-widest border border-indigo-100 dark:border-indigo-800 shadow-sm flex items-center gap-1.5"><GraduationCap size={10}/> Class: {task.targetClass}</span>}
<span className="px-3 py-1 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-lg text-[9px] font-black uppercase tracking-widest border border-red-100 dark:border-red-800 shadow-sm flex items-center gap-1.5"><Tag size={10}/> {task.subject}</span>
</div>
<h3 className={`font-black text-2xl tracking-tighter uppercase italic leading-none transition-all ${task.status === TaskStatus.COMPLETED ? 'text-gray-300 dark:text-gray-600 line-through' : 'text-gray-900 dark:text-white'}`}>{task.title}</h3>
</div>
<button onClick={() => deleteTask(task.id)} className="text-gray-300 hover:text-red-500 transition-colors p-2"><Trash2 size={20}/></button>
</div>
<div className="flex items-center gap-4 mt-6">
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden shadow-inner relative">
<div className={`h-full transition-all duration-1000 ease-out rounded-full ${prog === 100 ? 'bg-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]' : 'bg-indigo-600 shadow-[0_0_15px_rgba(79,70,229,0.5)]'}`} style={{width: `${prog}%`}}></div>
</div>
<span className="text-xs font-black uppercase tracking-widest text-indigo-500 shrink-0">{prog}% COMPLETE</span>
</div>
</div>
</div>
<div className="px-8 pb-8 flex justify-between items-center">
<div className="flex gap-4">
<div className="flex items-center gap-2 text-xs font-bold text-gray-400 uppercase tracking-widest bg-gray-50 dark:bg-gray-700/50 px-4 py-2 rounded-xl border border-gray-100 dark:border-gray-700"><Calendar size={14} className="text-red-500"/> Due: {new Date(task.dueDate).toLocaleDateString()}</div>
<div className="flex items-center gap-2 text-xs font-bold text-gray-400 uppercase tracking-widest bg-gray-50 dark:bg-gray-700/50 px-4 py-2 rounded-xl border border-gray-100 dark:border-gray-700"><Clock size={14} className="text-indigo-500"/> {task.estimatedMinutes} MIN</div>
</div>
<button onClick={() => onToggleExpand(task.id)} className="flex items-center gap-2 text-xs font-black uppercase tracking-[0.2em] text-gray-500 hover:text-indigo-600 transition-all active:scale-95 px-6 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-xl border border-gray-100 dark:border-gray-700">
{expandedTaskId === task.id ? <ChevronDown size={18}/> : <ChevronRight size={18}/>} Subtasks
</button>
</div>
{expandedTaskId === task.id && (
<div className="bg-gray-50 dark:bg-gray-900/50 p-8 border-t-2 border-gray-100 dark:border-gray-700 animate-in slide-in-from-top-4 duration-300">
<div className="space-y-4 mb-8">
{task.subtasks?.length === 0 && <p className="text-center py-6 text-gray-400 italic font-medium">No subtasks defined yet.</p>}
{task.subtasks?.map((sub: Subtask) => (
<div key={sub.id} className="flex items-center gap-4 bg-white dark:bg-gray-800 p-5 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 group/sub">
<button onClick={() => toggleSubtask(task.id, sub.id)} className={`transition-all active:scale-90 ${sub.completed ? 'text-green-500' : 'text-gray-300 hover:text-green-500'}`}>
{sub.completed ? <CheckCircle size={24}/> : <Circle size={24}/>}
</button>
<span className={`text-base font-bold flex-1 transition-all ${sub.completed ? 'text-gray-300 dark:text-gray-600 line-through italic' : 'text-gray-700 dark:text-gray-200'}`}>{sub.title}</span>
</div>
))}
</div>
{!isTeacher && (
<div className="flex gap-4">
<input className="flex-1 px-6 h-14 text-sm font-bold border-2 border-gray-100 rounded-2xl dark:bg-gray-800 dark:text-white dark:border-gray-700 outline-none focus:border-indigo-600 transition-all shadow-inner" placeholder="Add custom sub-task..." value={newSubtaskInput} onChange={(e: any) => setNewSubtaskInput(e.target.value)} onKeyDown={(e: any) => e.key === 'Enter' && addSubtask(task.id)} />
<button onClick={() => addSubtask(task.id)} className="w-14 h-14 bg-indigo-600 text-white rounded-2xl flex items-center justify-center hover:bg-indigo-700 transition-all shadow-xl active:scale-90 shrink-0"><Plus size={24}/></button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
export default Planner;

712
pages/Profile.tsx Normal file
View File

@ -0,0 +1,712 @@
import React, { useState, useEffect } from 'react';
import { StorageService } from '../services/storageService';
import { UserProfile, Transaction } from '../types';
import { Button } from '../components/ui/Button';
import { User, Mail, Briefcase, GraduationCap, Upload, Check, Loader2, CheckCircle, X, Sparkles, Palette, Frame as FrameIcon, Image as ImageIcon, ImagePlus, AtSign, Compass, Lock, History, Coins, ArrowUpRight, ArrowDownLeft, ChevronRight, PenTool, PlayCircle, Lightbulb, Wallet, Shield, Camera, ShoppingBag } from 'lucide-react';
import { useNavigate, Link, useLocation } from 'react-router-dom';
import confetti from 'canvas-confetti';
const AVATAR_PRESETS = [
{ id: 'unicorn', name: 'Unicorn', seed: 'unicorn', bg: 'bg-pink-100', border: 'border-pink-300' },
{ id: 'bunny', name: 'Bunny', seed: 'bunny', bg: 'bg-rose-100', border: 'border-rose-300' },
{ id: 'royal', name: 'Royal', seed: 'king', bg: 'bg-amber-100', border: 'border-amber-300' },
{ id: 'dreamy', name: 'Dreamy', seed: 'sky', bg: 'bg-sky-100', border: 'border-sky-300' },
{ id: 'witch', name: 'Witch', seed: 'witch', bg: 'bg-purple-100', border: 'border-purple-300' },
{ id: 'fox', name: 'Fox', seed: 'fox', bg: 'bg-orange-100', border: 'border-orange-300' },
{ id: 'fairy', name: 'Fairy', seed: 'butterfly', bg: 'bg-indigo-100', border: 'border-indigo-300' },
{ id: 'nature', name: 'Nature', seed: 'garden', bg: 'bg-emerald-100', border: 'border-emerald-300' },
{ id: 'dark', name: 'Dark', seed: 'demon', bg: 'bg-slate-800', border: 'border-slate-600' },
{ id: 'moon', name: 'Moon', seed: 'luna', bg: 'bg-blue-900', border: 'border-blue-700' },
{ id: 'bear', name: 'Bear', seed: 'bear', bg: 'bg-stone-100', border: 'border-stone-300' },
{ id: 'cat', name: 'Cat', seed: 'cat', bg: 'bg-orange-50', border: 'border-orange-200' },
{ id: 'dog', name: 'Dog', seed: 'dog', bg: 'bg-yellow-50', border: 'border-yellow-200' },
{ id: 'panda', name: 'Panda', seed: 'panda', bg: 'bg-gray-100', border: 'border-gray-300' },
{ id: 'lion', name: 'Lion', seed: 'lion', bg: 'bg-amber-200', border: 'border-amber-400' },
{ id: 'tiger', name: 'Tiger', seed: 'tiger', bg: 'bg-orange-200', border: 'border-orange-400' },
{ id: 'koala', name: 'Koala', seed: 'koala', bg: 'bg-slate-200', border: 'border-slate-400' },
];
const ADVENTURER_PRESETS = [
{ id: 'hero', name: 'Hero', seed: 'hero', bg: 'bg-blue-100', border: 'border-blue-300' },
{ id: 'scout', name: 'Scout', seed: 'scout', bg: 'bg-green-100', border: 'border-green-300' },
{ id: 'mage', name: 'Mage', seed: 'mage', bg: 'bg-purple-100', border: 'border-purple-300' },
{ id: 'rogue', name: 'Rogue', seed: 'rogue', bg: 'bg-gray-100', border: 'border-gray-300' },
{ id: 'paladin', name: 'Paladin', seed: 'paladin', bg: 'bg-yellow-100', border: 'border-yellow-300' },
{ id: 'hunter', name: 'Hunter', seed: 'hunter', bg: 'bg-orange-100', border: 'border-orange-300' },
{ id: 'cleric', name: 'Cleric', seed: 'cleric', bg: 'bg-teal-100', border: 'border-teal-300' },
{ id: 'bard', name: 'Bard', seed: 'bard', bg: 'bg-pink-100', border: 'border-pink-300' },
{ id: 'warrior', name: 'Warrior', seed: 'warrior', bg: 'bg-red-100', border: 'border-red-300' },
{ id: 'druid', name: 'Druid', seed: 'druid', bg: 'bg-emerald-200', border: 'border-emerald-400' },
{ id: 'necromancer', name: 'Necro', seed: 'necromancer', bg: 'bg-slate-800', border: 'border-slate-600' },
{ id: 'monk', name: 'Monk', seed: 'monk', bg: 'bg-orange-50', border: 'border-orange-200' },
];
const SCIFI_PRESETS = [
{ id: 'robot1', name: 'Unit 01', seed: 'robot1', bg: 'bg-slate-100', border: 'border-slate-300' },
{ id: 'robot2', name: 'Unit 02', seed: 'robot2', bg: 'bg-cyan-100', border: 'border-cyan-300' },
{ id: 'robot3', name: 'Unit 03', seed: 'robot3', bg: 'bg-emerald-100', border: 'border-emerald-300' },
{ id: 'robot4', name: 'Unit 04', seed: 'robot4', bg: 'bg-red-100', border: 'border-red-300' },
{ id: 'robot5', name: 'Unit 05', seed: 'robot5', bg: 'bg-purple-100', border: 'border-purple-300' },
{ id: 'robot6', name: 'Unit 06', seed: 'robot6', bg: 'bg-orange-100', border: 'border-orange-300' },
{ id: 'robot7', name: 'Unit 07', seed: 'robot7', bg: 'bg-indigo-100', border: 'border-indigo-300' },
{ id: 'robot8', name: 'Unit 08', seed: 'robot8', bg: 'bg-lime-100', border: 'border-lime-300' },
{ id: 'cyborg1', name: 'Cyborg A', seed: 'cyborg1', bg: 'bg-gray-800', border: 'border-gray-600' },
{ id: 'cyborg2', name: 'Cyborg B', seed: 'cyborg2', bg: 'bg-blue-900', border: 'border-blue-700' },
{ id: 'droid', name: 'Droid', seed: 'droid', bg: 'bg-yellow-100', border: 'border-yellow-400' },
{ id: 'mecha', name: 'Mecha', seed: 'mecha', bg: 'bg-red-50', border: 'border-red-200' },
];
const FRAMES = [
{ id: 'none', name: 'No Frame', css: 'border-4 border-transparent' },
{ id: 'unicorn', name: 'Unicorn', css: 'border-4 border-pink-400 ring-4 ring-pink-200 shadow-[0_0_15px_#f472b6]' },
{ id: 'bunny', name: 'Bunny', css: 'border-4 border-white ring-4 ring-rose-300 shadow-lg' },
{ id: 'royal', name: 'Royal', css: 'border-4 border-yellow-500 ring-4 ring-yellow-200 shadow-[0_0_20px_#eab308]' },
{ id: 'dreamy', name: 'Dreamy', css: 'border-4 border-sky-300 ring-4 ring-indigo-200 shadow-[0_0_15px_#7dd3fc] border-dashed' },
{ id: 'witch', name: 'Witch', css: 'border-4 border-purple-600 ring-4 ring-purple-900 shadow-[0_0_15px_#9333ea]' },
{ id: 'fox', name: 'Fox', css: 'border-4 border-orange-500 ring-4 ring-orange-200 shadow-md' },
{ id: 'fairy', name: 'Fairy', css: 'border-4 border-teal-300 ring-4 ring-emerald-100 shadow-[0_0_15px_#5eead4]' },
{ id: 'nature', name: 'Nature', css: 'border-8 border-green-500 border-double shadow-sm' },
{ id: 'dark', name: 'Dark', css: 'border-4 border-gray-800 ring-4 ring-red-900 shadow-[0_0_20px_#000]' },
{ id: 'moon', name: 'Moon', css: 'border-4 border-slate-300 ring-4 ring-blue-900 shadow-[0_0_15px_#cbd5e1]' },
{ id: 'cyber', name: 'Cyber', css: 'border-4 border-cyan-400 ring-4 ring-cyan-900 shadow-[0_0_20px_#22d3ee]' },
{ id: 'vintage', name: 'Vintage', css: 'border-8 border-amber-700 border-double shadow-inner' },
{ id: 'neon', name: 'Neon', css: 'border-4 border-lime-400 ring-4 ring-lime-900 shadow-[0_0_20px_#a3e635] animate-pulse' },
];
const TIPS = [
"Drink a glass of warm water every morning to boost metabolism.",
"Take a 5-minute break for every 25 minutes of study.",
"Meditation for 10 minutes can reduce daily stress by 40%.",
"Eating seasonal fruits supports local farmers and your health.",
"A clutter-free workspace leads to a clutter-free mind.",
"Kindness is the highest form of wisdom.",
"Consistent sleep schedules improve memory retention.",
];
const Profile: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [activeTab, setActiveTab] = useState<'info' | 'ledger'>('info');
const [profile, setProfile] = useState<UserProfile | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
// Avatar Studio State
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
const [studioTab, setStudioTab] = useState<'avatars' | 'frames'>('avatars');
const [activeCollection, setActiveCollection] = useState<'avataaars' | 'adventurer' | 'scifi'>('avataaars');
// Ad Simulation
const [isWatchingAd, setIsWatchingAd] = useState(false);
const [showTipModal, setShowTipModal] = useState(false);
const [currentTip, setCurrentTip] = useState('');
// Temporary State for Studio
const [tempAvatar, setTempAvatar] = useState('');
const [tempFrame, setTempFrame] = useState('none');
// Edit State
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [bio, setBio] = useState('');
const [avatarUrl, setAvatarUrl] = useState('');
const [bannerUrl, setBannerUrl] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [previewBannerUrl, setPreviewBannerUrl] = useState('');
const [frameId, setFrameId] = useState('none');
const [profession, setProfession] = useState('');
const [school, setSchool] = useState('');
useEffect(() => {
const fetchProfile = async () => {
setLoading(true);
const p = await StorageService.getProfile();
setProfile(p);
if (p) {
setName(p.name);
setUsername(p.username || '');
setBio(p.bio || '');
setAvatarUrl(p.avatarUrl || '');
setBannerUrl(p.bannerUrl || '');
setFrameId(p.frameId || 'none');
setProfession(p.profession || '');
setSchool(p.schoolName || '');
if (p.avatarUrl && p.avatarUrl.includes('/adventurer/')) {
setActiveCollection('adventurer');
} else if (p.avatarUrl && p.avatarUrl.includes('/bottts/')) {
setActiveCollection('scifi');
}
const txs = await StorageService.getTransactions(p.id);
setTransactions(txs);
}
setLoading(false);
};
fetchProfile();
}, []);
// Handle Intent from Navigation
useEffect(() => {
if (location.state && !loading && profile) {
const { action } = location.state as any;
if (action === 'ledger') {
setActiveTab('ledger');
} else if (action === 'avatar') {
openStudio();
} else if (action === 'edit') {
setActiveTab('info');
}
}
}, [location.state, loading, profile]);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
if (username !== profile?.username) {
const existing = await StorageService.searchUsers(username);
const taken = existing.find(u => u.username === username && u.id !== profile?.id);
if (taken) {
alert("Username already taken!");
setSaving(false);
return;
}
}
const finalAvatarUrl = previewUrl || avatarUrl;
const finalBannerUrl = previewBannerUrl || bannerUrl;
const updated = await StorageService.updateProfile({
name,
username,
bio,
avatarUrl: finalAvatarUrl,
bannerUrl: finalBannerUrl,
frameId,
profession: profile?.role === 'student' ? 'Student' : profession,
schoolName: school
});
setProfile(updated || null);
if (updated) {
setAvatarUrl(updated.avatarUrl || '');
setBannerUrl(updated.bannerUrl || '');
setPreviewUrl('');
setPreviewBannerUrl('');
setShowSuccess(true);
window.dispatchEvent(new Event('rudraksha-profile-update'));
setTimeout(() => { setShowSuccess(false); }, 3000);
}
setSaving(false);
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => setPreviewUrl(ev.target?.result as string);
reader.readAsDataURL(file);
}
};
const handleBannerChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => setPreviewBannerUrl(ev.target?.result as string);
reader.readAsDataURL(file);
}
};
const openStudio = () => {
setTempAvatar(previewUrl || avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${name}`);
setTempFrame(frameId);
setShowAvatarMenu(true);
};
const closeStudio = (apply: boolean) => {
if (apply) {
setPreviewUrl(tempAvatar);
setFrameId(tempFrame);
}
setShowAvatarMenu(false);
};
const selectPresetAvatar = (seed: string) => {
let url = '';
if (activeCollection === 'adventurer') {
url = `https://api.dicebear.com/9.x/adventurer/svg?seed=${seed}&backgroundColor=transparent`;
} else if (activeCollection === 'scifi') {
url = `https://api.dicebear.com/7.x/bottts/svg?seed=${seed}&backgroundColor=transparent`;
} else {
url = `https://api.dicebear.com/7.x/avataaars/svg?seed=${seed}&backgroundColor=transparent`;
}
setTempAvatar(url);
};
const getFrameStyle = (id: string) => {
return FRAMES.find(f => f.id === id)?.css || 'border-4 border-transparent';
};
const isAvatarSelected = (seed: string) => {
return tempAvatar.includes(`seed=${seed}`);
};
// --- KARMA EARNING SIMULATIONS ---
const handleWatchAd = () => {
setIsWatchingAd(true);
setTimeout(async () => {
setIsWatchingAd(false);
await StorageService.addPoints(50, 0, 'ad_reward', 'Watched Advertisement');
confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 }, colors: ['#facc15', '#eab308'] });
// Refresh profile
const p = await StorageService.getProfile();
setProfile(p);
if (p) {
const txs = await StorageService.getTransactions(p.id);
setTransactions(txs);
}
}, 3000); // 3 second simulation
};
const handleGetTip = () => {
const STORAGE_KEY_START = 'rudraksha_wisdom_start';
const STORAGE_KEY_AMOUNT = 'rudraksha_wisdom_amount';
const LIMIT = 100;
const REWARD = 10;
const DURATION = 24 * 60 * 60 * 1000;
const now = Date.now();
let startTime = parseInt(localStorage.getItem(STORAGE_KEY_START) || '0');
let amount = parseInt(localStorage.getItem(STORAGE_KEY_AMOUNT) || '0');
// Check if 24 hours have passed since the start of the current cycle
// If startTime is 0, it means it's the first time ever, so we start a cycle
if (now - startTime > DURATION || startTime === 0) {
startTime = now;
amount = 0;
localStorage.setItem(STORAGE_KEY_START, startTime.toString());
localStorage.setItem(STORAGE_KEY_AMOUNT, '0');
}
if (amount >= LIMIT) {
const timeLeft = DURATION - (now - startTime);
const hours = Math.floor(timeLeft / (1000 * 60 * 60));
const minutes = Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60));
alert(`Daily Wisdom limit reached (${LIMIT} Karma). Timer resets in ${hours}h ${minutes}m.`);
return;
}
const tip = TIPS[Math.floor(Math.random() * TIPS.length)];
setCurrentTip(tip);
setShowTipModal(true);
StorageService.addPoints(REWARD, 0, 'tip_reward', 'Daily Wisdom');
amount += REWARD;
localStorage.setItem(STORAGE_KEY_AMOUNT, amount.toString());
// Refresh profile background
StorageService.getProfile().then(p => {
setProfile(p);
if (p) StorageService.getTransactions(p.id).then(setTransactions);
});
};
const isAdventurerLocked = !profile?.unlockedItems?.includes('pack_adventurer');
if (loading) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-red-600"/></div>;
if (!profile) return null;
const currentBanner = previewBannerUrl || bannerUrl;
const isStudent = profile.role === 'student';
return (
<div className="max-w-5xl mx-auto space-y-8 pb-24 relative animate-fade-in px-4">
{showSuccess && (
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-[110] bg-green-600 text-white px-6 py-4 rounded-full shadow-2xl flex items-center justify-center gap-3 animate-slide-up">
<CheckCircle size={24} />
<span className="font-bold text-lg">Changes Saved Successfully!</span>
</div>
)}
{/* Modern Header Switcher */}
<div className="flex justify-center">
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl p-1.5 rounded-full shadow-lg border border-white/20 inline-flex gap-2">
<button
onClick={() => setActiveTab('info')}
className={`px-8 py-3 rounded-full text-sm font-black uppercase tracking-widest flex items-center gap-2 transition-all ${activeTab === 'info' ? 'bg-indigo-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}`}
>
<User size={16}/> Identity
</button>
<button
onClick={() => setActiveTab('ledger')}
className={`px-8 py-3 rounded-full text-sm font-black uppercase tracking-widest flex items-center gap-2 transition-all ${activeTab === 'ledger' ? 'bg-indigo-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}`}
>
<Wallet size={16}/> Ledger
</button>
</div>
</div>
{activeTab === 'info' ? (
<div className="bg-white dark:bg-gray-900 rounded-[3rem] shadow-2xl border-4 border-gray-100 dark:border-gray-800 overflow-hidden animate-in fade-in zoom-in duration-500 relative">
{/* Banner Area */}
<div className="h-64 relative group">
{currentBanner ? (
<img src={currentBanner} alt="Banner" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500"></div>
)}
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/40 transition-colors"></div>
<label className="absolute top-6 right-6 bg-white/20 backdrop-blur-md hover:bg-white/40 text-white p-3 rounded-2xl cursor-pointer transition-all hover:scale-105 active:scale-95 shadow-lg border border-white/30">
<ImagePlus size={24} />
<input type="file" className="hidden" accept="image/*" onChange={handleBannerChange}/>
</label>
</div>
{/* Avatar & Key Info Overlay */}
<div className="px-8 md:px-12 relative -mt-20 z-10 flex flex-col md:flex-row items-end md:items-end gap-6">
<div className="relative group">
<div className={`w-40 h-40 rounded-[2.5rem] overflow-hidden bg-white dark:bg-gray-800 relative shadow-2xl transition-transform transform group-hover:scale-[1.02] ${getFrameStyle(frameId)}`}>
<img
src={previewUrl || avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${name}`}
alt="Profile"
className="w-full h-full object-cover"
/>
{/* Custom Avatar Upload Overlay */}
<label className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer z-20 backdrop-blur-sm">
<Camera className="text-white w-8 h-8 mb-1" />
<span className="text-[8px] font-black uppercase text-white tracking-widest">Change Photo</span>
<input type="file" className="hidden" accept="image/*" onChange={handleFileChange} />
</label>
</div>
<div className="absolute -bottom-4 -right-4 flex gap-2 z-30">
<button
type="button"
onClick={openStudio}
className="p-3 bg-indigo-600 rounded-2xl border-4 border-white dark:border-gray-900 shadow-xl text-white transition-all hover:scale-110 active:scale-95 hover:rotate-12"
title="Open Avatar Studio"
>
<Palette size={20} />
</button>
</div>
</div>
<div className="flex-1 pb-4 text-center md:text-left">
<h2 className="text-4xl font-black text-gray-900 dark:text-white uppercase italic tracking-tighter">{name}</h2>
<p className="text-gray-500 dark:text-gray-400 font-bold">@{username}</p>
</div>
</div>
<form onSubmit={handleSave} className="p-8 md:p-12 space-y-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3">
<label className="text-xs font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Display Name</label>
<div className="relative group">
<User size={20} className="absolute left-5 top-4 text-gray-400 group-focus-within:text-indigo-500 transition-colors"/>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full pl-14 pr-6 py-4 border-2 border-gray-100 dark:border-gray-700 rounded-2xl bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-white focus:border-indigo-500 focus:bg-white dark:focus:bg-gray-800 outline-none transition-all font-bold text-lg"
/>
</div>
</div>
<div className="space-y-3">
<label className="text-xs font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Username</label>
<div className="relative group">
<AtSign size={20} className="absolute left-5 top-4 text-gray-400 group-focus-within:text-indigo-500 transition-colors"/>
<input
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full pl-14 pr-6 py-4 border-2 border-gray-100 dark:border-gray-700 rounded-2xl bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-white focus:border-indigo-500 focus:bg-white dark:focus:bg-gray-800 outline-none transition-all font-bold text-lg"
/>
</div>
</div>
<div className="space-y-3 md:col-span-2">
<label className="text-xs font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Bio / Manifesto</label>
<div className="relative group">
<PenTool size={20} className="absolute left-5 top-4 text-gray-400 group-focus-within:text-indigo-500 transition-colors"/>
<textarea
value={bio}
onChange={e => setBio(e.target.value)}
placeholder="Share your digital essence..."
rows={3}
className="w-full pl-14 pr-6 py-4 border-2 border-gray-100 dark:border-gray-700 rounded-2xl bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-white focus:border-indigo-500 focus:bg-white dark:focus:bg-gray-800 outline-none transition-all font-medium text-lg resize-none"
/>
</div>
</div>
<div className="space-y-3">
<label className="text-xs font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Role</label>
<div className="relative group">
<Briefcase size={20} className="absolute left-5 top-4 text-gray-400 group-focus-within:text-indigo-500 transition-colors"/>
<input
value={isStudent ? 'Student' : profession}
disabled={isStudent}
onChange={e => setProfession(e.target.value)}
className={`w-full pl-14 pr-6 py-4 border-2 border-gray-100 dark:border-gray-700 rounded-2xl bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-white focus:border-indigo-500 focus:bg-white dark:focus:bg-gray-800 outline-none transition-all font-bold text-lg ${isStudent ? 'opacity-60 cursor-not-allowed italic' : ''}`}
/>
</div>
</div>
{(profile.role === 'student' || profile.role === 'teacher') && (
<div className="space-y-3">
<label className="text-xs font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Institution</label>
<div className="relative group">
<GraduationCap size={20} className="absolute left-5 top-4 text-gray-400 group-focus-within:text-indigo-500 transition-colors"/>
<input
value={school}
onChange={e => setSchool(e.target.value)}
className="w-full pl-14 pr-6 py-4 border-2 border-gray-100 dark:border-gray-700 rounded-2xl bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-white focus:border-indigo-500 focus:bg-white dark:focus:bg-gray-800 outline-none transition-all font-bold text-lg"
/>
</div>
</div>
)}
</div>
<div className="pt-8 border-t border-gray-100 dark:border-gray-800 flex justify-end">
<Button type="submit" disabled={saving} className="w-full md:w-auto h-16 px-12 text-xl bg-indigo-600 hover:bg-indigo-700 shadow-xl shadow-indigo-600/30 rounded-2xl font-black uppercase tracking-widest">
{saving ? <Loader2 className="animate-spin mr-2"/> : <Check className="mr-2" size={24}/>}
Save Identity
</Button>
</div>
</form>
</div>
) : (
/* LEDGER VIEW */
<div className="space-y-8 animate-in slide-in-from-right-4 duration-500">
{/* Wallet Card */}
<div className="bg-gradient-to-br from-gray-900 to-black text-white rounded-[3rem] p-10 shadow-2xl relative overflow-hidden group">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-20"></div>
<div className="absolute -right-20 -top-20 w-80 h-80 bg-yellow-500/20 rounded-full blur-[100px] group-hover:bg-yellow-500/30 transition-colors"></div>
<div className="relative z-10 flex flex-col md:flex-row justify-between items-center gap-10">
<div className="flex items-center gap-8">
<div className="w-24 h-24 bg-yellow-500 rounded-3xl flex items-center justify-center text-black shadow-[0_0_40px_rgba(234,179,8,0.6)] rotate-3 group-hover:rotate-12 transition-transform duration-500">
<Coins size={48} />
</div>
<div>
<p className="text-xs font-black uppercase text-gray-400 tracking-[0.4em] mb-2">Karma Balance</p>
<h2 className="text-6xl font-black italic tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-yellow-200 to-yellow-500 drop-shadow-sm">{profile.points}</h2>
</div>
</div>
<div className="flex gap-4">
<Link to="/rewards">
<Button className="h-16 px-10 rounded-2xl font-black uppercase tracking-widest text-xs bg-white text-black hover:bg-gray-200 shadow-xl">
Spend <ArrowUpRight className="ml-2" size={18}/>
</Button>
</Link>
</div>
</div>
</div>
{/* Earn Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<button
onClick={handleWatchAd}
disabled={isWatchingAd}
className="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border-2 border-gray-100 dark:border-gray-700 shadow-lg hover:shadow-xl hover:border-blue-500/50 transition-all group text-left relative overflow-hidden"
>
{isWatchingAd && (
<div className="absolute inset-0 bg-black/80 z-20 flex flex-col items-center justify-center text-white backdrop-blur-sm">
<Loader2 className="animate-spin mb-4" size={48} />
<p className="font-black uppercase tracking-widest text-xs">Simulating Ad...</p>
</div>
)}
<div className="p-4 bg-blue-100 dark:bg-blue-900/30 w-fit rounded-2xl text-blue-600 mb-6 group-hover:scale-110 transition-transform"><PlayCircle size={32}/></div>
<h3 className="text-2xl font-black uppercase italic tracking-tighter text-gray-900 dark:text-white mb-2">Watch Ad</h3>
<p className="text-gray-500 text-sm font-medium">Support the platform and earn instant +50 Karma.</p>
</button>
<button
onClick={handleGetTip}
className="bg-white dark:bg-gray-800 p-8 rounded-[2.5rem] border-2 border-gray-100 dark:border-gray-700 shadow-lg hover:shadow-xl hover:border-yellow-500/50 transition-all group text-left"
>
<div className="p-4 bg-yellow-100 dark:bg-yellow-900/30 w-fit rounded-2xl text-yellow-600 mb-6 group-hover:scale-110 transition-transform"><Lightbulb size={32}/></div>
<h3 className="text-2xl font-black uppercase italic tracking-tighter text-gray-900 dark:text-white mb-2">Daily Wisdom</h3>
<p className="text-gray-500 text-sm font-medium">Read a health or study tip for +10 Karma.</p>
</button>
</div>
{/* Transactions */}
<div className="bg-white dark:bg-gray-800 rounded-[2.5rem] shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden">
<div className="px-10 py-8 border-b border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/50 flex items-center gap-4">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg"><History size={20} className="text-gray-500"/></div>
<h3 className="font-black text-gray-900 dark:text-white text-sm uppercase tracking-[0.2em]">Transaction Protocol</h3>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700 max-h-[600px] overflow-y-auto custom-scrollbar">
{transactions.length === 0 ? (
<div className="p-20 text-center text-gray-400">
<History size={64} className="mx-auto mb-6 opacity-20"/>
<p className="font-bold uppercase tracking-widest text-sm">No recent activity detected</p>
</div>
) : (
transactions.map(tx => (
<div key={tx.id} className="p-8 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
<div className="flex items-center gap-6">
<div className={`p-4 rounded-2xl transition-transform group-hover:scale-110 ${tx.amount > 0 ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}>
{tx.amount > 0 ? <ArrowDownLeft size={24}/> : <ArrowUpRight size={24}/>}
</div>
<div>
<p className="font-black text-gray-900 dark:text-white text-lg uppercase italic tracking-tight">{tx.description || tx.type.replace('_', ' ')}</p>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest mt-1">{new Date(tx.timestamp).toLocaleDateString()} {new Date(tx.timestamp).toLocaleTimeString()}</p>
</div>
</div>
<div className={`text-right font-black italic tracking-tighter text-2xl ${tx.amount > 0 ? 'text-green-600' : 'text-red-500'}`}>
{tx.amount > 0 ? '+' : ''}{tx.amount}
</div>
</div>
))
)}
</div>
</div>
</div>
)}
{/* MODALS */}
{/* Avatar Studio */}
{showAvatarMenu && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/90 backdrop-blur-md animate-fade-in">
<div className="bg-white dark:bg-gray-900 rounded-[3rem] shadow-2xl w-full max-w-5xl overflow-hidden border-4 border-gray-200 dark:border-gray-800 flex flex-col max-h-[90vh] animate-pop">
<div className="p-8 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center bg-gray-50 dark:bg-gray-950 shrink-0">
<h2 className="text-3xl font-black flex items-center gap-3 text-gray-900 dark:text-white tracking-tighter italic uppercase">
<Sparkles className="text-indigo-500" size={32} /> Avatar Studio
</h2>
<button onClick={() => closeStudio(false)} className="p-3 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-500 hover:text-red-500">
<X size={28} />
</button>
</div>
<div className="p-6 bg-white dark:bg-gray-900 shrink-0 space-y-6">
<div className="flex p-1.5 bg-gray-100 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
<button onClick={() => setStudioTab('avatars')} className={`flex-1 py-3 text-sm font-black uppercase tracking-wide rounded-xl transition-all ${studioTab === 'avatars' ? 'bg-white dark:bg-gray-700 text-indigo-600 shadow-md' : 'text-gray-500 hover:text-gray-700'}`}>
Avatar Base
</button>
<button onClick={() => setStudioTab('frames')} className={`flex-1 py-3 text-sm font-black uppercase tracking-wide rounded-xl transition-all ${studioTab === 'frames' ? 'bg-white dark:bg-gray-700 text-indigo-600 shadow-md' : 'text-gray-500 hover:text-gray-700'}`}>
Frames
</button>
</div>
{studioTab === 'avatars' && (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-none">
{['avataaars', 'adventurer', 'scifi'].map((col) => (
<button
key={col}
onClick={() => setActiveCollection(col as any)}
className={`px-6 py-2 rounded-full text-xs font-black uppercase tracking-widest border-2 transition-all flex items-center gap-2 ${activeCollection === col ? 'bg-indigo-600 text-white border-indigo-600 shadow-lg' : 'bg-white dark:bg-gray-800 text-gray-500 border-gray-200 dark:border-gray-700'}`}
>
{col}
{col === 'adventurer' && isAdventurerLocked && <Lock size={12} />}
</button>
))}
</div>
)}
</div>
<div className="p-8 overflow-y-auto flex-1 bg-gray-50 dark:bg-black/20 custom-scrollbar relative">
{studioTab === 'avatars' ? (
// Locked Collection View
activeCollection === 'adventurer' && isAdventurerLocked ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-50/90 dark:bg-gray-900/90 backdrop-blur-sm z-10 p-8 text-center animate-in zoom-in">
<div className="w-24 h-24 bg-gray-200 dark:bg-gray-800 rounded-full flex items-center justify-center mb-6 shadow-xl border-4 border-gray-300 dark:border-gray-700">
<Lock size={48} className="text-gray-400" />
</div>
<h3 className="text-3xl font-black uppercase italic tracking-tighter text-gray-800 dark:text-white mb-2">Locked Collection</h3>
<p className="text-gray-500 max-w-sm mb-8 font-medium">
The Adventurer Pack is a premium set available in the Karma Bazaar. Unlock it to access these exclusive RPG-style avatars.
</p>
<Link to="/rewards">
<Button className="h-14 px-8 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-black uppercase tracking-widest shadow-xl flex items-center gap-2">
<ShoppingBag size={18} /> Visit Bazaar
</Button>
</Link>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6 animate-in fade-in">
{(activeCollection === 'adventurer' ? ADVENTURER_PRESETS : (activeCollection === 'scifi' ? SCIFI_PRESETS : AVATAR_PRESETS)).map((preset) => (
<button
key={preset.id}
onClick={() => selectPresetAvatar(preset.seed)}
className={`group relative flex flex-col items-center gap-4 p-6 rounded-[2rem] transition-all duration-200 border-4 ${isAvatarSelected(preset.seed) ? 'bg-indigo-50 dark:bg-indigo-900/20 border-indigo-500 shadow-xl' : 'bg-white dark:bg-gray-800 border-transparent hover:border-indigo-200 hover:shadow-lg'}`}
>
<div className={`w-24 h-24 rounded-full ${preset.bg} p-1 border-4 ${preset.border} overflow-hidden shadow-sm group-hover:scale-110 transition-transform`}>
<img
src={activeCollection === 'adventurer' ? `https://api.dicebear.com/9.x/adventurer/svg?seed=${preset.seed}&backgroundColor=transparent` : (activeCollection === 'scifi' ? `https://api.dicebear.com/7.x/bottts/svg?seed=${preset.seed}&backgroundColor=transparent` : `https://api.dicebear.com/7.x/avataaars/svg?seed=${preset.seed}&backgroundColor=transparent`)}
alt={preset.name}
className="w-full h-full object-cover rounded-full"
/>
</div>
<span className="text-xs font-black uppercase tracking-widest text-gray-600 dark:text-gray-300">{preset.name}</span>
</button>
))}
</div>
)
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{FRAMES.map((frame) => (
<button
key={frame.id}
onClick={() => setTempFrame(frame.id)}
className={`group relative flex flex-col items-center gap-4 p-6 rounded-[2rem] transition-all border-4 ${tempFrame === frame.id ? 'bg-indigo-50 dark:bg-indigo-900/20 border-indigo-500 shadow-xl' : 'bg-white dark:bg-gray-800 border-transparent hover:border-indigo-200 hover:shadow-lg'}`}
>
<div className={`w-24 h-24 rounded-full bg-gray-100 dark:bg-gray-700 p-1 overflow-hidden ${frame.css} group-hover:scale-105 transition-transform`}>
<img
src={tempAvatar || avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${name}`}
alt={frame.name}
className="w-full h-full object-cover rounded-full"
/>
</div>
<span className="text-xs font-black uppercase tracking-widest text-gray-600 dark:text-gray-300">{frame.name}</span>
</button>
))}
</div>
)}
</div>
<div className="p-6 bg-white dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800 flex justify-end gap-4 shrink-0">
<Button variant="ghost" onClick={() => closeStudio(false)} className="h-14 px-8 rounded-2xl font-bold">Cancel</Button>
<Button onClick={() => closeStudio(true)} className="bg-indigo-600 hover:bg-indigo-700 text-white h-14 px-10 rounded-2xl font-black uppercase tracking-widest shadow-xl">Apply Changes</Button>
</div>
</div>
</div>
)}
{/* Tip Modal */}
{showTipModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="bg-white dark:bg-gray-900 w-full max-w-md rounded-[2.5rem] p-8 text-center shadow-2xl border-4 border-yellow-400 relative overflow-hidden animate-pop">
<div className="absolute top-0 right-0 p-4 opacity-10"><Lightbulb size={120}/></div>
<div className="w-20 h-20 bg-yellow-100 dark:bg-yellow-900/30 rounded-full flex items-center justify-center text-yellow-600 mx-auto mb-6 shadow-lg">
<Lightbulb size={40}/>
</div>
<h3 className="text-2xl font-black text-gray-900 dark:text-white uppercase italic tracking-tighter mb-4">Daily Wisdom</h3>
<p className="text-lg text-gray-600 dark:text-gray-300 font-medium italic mb-8 leading-relaxed">"{currentTip}"</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-4 rounded-2xl mb-8 border border-yellow-200 dark:border-yellow-800">
<p className="text-xs font-black uppercase tracking-widest text-yellow-600 flex items-center justify-center gap-2">
<CheckCircle size={16}/> Reward Added
</p>
<p className="text-3xl font-black text-gray-900 dark:text-white mt-1">+10 Karma</p>
</div>
<Button onClick={() => setShowTipModal(false)} className="w-full h-16 bg-yellow-500 hover:bg-yellow-600 text-white rounded-2xl font-black uppercase tracking-widest shadow-lg text-lg">
Awesome
</Button>
</div>
</div>
)}
</div>
);
};
export default Profile;

310
pages/PublicProfile.tsx Normal file
View File

@ -0,0 +1,310 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { StorageService } from '../services/storageService';
import { UserProfile } from '../types';
import { Loader2, ArrowLeft, Trophy, Shield, Calendar, Users, MapPin, Briefcase, GraduationCap, UserPlus, CheckCircle, UserCheck, Quote } from 'lucide-react';
import { Button } from '../components/ui/Button';
// Reusing theme configs to apply them locally
const THEMES: Record<string, {
colors: Record<string, string>;
uiMode: 'light' | 'dark' | 'auto';
bgPattern?: string;
bgColor: string;
darkBgColor?: string;
darkBgPattern?: string;
}> = {
'default': {
uiMode: 'auto',
bgColor: '#fdfbf7',
darkBgColor: '#09090b',
bgPattern: "radial-gradient(circle at 0% 0%, rgba(255, 200, 150, 0.15) 0%, transparent 50%), radial-gradient(circle at 100% 100%, rgba(255, 100, 100, 0.1) 0%, transparent 50%)",
darkBgPattern: "radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.03) 0%, transparent 50%)",
colors: {}
},
'theme_royal': {
uiMode: 'dark',
bgColor: '#1a0b2e',
bgPattern: "radial-gradient(circle at 50% 0%, #581c87 0%, transparent 70%), radial-gradient(circle at 100% 100%, #3b0764 0%, transparent 50%)",
colors: { '--color-red-600': '#c084fc', '--color-red-700': '#d8b4fe' },
},
'theme_nature': {
uiMode: 'auto',
bgColor: '#f0fdf4',
darkBgColor: '#022c22',
bgPattern: "radial-gradient(circle at 0% 100%, rgba(34, 197, 94, 0.1) 0%, transparent 40%), url('https://www.transparenttextures.com/patterns/leaves.png')",
darkBgPattern: "radial-gradient(circle at 0% 100%, rgba(34, 197, 94, 0.05) 0%, transparent 40%)",
colors: { '--color-red-600': '#16a34a' },
},
'theme_ocean': {
uiMode: 'auto',
bgColor: '#eff6ff',
darkBgColor: '#0f172a',
bgPattern: "radial-gradient(circle at 100% 0%, rgba(59, 130, 246, 0.1) 0%, transparent 60%), url('https://www.transparenttextures.com/patterns/cubes.png')",
darkBgPattern: "radial-gradient(circle at 100% 0%, rgba(59, 130, 246, 0.05) 0%, transparent 60%)",
colors: { '--color-red-600': '#2563eb' },
},
'theme_midnight': {
uiMode: 'dark',
bgColor: '#020617',
bgPattern: "radial-gradient(circle at 50% 50%, rgba(99, 102, 241, 0.15) 0%, transparent 60%), radial-gradient(circle, rgba(255,255,255,0.05) 1px, transparent 1px) 0 0/30px 30px",
colors: { '--color-red-600': '#4f46e5' },
},
'theme_sky': {
uiMode: 'light',
bgColor: '#f0f9ff',
bgPattern: "linear-gradient(180deg, #e0f2fe 0%, #f0f9ff 100%)",
colors: { '--color-red-600': '#0891b2' },
},
};
const getFrameStyle = (id?: string) => {
if (!id || id === 'none') return 'ring-4 ring-white/50';
if (id === 'unicorn') return 'ring-4 ring-pink-400 shadow-[0_0_20px_#f472b6]';
if (id === 'royal') return 'ring-4 ring-yellow-500 shadow-[0_0_20px_#eab308]';
if (id === 'nature') return 'ring-4 ring-green-500';
if (id === 'dark') return 'ring-4 ring-gray-800 shadow-[0_0_20px_#000]';
return 'ring-4 ring-indigo-400';
};
const PublicProfile: React.FC = () => {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const [user, setUser] = useState<UserProfile | null>(null);
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [friendStatus, setFriendStatus] = useState<'none' | 'sent' | 'friends' | 'received'>('none');
useEffect(() => {
const fetchUser = async () => {
if (userId) {
const [targetUser, me] = await Promise.all([
StorageService.getUserPublicProfile(userId),
StorageService.getProfile()
]);
setUser(targetUser);
setCurrentUser(me);
if (targetUser && me) {
if (me.friends?.includes(targetUser.id)) setFriendStatus('friends');
else if (me.sentRequests?.includes(targetUser.id)) setFriendStatus('sent');
else if (me.friendRequests?.includes(targetUser.id)) setFriendStatus('received');
else setFriendStatus('none');
}
}
setLoading(false);
};
fetchUser();
}, [userId]);
// Apply User's Theme Effect
useEffect(() => {
if (!user) return;
const themeId = user.activeTheme || 'default';
const themeConfig = THEMES[themeId] || THEMES['default'];
const root = document.documentElement;
const body = document.body;
// Save previous state to restore
const prevBgColor = body.style.backgroundColor;
const prevBgImage = body.style.backgroundImage;
const prevDataTheme = body.getAttribute('data-theme');
// Apply new theme
body.setAttribute('data-theme', themeId);
body.setAttribute('data-theme-override', 'true'); // Flag to prevent Layout from overwriting immediately
// Mode
let isDark = false;
if (themeConfig.uiMode !== 'auto') {
isDark = themeConfig.uiMode === 'dark';
} else {
// Use system pref if auto
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
if (isDark) root.classList.add('dark');
else root.classList.remove('dark');
// Backgrounds
const bgColor = (isDark && themeConfig.darkBgColor) ? themeConfig.darkBgColor : themeConfig.bgColor;
const bgPattern = (isDark && themeConfig.darkBgPattern !== undefined) ? themeConfig.darkBgPattern : (themeConfig.bgPattern || 'none');
body.style.backgroundColor = bgColor;
body.style.backgroundImage = bgPattern;
// Apply colors
const appliedProps: string[] = [];
Object.entries(themeConfig.colors).forEach(([key, value]) => {
root.style.setProperty(key, value);
appliedProps.push(key);
});
// Cleanup on Unmount
return () => {
body.removeAttribute('data-theme-override');
// We trigger a global event to let Layout.tsx re-apply the logged-in user's theme
window.dispatchEvent(new Event('rudraksha-profile-update'));
// Clean up explicit props if needed, though Layout will likely overwrite them
appliedProps.forEach(k => root.style.removeProperty(k));
};
}, [user]);
const handleFriendRequest = async () => {
if (!user) return;
setFriendStatus('sent');
await StorageService.sendFriendRequest(user.id);
};
const handleAcceptRequest = async () => {
if (!user) return;
setFriendStatus('friends');
await StorageService.acceptFriendRequest(user.id);
};
if (loading) return <div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-white w-12 h-12"/></div>;
if (!user) return <div className="flex flex-col items-center justify-center h-screen text-gray-500">User not found <Button onClick={() => navigate(-1)} className="mt-4">Go Back</Button></div>;
return (
<div className="min-h-screen pb-20 animate-in fade-in duration-700 relative">
{/* Back Button */}
<div className="absolute top-4 left-4 z-50">
<Button onClick={() => navigate(-1)} variant="secondary" className="bg-white/20 hover:bg-white/30 backdrop-blur-md border-transparent text-white shadow-lg">
<ArrowLeft size={20} className="mr-2"/> Back
</Button>
</div>
{/* Hero Section */}
<div className="relative h-64 md:h-80 w-full overflow-hidden">
{user.bannerUrl ? (
<img src={user.bannerUrl} className="w-full h-full object-cover" alt="Banner" />
) : (
<div className="w-full h-full bg-gradient-to-r from-gray-800 to-gray-900 opacity-50"></div>
)}
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/60"></div>
</div>
<div className="max-w-4xl mx-auto px-4 -mt-20 relative z-10">
<div className="flex flex-col md:flex-row items-center md:items-end gap-6 text-center md:text-left">
{/* Avatar */}
<div className="relative">
<img
src={user.avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`}
className={`w-32 h-32 md:w-40 md:h-40 rounded-full object-cover bg-white ${getFrameStyle(user.frameId)}`}
alt={user.name}
/>
<div className="absolute bottom-2 right-2 bg-white dark:bg-gray-800 p-1.5 rounded-full shadow-md border border-gray-200 dark:border-gray-700" title={user.activeTheme || "Default Theme"}>
<div className="w-4 h-4 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500"></div>
</div>
</div>
<div className="flex-1 mb-2">
<h1 className="text-3xl md:text-4xl font-black text-white drop-shadow-md">{user.name}</h1>
<p className="text-gray-200 font-medium text-lg">@{user.username || 'user'}</p>
<div className="flex flex-wrap justify-center md:justify-start gap-2 mt-2">
<span className="bg-black/40 backdrop-blur-sm text-white px-3 py-1 rounded-full text-sm font-bold uppercase tracking-wider border border-white/20">
{user.role}
</span>
{user.schoolName && (
<span className="bg-black/40 backdrop-blur-sm text-gray-200 px-3 py-1 rounded-full text-sm flex items-center gap-1 border border-white/20">
<GraduationCap size={14}/> {user.schoolName}
</span>
)}
</div>
</div>
{/* Friend Action */}
<div className="mb-4">
{friendStatus === 'none' && currentUser && user.id !== currentUser.id ? (
<button
onClick={handleFriendRequest}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-2xl shadow-lg flex items-center gap-2 font-black uppercase tracking-widest text-xs transition-all hover:scale-105 active:scale-95"
>
<UserPlus size={18}/> Connect
</button>
) : friendStatus === 'sent' ? (
<button disabled className="bg-gray-600/80 text-white px-6 py-3 rounded-2xl flex items-center gap-2 font-black uppercase tracking-widest text-xs cursor-default">
<CheckCircle size={18}/> Request Sent
</button>
) : friendStatus === 'received' ? (
<button onClick={handleAcceptRequest} className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-2xl shadow-lg flex items-center gap-2 font-black uppercase tracking-widest text-xs transition-all hover:scale-105 active:scale-95">
<UserPlus size={18}/> Accept Request
</button>
) : friendStatus === 'friends' ? (
<button disabled className="bg-green-600/80 text-white px-6 py-3 rounded-2xl flex items-center gap-2 font-black uppercase tracking-widest text-xs cursor-default shadow-lg">
<UserCheck size={18}/> Friend
</button>
) : null}
</div>
</div>
{/* Stats */}
<div className="flex justify-center md:justify-start gap-4 mt-6">
<div className="bg-white/10 backdrop-blur-md p-3 rounded-xl border border-white/20 text-center min-w-[80px]">
<Trophy className="mx-auto text-yellow-400 mb-1" size={20}/>
<p className="text-xl font-bold text-white">{user.points}</p>
<p className="text-[10px] text-gray-300 uppercase">Karma</p>
</div>
<div className="bg-white/10 backdrop-blur-md p-3 rounded-xl border border-white/20 text-center min-w-[80px]">
<Shield className="mx-auto text-blue-400 mb-1" size={20}/>
<p className="text-xl font-bold text-white">{Math.floor((user.xp || 0) / 500) + 1}</p>
<p className="text-[10px] text-gray-300 uppercase">Level</p>
</div>
</div>
{/* Content Body - Consolidated "About" with Bio */}
<div className="mt-8">
<div className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl rounded-[2.5rem] p-8 shadow-xl border border-white/50 dark:border-gray-700">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6 uppercase tracking-wider border-b border-gray-200 dark:border-gray-800 pb-4">Personal Profile</h2>
{user.bio && (
<div className="mb-8 relative pl-6">
<Quote size={24} className="absolute left-0 top-0 text-indigo-400/30 rotate-180" />
<p className="text-xl md:text-2xl font-medium text-gray-800 dark:text-gray-200 italic leading-relaxed font-serif">
"{user.bio}"
</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div className="flex items-center gap-4 text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800/50 p-4 rounded-2xl border border-gray-100 dark:border-gray-700">
<div className="w-12 h-12 rounded-full bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center text-indigo-600">
<Briefcase size={20}/>
</div>
<div>
<p className="text-[10px] text-gray-400 uppercase font-black tracking-widest">Role</p>
<p className="font-bold text-lg">{user.profession || user.role}</p>
</div>
</div>
{user.grade && (
<div className="flex items-center gap-4 text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800/50 p-4 rounded-2xl border border-gray-100 dark:border-gray-700">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center text-green-600">
<Users size={20}/>
</div>
<div>
<p className="text-[10px] text-gray-400 uppercase font-black tracking-widest">Class</p>
<p className="font-bold text-lg">{user.grade}</p>
</div>
</div>
)}
<div className="flex items-center gap-4 text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800/50 p-4 rounded-2xl border border-gray-100 dark:border-gray-700">
<div className="w-12 h-12 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center text-orange-600">
<Calendar size={20}/>
</div>
<div>
<p className="text-[10px] text-gray-400 uppercase font-black tracking-widest">Joined</p>
<p className="font-bold text-lg">2024</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default PublicProfile;

648
pages/Recipes.tsx Normal file
View File

@ -0,0 +1,648 @@
import React, { useState, useEffect, useRef } from 'react';
import { StorageService } from '../services/storageService';
import { Recipe, ChatMessage, Review, UserProfile, RecipeCategory } from '../types';
import { getCookingChatSession } from '../services/geminiService';
import { Button } from '../components/ui/Button';
import {
Heart, Plus, Loader2, Upload, Image as ImageIcon,
X, History, ChefHat, Send, Star,
Clock, Utensils, Search, ChevronRight, User, Sparkles
} from 'lucide-react';
import { Chat, GenerateContentResponse } from '@google/genai';
import { useLanguage } from '../contexts/LanguageContext';
// Helper to parse dual-language JSON messages
const parseMessage = (rawText: string) => {
try {
const json = JSON.parse(rawText);
if (json.en || json.ne) return json;
return { en: rawText, ne: rawText };
} catch {
return { en: rawText, ne: rawText };
}
};
const Recipes: React.FC = () => {
const { t, language } = useLanguage();
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [filteredRecipes, setFilteredRecipes] = useState<Recipe[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [showForm, setShowForm] = useState(false);
const [loading, setLoading] = useState(true);
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
const [likedRecipes, setLikedRecipes] = useState<Set<string>>(new Set());
// Reviews
const [reviews, setReviews] = useState<Review[]>([]);
const [rating, setRating] = useState(0);
const [comment, setComment] = useState('');
// AI Chef State
const [isChatOpen, setIsChatOpen] = useState(false);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [chatInput, setChatInput] = useState('');
const [isChatTyping, setIsChatTyping] = useState(false);
const chatSessionRef = useRef<Chat | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// New/Edit Recipe Form State
const [newRecipe, setNewRecipe] = useState<Partial<Recipe>>({
title: '', description: '', ingredients: [], instructions: '', imageUrl: '', prepTime: 20, tags: ['daily']
});
const [ingredientsText, setIngredientsText] = useState('');
const loadRecipes = async () => {
setLoading(true);
const [data, profile] = await Promise.all([
StorageService.getRecipes('recent'),
StorageService.getProfile()
]);
setRecipes(data);
setCurrentUser(profile);
setLoading(false);
};
useEffect(() => { loadRecipes(); }, []);
const sendMessage = async (text: string) => {
if (!text.trim()) return;
if (!chatSessionRef.current) {
chatSessionRef.current = getCookingChatSession();
}
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
text: JSON.stringify({ en: text, ne: text }),
timestamp: Date.now()
};
setChatMessages(prev => [...prev, userMsg]);
setChatInput('');
setIsChatTyping(true);
try {
const resultStream = await chatSessionRef.current.sendMessageStream({ message: text });
let fullResponse = '';
const modelMsgId = (Date.now() + 1).toString();
setChatMessages(prev => [...prev, { id: modelMsgId, role: 'model', text: '', timestamp: Date.now() }]);
for await (const chunk of resultStream) {
const textChunk = (chunk as GenerateContentResponse).text || '';
fullResponse += textChunk;
setChatMessages(prev => prev.map(msg =>
msg.id === modelMsgId ? { ...msg, text: fullResponse } : msg
));
}
} catch {
setChatMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'model',
text: JSON.stringify({ en: "The kitchen is a bit smoky, try again!", ne: "भान्सामा अलिकति धुवाँ छ, फेरि प्रयास गर्नुहोस्!" }),
timestamp: Date.now()
}]);
} finally {
setIsChatTyping(false);
}
};
useEffect(() => {
const handleOpenRecipe = (e: any) => {
const { recipeId } = e.detail;
if (recipes.length > 0) {
const found = recipes.find(r => r.id === recipeId);
if (found) setSelectedRecipe(found);
}
};
const handleDraftRecipe = (e: any) => {
const { title, description } = e.detail;
setNewRecipe(prev => ({ ...prev, title: title || '', description: description || '' }));
setShowForm(true);
};
const handleConsultChef = (e: any) => {
const { query } = e.detail;
setIsChatOpen(true);
if (!chatSessionRef.current) {
const session = getCookingChatSession();
chatSessionRef.current = session;
setChatMessages([{
id: 'init',
role: 'model',
text: JSON.stringify({
en: "Namaste! I am Bhanse Dai. I am here to share the secrets of Nepali heritage cooking. What are we cooking today?",
ne: "नमस्ते! म भान्से दाइ हुँ। म यहाँ नेपाली मौलिक खानाका रहस्यहरू बाँड्न आएको छु। आज हामी के पकाउने?"
}),
timestamp: Date.now()
}]);
}
setTimeout(() => {
sendMessage(`Please teach me how to cook ${query}. Provide a list of ingredients and step-by-step instructions.`);
}, 600);
};
window.addEventListener('rudraksha-open-recipe', handleOpenRecipe);
window.addEventListener('rudraksha-draft-recipe', handleDraftRecipe);
window.addEventListener('rudraksha-consult-chef', handleConsultChef);
return () => {
window.removeEventListener('rudraksha-open-recipe', handleOpenRecipe);
window.removeEventListener('rudraksha-draft-recipe', handleDraftRecipe);
window.removeEventListener('rudraksha-consult-chef', handleConsultChef);
};
}, [recipes, t]);
useEffect(() => {
let filtered = recipes;
if (searchQuery.trim()) {
filtered = filtered.filter(r =>
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.description.toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredRecipes(filtered);
}, [recipes, searchQuery]);
const handleLike = async (e: React.MouseEvent, recipe: Recipe) => {
e.stopPropagation();
const isLiked = likedRecipes.has(recipe.id);
const newLiked = new Set(likedRecipes);
if (isLiked) {
newLiked.delete(recipe.id);
recipe.likes = Math.max(0, recipe.likes - 1);
} else {
newLiked.add(recipe.id);
recipe.likes += 1;
}
setLikedRecipes(newLiked);
await StorageService.saveRecipe(recipe);
setRecipes([...recipes]);
};
useEffect(() => {
if (selectedRecipe) {
loadReviews(selectedRecipe.id);
}
}, [selectedRecipe]);
const loadReviews = async (recipeId: string) => {
const r = await StorageService.getReviews(recipeId);
setReviews(r);
};
const handleStarClick = (selectedRating: number) => {
if (rating === selectedRating) {
setRating(0);
} else {
setRating(selectedRating);
}
};
const submitReview = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRecipe || rating === 0 || !comment.trim()) return;
const profile = await StorageService.getProfile();
const review: Review = {
id: Date.now().toString(),
targetId: selectedRecipe.id,
userId: profile?.id || 'anon',
userName: profile?.name || 'Anonymous',
rating,
comment,
timestamp: Date.now()
};
await StorageService.addReview(review);
setReviews([review, ...reviews]);
setRating(0);
setComment('');
};
useEffect(() => {
if (isChatOpen) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [chatMessages, isChatOpen]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => setNewRecipe(prev => ({ ...prev, imageUrl: reader.result as string }));
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const profile = await StorageService.getProfile();
if (!profile) return;
const recipeToSave: Recipe = {
id: Date.now().toString(),
title: newRecipe.title!,
author: profile.name,
description: newRecipe.description!,
ingredients: ingredientsText.split('\n').filter(s => s.trim()),
instructions: newRecipe.instructions!,
isPublic: true,
likes: newRecipe.likes || 0,
imageUrl: newRecipe.imageUrl,
history: newRecipe.history,
prepTime: newRecipe.prepTime,
tags: newRecipe.tags
};
await StorageService.saveRecipe(recipeToSave);
await loadRecipes();
setShowForm(false);
};
const toggleChat = () => {
if (!isChatOpen) {
if (!chatSessionRef.current) {
const session = getCookingChatSession();
chatSessionRef.current = session;
if (chatMessages.length === 0) {
setChatMessages([{
id: 'init',
role: 'model',
text: JSON.stringify({
en: "Namaste! I am Bhanse Dai. I am here to share the secrets of Nepali heritage cooking. What are we cooking today?",
ne: "नमस्ते! म भान्से दाइ हुँ। म यहाँ नेपाली मौलिक खानाका रहस्यहरू बाँड्न आएको छु। आज हामी के पकाउने?"
}),
timestamp: Date.now()
}]);
}
}
}
setIsChatOpen(!isChatOpen);
};
const openNewForm = () => {
setNewRecipe({ title: '', description: '', ingredients: [], instructions: '', imageUrl: '', prepTime: 20, tags: ['daily'] });
setIngredientsText('');
setShowForm(true);
};
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-orange-600 w-12 h-12" /></div>;
return (
<div className="space-y-8 pb-20 relative font-sans">
<header className="relative bg-gray-950 rounded-[2.5rem] overflow-hidden p-8 md:p-12 shadow-2xl border-4 border-gray-900">
<div className="absolute inset-0 opacity-70 bg-[url('https://rachelgouk.com/wp-content/uploads/2020/05/nepali-kitchen-nepalese-restaurant-shanghai-1.jpg')] bg-cover bg-center"></div>
<div className="absolute inset-0 bg-gradient-to-br from-black via-black/40 to-transparent"></div>
<div className="relative z-10 flex flex-col lg:flex-row justify-between items-center gap-8">
<div className="text-center lg:text-left space-y-4">
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 backdrop-blur-md text-orange-400 text-xs font-black uppercase tracking-widest border border-white/10">
<Utensils size={14} /> Culinary Rituals
</div>
<h1 className="text-5xl md:text-7xl font-black italic tracking-tighter text-white leading-none uppercase">
BHANSE <br/> <span className="text-orange-500">KITCHEN</span>
</h1>
<p className="text-gray-100 text-lg font-medium max-w-xl">
{t("From daily rituals to ancient ethnic delicacies, explore the flavors that define Nepal.", "From daily rituals to ancient ethnic delicacies, explore the flavors that define Nepal.")}
</p>
<div className="flex flex-wrap justify-center lg:justify-start gap-4 pt-4">
<Button onClick={toggleChat} className="bg-orange-600 hover:bg-orange-700 h-14 px-8 rounded-2xl text-lg font-black shadow-xl shadow-orange-600/30">
<ChefHat size={22} className="mr-2"/> {t("Ask Bhanse Dai", "Ask Bhanse Dai")}
</Button>
<Button onClick={openNewForm} variant="secondary" className="h-14 px-8 rounded-2xl text-lg font-black bg-white/5 border-white/10 text-white hover:bg-white/10">
<Plus size={22} className="mr-2"/> {t("Add Recipe", "Add Recipe")}
</Button>
</div>
</div>
<div className="relative w-48 h-48 md:w-64 md:h-64 shrink-0 pointer-events-none hidden md:block">
<div className="absolute inset-0 bg-orange-500/20 rounded-full blur-3xl animate-pulse"></div>
<img src="https://img.freepik.com/free-photo/view-traditional-nepalese-food-dish_23-2151122718.jpg" className="w-full h-full object-cover rounded-full border-8 border-gray-900 shadow-2xl rotate-6" alt="Dal Bhat" />
</div>
</div>
</header>
{/* Main Flashcard Grid */}
<div className="space-y-8 animate-in fade-in slide-in-from-right-6 duration-700">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex-1 max-w-md relative w-full">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Find a dish..."
className="w-full pl-12 pr-4 py-3 bg-white dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-2xl outline-none focus:border-orange-500 transition-all"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredRecipes.map((r) => (
<div
key={r.id}
onClick={() => setSelectedRecipe(r)}
className="bg-white dark:bg-gray-800 rounded-[2.5rem] overflow-hidden border-2 border-gray-100 dark:border-gray-700 shadow-sm cursor-pointer relative transition-transform hover:-translate-y-1"
>
<div className="h-64 relative overflow-hidden">
{r.imageUrl ? (
<img src={r.imageUrl} className="w-full h-full object-cover" alt={r.title} loading="lazy" />
) : (
<div className="w-full h-full bg-gray-100 flex items-center justify-center text-gray-300"><ImageIcon size={64}/></div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
<div className="absolute top-4 left-4 flex gap-2">
<span className="bg-black/50 backdrop-blur-md text-white px-3 py-1.5 rounded-xl text-[10px] font-black uppercase tracking-widest flex items-center gap-2">
<Clock size={12} className="text-orange-500" /> {r.prepTime} MIN
</span>
</div>
<div className="absolute bottom-6 left-6 right-6 text-white flex justify-between items-end">
<div>
<h3 className="text-2xl font-black italic tracking-tighter leading-none mb-1 uppercase">{r.title}</h3>
<p className="text-xs font-bold text-gray-300 flex items-center gap-2">by {r.author}</p>
</div>
<button
onClick={(e) => handleLike(e, r)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full backdrop-blur-md border transition-all ${likedRecipes.has(r.id) ? 'bg-red-500 border-red-500 text-white' : 'bg-black/20 border-white/20 text-white hover:bg-black/40'}`}
>
<Heart size={14} fill={likedRecipes.has(r.id) ? "currentColor" : "none"} />
<span className="text-xs font-black">{r.likes}</span>
</button>
</div>
</div>
<div className="p-8">
<p className="text-gray-500 dark:text-gray-400 text-sm leading-relaxed line-clamp-3 mb-6 font-medium italic">"{r.description}"</p>
<div className="flex justify-between items-center pt-6 border-t border-gray-100 dark:border-gray-700">
<div className="flex -space-x-2">
{[1,2,3].map(i => <div key={i} className="w-8 h-8 rounded-full bg-gray-100 border-2 border-white dark:border-gray-800 flex items-center justify-center text-[10px] font-bold text-gray-400">U</div>)}
</div>
<span className="text-[10px] font-black uppercase tracking-widest text-orange-600 flex items-center gap-2">
View Recipe <ChevronRight size={14} />
</span>
</div>
</div>
</div>
))}
{filteredRecipes.length === 0 && (
<div className="col-span-full py-20 text-center text-gray-400">
<Search size={48} className="mx-auto mb-4 opacity-20"/>
<p className="font-bold text-xl uppercase tracking-widest">No recipes matched your search</p>
</div>
)}
</div>
</div>
{/* Floating Chat System */}
{isChatOpen && (
<div className="fixed bottom-0 right-0 sm:bottom-6 sm:right-6 z-[60] flex flex-col items-end w-full sm:w-auto p-4 sm:p-0">
<div className="fixed inset-0 bg-black/40 sm:hidden z-[-1]" onClick={() => setIsChatOpen(false)}></div>
<div className="bg-white dark:bg-gray-950 w-full sm:w-[450px] h-[85vh] sm:h-[650px] rounded-3xl shadow-[0_30px_90px_-15px_rgba(0,0,0,0.4)] flex flex-col overflow-hidden animate-in slide-in-from-right-10 duration-500 border-4 border-gray-900">
<header className="p-6 bg-gray-900 text-white flex justify-between items-center shrink-0">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-orange-600 flex items-center justify-center shadow-lg shadow-orange-600/40 animate-bounce">
<ChefHat size={24} />
</div>
<div>
<h3 className="text-lg font-black tracking-tighter uppercase italic">Bhanse Dai</h3>
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Heritage Specialist</span>
</div>
</div>
</div>
<button onClick={() => setIsChatOpen(false)} className="p-2 hover:bg-white/10 rounded-full transition-colors"><X size={24}/></button>
</header>
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-gray-50/50 dark:bg-black/20 custom-scrollbar">
{chatMessages.map((msg) => {
const content = parseMessage(msg.text);
const displayText = language === 'ne' ? (content.ne || content.en) : content.en;
const isUser = msg.role === 'user';
return (
<div key={msg.id} className={`flex gap-3 ${isUser ? 'flex-row-reverse' : ''} animate-in slide-in-from-bottom-2 duration-300`}>
<div className={`max-w-[85%] p-5 rounded-3xl text-sm shadow-sm ${isUser ? 'bg-orange-600 text-white rounded-tr-none shadow-orange-600/20' : 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-tl-none border border-gray-100 dark:border-gray-700'}`}>
<p className="whitespace-pre-wrap font-medium leading-relaxed prose prose-sm dark:prose-invert">{displayText}</p>
<span className="text-[9px] uppercase font-bold opacity-40 mt-3 block">{new Date(msg.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
</div>
</div>
);
})}
{isChatTyping && (
<div className="flex gap-2">
<div className="bg-white dark:bg-gray-800 p-4 rounded-3xl flex items-center gap-2 shadow-sm">
<div className="w-2 h-2 bg-orange-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-orange-500 rounded-full animate-bounce delay-100"></div>
<div className="w-2 h-2 bg-orange-500 rounded-full animate-bounce delay-200"></div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={(e) => { e.preventDefault(); sendMessage(chatInput); }} className="p-6 bg-white dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800 flex gap-4">
<input
value={chatInput}
onChange={e => setChatInput(e.target.value)}
placeholder={t("Ask for heritage facts or basic tips...", "Ask for heritage facts or basic tips...")}
className="flex-1 px-6 py-4 bg-gray-50 dark:bg-gray-800 rounded-2xl text-sm font-medium border-2 border-transparent focus:border-orange-500 outline-none transition-all dark:text-white"
/>
<button type="submit" disabled={!chatInput.trim()} className="w-14 h-14 rounded-2xl bg-orange-600 text-white flex items-center justify-center hover:bg-orange-700 shadow-xl shadow-orange-600/30 transition-all active:scale-90">
<Send size={24} />
</button>
</form>
</div>
</div>
)}
{selectedRecipe && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/90 backdrop-blur-xl" onClick={() => setSelectedRecipe(null)}></div>
<div className="bg-white dark:bg-gray-950 w-full max-w-6xl max-h-[95vh] rounded-[3rem] shadow-2xl relative overflow-hidden flex flex-col md:flex-row animate-in zoom-in duration-300 border-4 border-gray-900">
<button onClick={() => setSelectedRecipe(null)} className="absolute top-6 right-6 z-10 p-3 bg-black/40 text-white rounded-full hover:bg-red-600 transition-all backdrop-blur-md active:scale-90"><X size={28}/></button>
<div className="md:w-1/2 relative h-80 md:h-auto shrink-0">
<img src={selectedRecipe.imageUrl} className="w-full h-full object-cover" alt={selectedRecipe.title} />
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent"></div>
<div className="absolute bottom-12 left-12 right-12 text-white space-y-4">
<div className="flex gap-2">
{selectedRecipe.tags?.map(t => (
<span key={t} className="bg-orange-600 text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest">{t}</span>
))}
</div>
<h2 className="text-5xl font-black italic uppercase tracking-tighter drop-shadow-2xl">{selectedRecipe.title}</h2>
<div className="flex items-center gap-6 text-sm font-bold opacity-80">
<span className="flex items-center gap-2"><Clock size={18} className="text-orange-500"/> {selectedRecipe.prepTime} Min</span>
<span className="flex items-center gap-2"><User size={18} className="text-orange-500"/> {selectedRecipe.author}</span>
</div>
</div>
</div>
<div className="md:w-1/2 p-8 md:p-14 overflow-y-auto bg-white dark:bg-gray-950 custom-scrollbar">
<div className="space-y-12">
{selectedRecipe.history && (
<div className="p-8 bg-orange-50 dark:bg-orange-900/10 rounded-[2.5rem] border-2 border-orange-100 dark:border-orange-900/30">
<h4 className="text-xs font-black uppercase text-orange-600 mb-3 tracking-[0.3em] flex items-center gap-2"><History size={16}/> Heritage Protocol</h4>
<p className="text-gray-700 dark:text-gray-300 text-lg leading-relaxed font-medium italic">"{selectedRecipe.history}"</p>
</div>
)}
<div className="grid grid-cols-1 gap-12">
<div className="space-y-6">
<h3 className="text-2xl font-black uppercase italic tracking-tighter flex items-center gap-4">
<div className="w-1.5 h-8 bg-orange-600 rounded-full"></div> Ingredients
</h3>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{selectedRecipe.ingredients.map((ing, i) => (
<li key={i} className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-2xl border border-gray-100 dark:border-gray-800 font-bold text-gray-700 dark:text-gray-200 text-sm">
<div className="w-3 h-3 rounded-full border-2 border-orange-500"></div> {ing}
</li>
))}
</ul>
</div>
<div className="space-y-6">
<h3 className="text-2xl font-black uppercase italic tracking-tighter flex items-center gap-4">
<div className="w-1.5 h-8 bg-orange-600 rounded-full"></div> Preparation
</h3>
<div className="space-y-3">
{selectedRecipe.instructions.split('\n').filter(s => s.trim()).map((step, i) => (
// Regex handles numbering if present, otherwise just renders text
<div key={i} className="flex gap-6 p-4 rounded-3xl hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
<span className="shrink-0 w-10 h-10 rounded-2xl bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center font-black text-orange-600 dark:text-orange-400 border border-orange-200 dark:border-orange-800">{i+1}</span>
<p className="text-gray-700 dark:text-gray-300 text-lg leading-relaxed font-medium pt-1">{step.replace(/^\d+\.\s*/, '')}</p>
</div>
))}
</div>
</div>
</div>
<div className="pt-12 border-t border-gray-100 dark:border-gray-800 space-y-8">
<div className="flex justify-between items-center">
<h3 className="text-2xl font-black uppercase italic tracking-tighter">Community Feedback</h3>
<div className="flex items-center gap-2 bg-yellow-400/10 px-4 py-2 rounded-xl text-yellow-600 font-black">
<Star size={20} fill="currentColor"/> {reviews.length > 0 ? (reviews.reduce((a,b) => a+b.rating, 0)/reviews.length).toFixed(1) : "NEW"}
</div>
</div>
<div className="space-y-4">
{reviews.map(r => (
<div key={r.id} className="p-6 bg-gray-50 dark:bg-gray-900 rounded-[2rem] border border-gray-100 dark:border-gray-700">
<div className="flex justify-between items-start mb-3">
<p className="font-black text-gray-900 dark:text-white uppercase tracking-tighter">{r.userName}</p>
<div className="flex text-yellow-500">
{[...Array(r.rating)].map((_, i) => <Star key={i} size={14} fill="currentColor" />)}
</div>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm font-medium italic">"{r.comment}"</p>
</div>
))}
</div>
<form onSubmit={submitReview} className="space-y-4 p-8 bg-gray-50 dark:bg-gray-900 rounded-[2.5rem]">
<p className="text-xs font-black uppercase tracking-[0.3em] text-gray-400 text-center">Share Your Thoughts</p>
<div className="flex justify-center gap-2">
{[1,2,3,4,5].map(s => (
<button key={s} type="button" onClick={() => handleStarClick(s)} className={`transition-all hover:scale-125 ${s <= rating ? 'text-yellow-500' : 'text-gray-300'}`}>
<Star size={32} fill={s <= rating ? "currentColor" : "none"} />
</button>
))}
</div>
<textarea value={comment} onChange={e => setComment(e.target.value)} placeholder="How did this turn out?" className="w-full bg-white dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-2xl p-5 text-sm font-medium outline-none focus:border-orange-500 transition-all dark:text-white h-24 resize-none"/>
<Button type="submit" disabled={!rating || !comment} className="w-full h-16 rounded-2xl font-black uppercase text-lg bg-orange-600 shadow-xl shadow-orange-600/30">Submit Review</Button>
</form>
</div>
</div>
</div>
</div>
</div>
)}
{showForm && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/95 backdrop-blur-xl" onClick={() => setShowForm(false)}></div>
<div className="relative w-full max-w-4xl bg-white dark:bg-gray-950 rounded-[3rem] shadow-2xl overflow-hidden border-4 border-gray-900 animate-in zoom-in duration-300">
<header className="p-8 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 flex justify-between items-center">
<div>
<h2 className="text-3xl font-black italic uppercase tracking-tighter">Add Ritual</h2>
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Share heritage or daily tips</p>
</div>
<button onClick={() => setShowForm(false)} className="p-2 hover:bg-red-500/20 text-gray-500 hover:text-red-500 rounded-full transition-all"><X size={32}/></button>
</header>
<form onSubmit={handleSubmit} className="p-8 md:p-12 overflow-y-auto max-h-[75vh] custom-scrollbar">
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
<div className="space-y-8">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-gray-400 tracking-[0.3em] ml-2">Recipe Title</label>
<input required className="w-full bg-gray-50 dark:bg-gray-900 border-2 border-gray-100 dark:border-gray-800 p-5 rounded-2xl focus:border-orange-600 outline-none transition-all font-black text-xl uppercase tracking-tighter italic" value={newRecipe.title} onChange={e => setNewRecipe({...newRecipe, title: e.target.value})} placeholder="e.g. Masala Oats" />
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-gray-400 tracking-[0.3em] ml-2">Summary/History</label>
<textarea required className="w-full bg-gray-50 dark:bg-gray-900 border-2 border-gray-100 dark:border-gray-800 p-5 rounded-2xl focus:border-orange-600 outline-none transition-all font-medium h-32 resize-none" value={newRecipe.description} onChange={e => setNewRecipe({...newRecipe, description: e.target.value})} placeholder="Short summary of the dish" />
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-gray-400 tracking-[0.3em] ml-2">Prep Time (Min)</label>
<input type="number" required className="w-full bg-gray-50 dark:bg-gray-900 border-2 border-gray-100 dark:border-gray-800 p-5 rounded-2xl focus:border-orange-600 outline-none transition-all font-black" value={newRecipe.prepTime} onChange={e => setNewRecipe({...newRecipe, prepTime: parseInt(e.target.value)})} />
</div>
</div>
</div>
<div className="space-y-8">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-gray-400 tracking-[0.3em] ml-2">Image URL</label>
<input
className="w-full bg-gray-50 dark:bg-gray-900 border-2 border-gray-100 dark:border-gray-700 p-5 rounded-2xl focus:border-orange-600 outline-none transition-all font-medium text-sm"
value={newRecipe.imageUrl}
onChange={e => setNewRecipe({...newRecipe, imageUrl: e.target.value})}
placeholder="https://..."
/>
<div className="mt-2 text-[9px] text-gray-500 uppercase text-center">OR</div>
<label className="block w-full h-32 border-4 border-dashed border-gray-100 dark:border-gray-800 rounded-3xl flex flex-col items-center justify-center cursor-pointer hover:border-orange-500 transition-all overflow-hidden relative">
{newRecipe.imageUrl?.startsWith('data:') ? (
<img src={newRecipe.imageUrl} className="w-full h-full object-cover" />
) : (
<div className="text-center">
<Upload className="mx-auto mb-1 text-gray-300" size={24}/>
<p className="text-[10px] font-black uppercase text-gray-400">Upload Photo</p>
</div>
)}
<input type="file" className="hidden" accept="image/*" onChange={handleFileChange} />
</label>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-gray-400 tracking-[0.3em] ml-2">Ingredients (One per line)</label>
<textarea required className="w-full bg-gray-50 dark:bg-gray-900 border-2 border-gray-100 dark:border-gray-800 p-5 rounded-2xl focus:border-orange-600 outline-none transition-all font-mono text-xs h-32 resize-none" value={ingredientsText} onChange={e => setIngredientsText(e.target.value)} placeholder="1 Cup Rice Flour&#10;2 Tbsp Sugar..." />
</div>
</div>
</div>
<div className="mt-10 space-y-2">
<label className="text-[10px] font-black uppercase text-gray-400 tracking-[0.3em] ml-2">Instructions</label>
<textarea required className="w-full bg-gray-50 dark:bg-gray-900 border-2 border-gray-100 dark:border-gray-800 p-8 rounded-[2.5rem] focus:border-orange-600 outline-none transition-all font-medium text-lg leading-relaxed h-64" value={newRecipe.instructions} onChange={e => setNewRecipe({...newRecipe, instructions: e.target.value})} placeholder="How to cook this dish..." />
</div>
<div className="mt-12 flex justify-end gap-6">
<Button type="button" variant="ghost" onClick={() => setShowForm(false)} className="text-gray-500 font-black h-16 px-10">Cancel</Button>
<Button type="submit" className="h-16 px-12 rounded-2xl font-black uppercase text-xl bg-orange-600 shadow-2xl shadow-orange-600/40">Post Recipe</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default Recipes;

230
pages/Rewards.tsx Normal file
View File

@ -0,0 +1,230 @@
import React, { useState, useEffect } from 'react';
import { StorageService } from '../services/storageService';
import { UserProfile } from '../types';
import { Button } from '../components/ui/Button';
import { ShoppingBag, Star, Crown, Heart, TreePine, Dog, Sparkles, Lock, CheckCircle, Loader2, Coins, Palette, Compass, Flame, Zap, Calendar, Gift } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import confetti from 'canvas-confetti';
import { THEME_REGISTRY } from '../config/themes';
interface RewardItem {
id: string;
nameKey: string;
cost: number;
icon: React.FC<any>;
color: string;
category: 'digital' | 'impact' | 'theme' | 'spiritual';
descriptionKey: string;
}
const ITEMS: RewardItem[] = [
{ id: 'frame_gold', nameKey: 'Golden Avatar Frame', cost: 200, icon: Crown, color: 'text-yellow-500', category: 'digital', descriptionKey: 'Stand out with a royal golden border.' },
{ id: 'pack_adventurer', nameKey: 'Adventurer Avatar Pack', cost: 500, icon: Compass, color: 'text-blue-600', category: 'digital', descriptionKey: 'Unlock the RPG-style Adventurer avatar collection.' },
{ id: 'theme_rudra', nameKey: 'Rudra Eternal', cost: 400, icon: Flame, color: 'text-orange-600', category: 'spiritual', descriptionKey: 'Premium dark theme dedicated to Lord Shiva.' },
{ id: 'theme_divine', nameKey: 'Divine Radiance', cost: 450, icon: SunIcon, color: 'text-yellow-400', category: 'spiritual', descriptionKey: 'Glow with celestial light and spiritual energy.' },
{ id: 'theme_buddha', nameKey: "Buddha's Path", cost: 350, icon: Sparkles, color: 'text-red-500', category: 'spiritual', descriptionKey: 'Serene heritage theme with a peaceful aesthetic.' },
{ id: 'theme_cyberpunk', nameKey: 'Cyber Protocol', cost: 400, icon: Zap, color: 'text-pink-500', category: 'theme', descriptionKey: 'Futuristic Tech aesthetic.' },
{ id: 'theme_royal', nameKey: 'Royal Palace', cost: 450, icon: Crown, color: 'text-purple-600', category: 'theme', descriptionKey: 'Gilded palace interior vibes.' },
{ id: 'theme_gold', nameKey: 'Golden Karma', cost: 600, icon: Coins, color: 'text-amber-500', category: 'theme', descriptionKey: 'Solid gold UI for the masters.' },
{ id: 'theme_obsidian', nameKey: 'Black Velvet', cost: 750, icon: Lock, color: 'text-gray-900', category: 'theme', descriptionKey: 'Absolute pitch black premium skin.' },
{ id: 'donate_dog', nameKey: 'Feed a Stray Dog', cost: 100, icon: Dog, color: 'text-orange-600', category: 'impact', descriptionKey: 'We donate Rs. 10 to a local shelter.' },
{ id: 'donate_tree', nameKey: 'Plant a Tree', cost: 250, icon: TreePine, color: 'text-green-600', category: 'impact', descriptionKey: 'Contribute to reforestation projects.' },
{ id: 'donate_orphan', nameKey: 'Donate to Orphanage', cost: 1000, icon: Heart, color: 'text-pink-600', category: 'impact', descriptionKey: 'Provide school supplies for a child.' },
];
function SunIcon(props: any) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>
</svg>
);
}
const Rewards: React.FC = () => {
const { t } = useLanguage();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [redeeming, setRedeeming] = useState<string | null>(null);
const [claimingDaily, setClaimingDaily] = useState(false);
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
setLoading(true);
const p = await StorageService.getProfile();
setProfile(p);
setLoading(false);
};
const handleRedeem = async (item: RewardItem) => {
if (!profile) return;
setRedeeming(item.id);
setTimeout(async () => {
const { success, error } = await StorageService.redeemReward(item.id, item.cost);
if (success) {
setProfile(prev => prev ? { ...prev, points: prev.points - item.cost, unlockedItems: [...(prev.unlockedItems || []), item.id] } : null);
confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 }, colors: ['#FFD700', '#FFA500'] });
window.dispatchEvent(new Event('rudraksha-profile-update'));
} else {
alert(error || t("Insufficient Karma", "Insufficient Karma"));
}
setRedeeming(null);
}, 800);
};
const handleEquipTheme = async (themeId: string) => {
if (!profile) return;
setRedeeming(themeId);
const newTheme = profile.activeTheme === themeId ? 'default' : themeId;
const updated = await StorageService.updateProfile({ activeTheme: newTheme });
setProfile(updated);
setRedeeming(null);
window.dispatchEvent(new Event('rudraksha-profile-update'));
};
const handleDailyClaim = async () => {
setClaimingDaily(true);
const res = await StorageService.claimDailyBonus();
if (res.success) {
confetti({ particleCount: 150, spread: 100, origin: { y: 0.3 } });
const p = await StorageService.getProfile();
setProfile(p);
alert(res.message);
} else {
alert(res.message);
}
setClaimingDaily(false);
};
if (loading) return <div className="flex justify-center p-12"><Loader2 className="animate-spin text-red-600"/></div>;
if (!profile) return null;
return (
<div className="space-y-8 pb-20">
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-black text-gray-900 dark:text-white flex items-center gap-2 tracking-tighter">
<ShoppingBag className="text-red-600" /> {t("Karma Bazaar", "Karma Bazaar")}
</h1>
<p className="text-gray-500 dark:text-gray-400 font-medium">{t("Redeem your hard-earned points for digital goods or social impact.", "Redeem your hard-earned points for digital goods or social impact.")}</p>
</div>
<div className="flex gap-3">
<Button onClick={handleDailyClaim} disabled={claimingDaily} className="bg-gradient-to-r from-yellow-400 to-orange-500 hover:from-yellow-500 hover:to-orange-600 text-white rounded-2xl shadow-xl font-black uppercase text-xs tracking-widest px-6">
{claimingDaily ? <Loader2 className="animate-spin" size={16}/> : <><Gift size={16} className="mr-2"/> Daily Bonus</>}
</Button>
<div className="bg-white dark:bg-gray-800 p-4 rounded-2xl shadow-xl border-b-4 border-red-600 flex items-center gap-4">
<div>
<p className="text-[10px] text-gray-500 uppercase font-black tracking-widest">{t("Balance", "Balance")}</p>
<p className="text-3xl font-black text-red-600 dark:text-red-400 flex items-center gap-2">
<Coins className="fill-yellow-400 text-yellow-500" /> {profile.points}
</p>
</div>
</div>
</div>
</header>
<div className="space-y-12">
<section>
<h2 className="text-xl font-black text-gray-800 dark:text-white mb-6 flex items-center gap-3 uppercase tracking-tighter italic">
<Flame className="text-orange-500" /> Spiritual Aesthetics
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{ITEMS.filter(i => i.category === 'spiritual').map((item, idx) => (
<div key={item.id} className="animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${idx * 100}ms` }}>
<RewardCard item={item} profile={profile} onRedeem={handleRedeem} onEquip={handleEquipTheme} redeeming={redeeming} t={t} />
</div>
))}
</div>
</section>
<section>
<h2 className="text-xl font-black text-gray-800 dark:text-white mb-6 flex items-center gap-3 uppercase tracking-tighter italic">
<Palette className="text-indigo-500" /> Premium Skins
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{ITEMS.filter(i => i.category === 'theme').map((item, idx) => (
<div key={item.id} className="animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${idx * 100}ms` }}>
<RewardCard item={item} profile={profile} onRedeem={handleRedeem} onEquip={handleEquipTheme} redeeming={redeeming} t={t} />
</div>
))}
</div>
</section>
<section>
<h2 className="text-xl font-black text-gray-800 dark:text-white mb-6 flex items-center gap-3 uppercase tracking-tighter italic">
<Sparkles className="text-purple-500" /> {t("Digital Goods", "Digital Goods")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{ITEMS.filter(i => i.category === 'digital').map((item, idx) => (
<div key={item.id} className="animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${idx * 100}ms` }}>
<RewardCard item={item} profile={profile} onRedeem={handleRedeem} onEquip={handleEquipTheme} redeeming={redeeming} t={t} />
</div>
))}
</div>
</section>
<section>
<h2 className="text-xl font-black text-gray-800 dark:text-white mb-6 flex items-center gap-3 uppercase tracking-tighter italic">
<Heart className="text-red-500" /> {t("Social Impact", "Social Impact")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{ITEMS.filter(i => i.category === 'impact').map((item, idx) => (
<div key={item.id} className="animate-in fade-in slide-in-from-bottom-4 duration-500" style={{ animationDelay: `${idx * 100}ms` }}>
<RewardCard item={item} profile={profile} onRedeem={handleRedeem} onEquip={handleEquipTheme} redeeming={redeeming} t={t} />
</div>
))}
</div>
</section>
</div>
</div>
);
};
const RewardCard: React.FC<{ item: RewardItem, profile: UserProfile, onRedeem: (i: RewardItem) => void, onEquip: (id: string) => void, redeeming: string | null, t: any }> = ({ item, profile, onRedeem, onEquip, redeeming, t }) => {
const isOwned = profile.unlockedItems?.includes(item.id);
const canAfford = profile.points >= item.cost;
const isRedeeming = redeeming === item.id;
const isTheme = item.category === 'theme' || item.category === 'spiritual';
const isEquipped = profile.activeTheme === item.id;
return (
<div className={`
relative bg-white dark:bg-gray-800 rounded-[2rem] p-8 shadow-sm border-2 transition-all duration-300 group flex flex-col h-full
${isEquipped ? 'border-indigo-500 ring-4 ring-indigo-500/10' : (isOwned ? 'border-green-100 bg-green-50/20 dark:border-green-900/30' : 'border-gray-100 dark:border-gray-700 hover:shadow-2xl hover:border-red-200 hover:-translate-y-1')}
`}>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-6 transition-transform duration-500 group-hover:scale-110 group-hover:rotate-6 ${isOwned ? 'bg-green-100 text-green-600 shadow-lg' : 'bg-gray-50 dark:bg-gray-700 shadow-sm'} ${item.color}`}>
{isOwned ? <CheckCircle size={28} /> : <item.icon size={28} />}
</div>
<div className="flex-1">
<h3 className="font-black text-xl text-gray-900 dark:text-white mb-2 tracking-tighter uppercase italic">{t(item.nameKey, item.nameKey)}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 font-medium leading-relaxed">{t(item.descriptionKey, item.descriptionKey)}</p>
</div>
<div className="flex items-center justify-between mt-auto pt-6 border-t border-gray-50 dark:border-gray-700">
<span className="font-black text-gray-900 dark:text-white flex items-center gap-1.5">
<Coins size={18} className="text-yellow-500 fill-yellow-400" /> {item.cost}
</span>
{isOwned && isTheme ? (
<Button onClick={() => onEquip(item.id)} disabled={isRedeeming} className={`px-6 rounded-xl font-black uppercase text-xs tracking-widest h-10 ${isEquipped ? 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-lg' : 'bg-gray-800 hover:bg-gray-900 text-white'}`} size="sm">
{isRedeeming ? <Loader2 className="animate-spin" size={16}/> : (isEquipped ? "Applied" : "Equip")}
</Button>
) : isOwned && item.category === 'digital' ? (
<span className="text-xs font-black text-green-600 bg-green-100 px-4 py-2 rounded-xl uppercase tracking-widest">{t("Owned", "Owned")}</span>
) : (
<Button onClick={() => onRedeem(item)} disabled={!canAfford || isRedeeming} className={`px-6 rounded-xl font-black uppercase text-xs tracking-widest h-10 ${isOwned ? 'bg-green-600 hover:bg-green-700' : (canAfford ? 'bg-red-600 hover:bg-red-700 shadow-lg' : 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed text-gray-400')}`} size="sm">
{isRedeeming ? <Loader2 className="animate-spin" size={16}/> : (isOwned ? t("Buy Again", "Buy Again") : t("Unlock", "Unlock"))}
</Button>
)}
</div>
</div>
);
};
export default Rewards;

782
pages/Safety.tsx Normal file
View File

@ -0,0 +1,782 @@
import React, { useState, useEffect } from 'react';
import {
ShieldAlert, Radio, AlertTriangle, CheckCircle, MapPin, Eye,
Activity, ClipboardCheck, History, Search, Zap, OctagonX,
Check as CheckIcon, Image as ImageIcon, X, Sparkles, Coins, Trash2, Clock, QrCode, Download, Share2, ShieldCheck, Printer, Camera, Upload, ArrowUpRight, Loader2
} from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { Button } from '../components/ui/Button';
import { matchEmergencyReports } from '../services/geminiService';
import { StorageService } from '../services/storageService';
import { FTLStorageService } from '../services/ftl/storage';
import { FTLMission, Sighting, UserProfile } from '../types';
import confetti from 'canvas-confetti';
const FTLLogo = () => (
<div className="relative w-48 h-48 md:w-64 md:h-64 flex items-center justify-center">
<div className="absolute inset-0 bg-red-600/10 rounded-full animate-ping [animation-duration:3s]"></div>
<div className="absolute inset-4 border-2 border-red-500/20 rounded-full animate-pulse"></div>
<div className="absolute inset-8 border-4 border-red-500/10 rounded-full animate-[spin_10s_linear_infinite]"></div>
<div className="relative w-32 h-32 md:w-40 md:h-40 bg-gradient-to-br from-red-600 to-red-900 rounded-full shadow-[0_0_50px_rgba(220,38,38,0.5)] border-4 border-white/20 flex flex-col items-center justify-center transform transition-transform hover:scale-110 duration-500 cursor-pointer group">
<span className="text-4xl md:text-5xl font-black tracking-tighter text-white group-hover:tracking-widest transition-all duration-500">FTL</span>
<div className="flex gap-1 mt-1">
<div className="w-1 h-1 bg-red-200 rounded-full animate-bounce"></div>
<div className="w-1 h-1 bg-red-200 rounded-full animate-bounce [animation-delay:0.2s]"></div>
<div className="w-1 h-1 bg-red-200 rounded-full animate-bounce [animation-delay:0.4s]"></div>
</div>
</div>
</div>
);
const QRGenerator = ({ onClose }: { onClose: () => void }) => {
const [qrData, setQrData] = useState({ name: '', contact: '', info: '', photo: '' });
const [step, setStep] = useState(1);
const [isGenerating, setIsGenerating] = useState(false);
// Using a public API for QR generation to ensure we have a valid image source for canvas
const getQRUrl = (data: string) => `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(data)}&bgcolor=ffffff`;
const handlePhoto = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => setQrData(p => ({ ...p, photo: ev.target?.result as string }));
reader.readAsDataURL(file);
}
};
const generateSmartTag = async () => {
setIsGenerating(true);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Card Dimensions
const width = 600;
const height = 900;
canvas.width = width;
canvas.height = height;
// 1. Background
const grd = ctx.createLinearGradient(0, 0, 0, height);
grd.addColorStop(0, '#ffffff');
grd.addColorStop(1, '#f8fafc');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, width, height);
// 2. Header Color (Red)
ctx.fillStyle = '#dc2626';
ctx.fillRect(0, 0, width, 20);
// 3. User Photo (Circular)
if (qrData.photo) {
const img = new Image();
img.src = qrData.photo;
await new Promise((resolve) => { img.onload = resolve; });
ctx.save();
ctx.beginPath();
ctx.arc(width/2, 200, 120, 0, Math.PI * 2, true);
ctx.closePath();
ctx.clip();
ctx.drawImage(img, width/2 - 120, 80, 240, 240);
// Border
ctx.lineWidth = 10;
ctx.strokeStyle = '#dc2626';
ctx.stroke();
ctx.restore();
}
// 4. Text Info
ctx.textAlign = 'center';
// Name
ctx.fillStyle = '#0f172a';
ctx.font = 'bold 50px Inter, sans-serif';
ctx.fillText(qrData.name.toUpperCase(), width/2, 380);
// Label
ctx.fillStyle = '#dc2626';
ctx.font = 'bold 24px Inter, sans-serif';
ctx.fillText('EMERGENCY RESCUE TAG', width/2, 420);
// Info Box
ctx.fillStyle = '#f1f5f9';
ctx.beginPath();
ctx.roundRect(50, 450, 500, 100, 20);
ctx.fill();
ctx.fillStyle = '#475569';
ctx.font = 'italic 24px Inter, sans-serif';
// Simple text wrapping for info (first line only for simplicity in this demo)
const infoText = qrData.info.length > 35 ? qrData.info.substring(0, 35) + '...' : qrData.info;
ctx.fillText(`"${infoText}"`, width/2, 510);
// 5. QR Code
const qrJson = JSON.stringify({
n: qrData.name,
c: qrData.contact,
i: qrData.info,
app: 'Rudraksha'
});
const qrImg = new Image();
qrImg.crossOrigin = "Anonymous"; // Crucial for external images on canvas
qrImg.src = getQRUrl(qrJson);
await new Promise((resolve, reject) => {
qrImg.onload = resolve;
qrImg.onerror = resolve; // Continue even if QR fails (though it shouldn't)
});
ctx.drawImage(qrImg, width/2 - 100, 600, 200, 200);
// 6. Footer
ctx.fillStyle = '#0f172a';
ctx.font = 'bold 30px Inter, sans-serif';
ctx.fillText(qrData.contact, width/2, 850);
// Convert to Download
const link = document.createElement('a');
link.download = `Rudraksha-Tag-${qrData.name}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
setIsGenerating(false);
confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
};
return (
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4 bg-black/95 backdrop-blur-xl animate-in fade-in duration-300">
<div className="relative w-full max-w-2xl bg-white dark:bg-gray-900 rounded-[3rem] shadow-[0_50px_100px_rgba(0,0,0,0.8)] overflow-hidden border-4 border-gray-100 dark:border-gray-800 animate-in zoom-in duration-300 flex flex-col max-h-[90vh]">
<header className="p-8 border-b dark:border-gray-800 flex justify-between items-center shrink-0 bg-gray-50 dark:bg-gray-950">
<div className="flex items-center gap-4">
<div className="p-3 bg-red-600 text-white rounded-2xl shadow-xl"><QrCode size={28}/></div>
<div>
<h2 className="text-2xl font-black uppercase italic tracking-tighter dark:text-white">Rescue Tag Studio</h2>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Protocol: QR Identification Generator</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 transition-colors"><X size={32}/></button>
</header>
<div className="flex-1 overflow-y-auto p-10 space-y-10 custom-scrollbar">
{step === 1 ? (
<div className="space-y-8 animate-in slide-in-from-right-4 duration-500">
<div className="bg-gray-50 dark:bg-gray-900 border-4 border-dashed border-gray-200 dark:border-gray-800 rounded-[2.5rem] h-56 flex flex-col items-center justify-center relative group overflow-hidden transition-all hover:border-red-500/50">
{qrData.photo ? (
<>
<img src={qrData.photo} className="w-full h-full object-cover" alt="" />
<button onClick={() => setQrData(p => ({...p, photo: ''}))} className="absolute top-4 right-4 p-2 bg-black/60 text-white rounded-full"><X size={16}/></button>
</>
) : (
<label className="flex flex-col items-center gap-4 cursor-pointer text-gray-400 hover:text-red-500 transition-colors">
<Camera size={48}/>
<span className="font-black uppercase text-xs tracking-widest">Upload Subject Photo</span>
<input type="file" className="hidden" accept="image/*" onChange={handlePhoto}/>
</label>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Name / Identity</label>
<input className="w-full h-14 px-6 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-red-500 rounded-2xl outline-none font-bold dark:text-white" value={qrData.name} onChange={e => setQrData(p => ({...p, name: e.target.value}))} placeholder="e.g. Bruno (Dog)" />
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Secure Contact</label>
<input className="w-full h-14 px-6 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-red-500 rounded-2xl outline-none font-bold dark:text-white" value={qrData.contact} onChange={e => setQrData(p => ({...p, contact: e.target.value}))} placeholder="Phone or Username" />
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-gray-500 tracking-widest ml-2">Critical Info / Medical</label>
<textarea className="w-full h-32 p-6 bg-gray-50 dark:bg-gray-800 border-2 border-transparent focus:border-red-500 rounded-2xl outline-none font-medium dark:text-white resize-none" value={qrData.info} onChange={e => setQrData(p => ({...p, info: e.target.value}))} placeholder="Allergies, rewards, or specific behavioral traits..." />
</div>
<Button onClick={() => setStep(2)} disabled={!qrData.name || !qrData.photo} className="w-full h-18 bg-red-600 hover:bg-red-700 text-lg font-black uppercase italic shadow-2xl shadow-red-600/30">
Generate Digital Tag
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-10 animate-in zoom-in duration-500">
<div className="w-full max-w-sm bg-white dark:bg-black rounded-[3rem] border-[12px] border-red-600 p-8 shadow-[0_30px_90px_rgba(220,38,38,0.4)] flex flex-col items-center relative group overflow-hidden" id="tag-preview">
<div className="absolute top-0 right-0 p-6 opacity-5"><QrCode size={120}/></div>
<div className="w-32 h-32 rounded-3xl overflow-hidden mb-6 shadow-xl border-4 border-gray-100 dark:border-gray-800 shrink-0">
<img src={qrData.photo} className="w-full h-full object-cover" alt="" />
</div>
<h3 className="text-3xl font-black uppercase italic tracking-tighter text-gray-900 dark:text-white leading-none text-center mb-2">{qrData.name}</h3>
<p className="text-[10px] font-black uppercase tracking-widest text-red-600 mb-8 bg-red-50 dark:bg-red-900/30 px-3 py-1 rounded-full">Secure Rescue ID</p>
<div className="w-full space-y-4 border-y border-gray-100 dark:border-gray-700 py-6 mb-8">
<div className="flex items-center gap-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg text-gray-500"><Activity size={16}/></div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 line-clamp-2 italic">"{qrData.info}"</p>
</div>
<div className="flex items-center gap-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg text-gray-500"><ShieldCheck size={16}/></div>
<p className="text-xs font-black text-gray-900 dark:text-white uppercase tracking-wider">{qrData.contact}</p>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-3xl border-2 border-gray-100 dark:border-gray-700 flex flex-col items-center gap-2">
<img
src={getQRUrl(JSON.stringify({n: qrData.name, c: qrData.contact, i: qrData.info}))}
alt="QR"
className="w-24 h-24 mix-blend-multiply dark:mix-blend-normal"
/>
<span className="text-[8px] font-bold text-gray-400 uppercase tracking-[0.4em]">SCAN VIA RUDRAKSHA</span>
</div>
</div>
<div className="flex gap-4 w-full">
<Button onClick={generateSmartTag} disabled={isGenerating} className="flex-1 h-16 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs shadow-xl shadow-indigo-600/20 flex items-center justify-center gap-2">
{isGenerating ? <Loader2 className="animate-spin"/> : <Download size={18}/>}
Download Tag
</Button>
</div>
<button onClick={() => setStep(1)} className="text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-red-500 transition-colors">Modify Identification</button>
</div>
)}
</div>
</div>
</div>
);
};
const Safety: React.FC = () => {
const { t } = useLanguage();
const [activeTab, setActiveTab] = useState<'feed' | 'my-logs' | 'vault'>('feed');
const [showFTLModal, setShowFTLModal] = useState(false);
const [showQRModal, setShowQRModal] = useState(false);
const [missions, setMissions] = useState<FTLMission[]>([]);
const [selectedMission, setSelectedMission] = useState<FTLMission | null>(null);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [reportMode, setReportMode] = useState<'lost' | 'found' | 'sighting'>('lost');
const [reportType, setReportType] = useState<'pet' | 'person' | 'object' | 'ai' | null>(null);
const [targetMissionId, setTargetMissionId] = useState<string | null>(null);
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null);
const [formData, setFormData] = useState({ name: '', description: '', location: '', contact: '' });
const [bountyAmount, setBountyAmount] = useState<string>('');
const [potentialMatch, setPotentialMatch] = useState<{ mission: FTLMission, reasoning: string } | null>(null);
const [isCheckingMatch, setIsCheckingMatch] = useState(false);
useEffect(() => {
loadData();
const handleUpdate = () => loadData();
const handleDraft = (e: any) => {
const { status, category, description, location } = e.detail;
setReportMode(status);
setReportType(category);
setFormData(prev => ({
...prev,
description: description || '',
location: location || '',
name: description ? description.split(' ').slice(0, 2).join(' ') : ''
}));
setShowFTLModal(true);
};
window.addEventListener('rudraksha-ftl-update', handleUpdate);
window.addEventListener('rudraksha-ftl-draft', handleDraft);
return () => {
window.removeEventListener('rudraksha-ftl-update', handleUpdate);
window.removeEventListener('rudraksha-ftl-draft', handleDraft);
};
}, []);
const loadData = async () => {
const m = await FTLStorageService.getMissions();
const p = await StorageService.getProfile();
setMissions(m.sort((a, b) => b.timestamp - a.timestamp));
setProfile(p);
};
const handleFTLClick = (mode: 'lost' | 'found' | 'sighting' = 'lost', missionId?: string) => {
setReportMode(mode);
setReportType(null);
setTargetMissionId(missionId || null);
setIsSubmitted(false);
setIsBroadcasting(false);
setSelectedPhoto(null);
setPotentialMatch(null);
setFormData({ name: '', description: '', location: '', contact: '' });
setBountyAmount('');
setShowFTLModal(true);
};
const handleReportSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!profile) return;
const numericBounty = parseInt(bountyAmount) || 0;
if (reportMode === 'lost' && numericBounty > profile.points) {
alert("Insufficient Karma points for this reward.");
return;
}
setIsBroadcasting(true);
setTimeout(async () => {
if (reportMode === 'found') {
setIsCheckingMatch(true);
const matchResult = await matchEmergencyReports(formData.description, missions);
if (matchResult.matchId && matchResult.confidence > 60) {
const matched = missions.find(m => m.id === matchResult.matchId);
if (matched) setPotentialMatch({ mission: matched, reasoning: matchResult.reasoning });
}
setIsCheckingMatch(false);
}
if (reportMode === 'sighting' && targetMissionId) {
const sighting: Sighting = {
id: 's' + Date.now(),
userId: profile.id,
userName: profile.name,
time: 'Just now',
location: formData.location,
info: formData.description,
image: selectedPhoto || undefined,
timestamp: Date.now()
};
await FTLStorageService.addSighting(targetMissionId, sighting);
await StorageService.addPoints(50, 250, 'reward', `Valid FTL Sighting Logged`);
confetti({ particleCount: 80, origin: { y: 0.7 }, colors: ['#3b82f6'] });
} else {
const newMission: FTLMission = {
id: 'm' + Date.now(),
type: reportType === 'ai' ? 'person' : (reportType || 'pet'),
title: `${reportMode === 'lost' ? 'Lost' : 'Found'}: ${formData.name}`,
location: formData.location,
time: 'Just now',
status: 'active',
bounty: reportMode === 'lost' ? numericBounty : 0,
description: formData.description,
sightings: [],
image: selectedPhoto || 'https://images.unsplash.com/photo-1541339907198-e08756eaa539?q=80&w=400&auto=format&fit=crop',
userId: profile.id,
isLost: reportMode === 'lost',
timestamp: Date.now()
};
await FTLStorageService.saveMission(newMission);
if (newMission.bounty > 0) {
await StorageService.updateProfile({ points: profile.points - newMission.bounty });
}
if (reportMode === 'found') {
await StorageService.addPoints(100, 500, 'reward', `Reported Found Item: ${formData.name}`);
confetti({ particleCount: 150, origin: { y: 0.7 }, colors: ['#10b981', '#facc15'] });
}
}
await loadData();
setIsBroadcasting(false);
setIsSubmitted(true);
}, 2000);
};
const handleVerifySighting = async (missionId: string, sightingId: string, verified: boolean) => {
if (verified && selectedMission?.bounty && selectedMission.bounty > 0) {
if (confirm(`Confirm ${selectedMission.bounty} Karma Bounty to this finder? Action is irreversible.`)) {
const sighterId = selectedMission.sightings.find(s => s.id === sightingId)?.userId;
await FTLStorageService.verifySighting(missionId, sightingId, true);
await FTLStorageService.resolveMission(missionId);
if (sighterId) {
await StorageService.rewardUser(sighterId, selectedMission.bounty);
}
confetti({ particleCount: 150, spread: 100, colors: ['#fbbf24'] });
window.dispatchEvent(new Event('rudraksha-profile-update'));
alert("Reward Issued! Neural Ledger Updated.");
setSelectedMission(null);
await loadData();
return;
}
} else {
await FTLStorageService.verifySighting(missionId, sightingId, verified);
}
await loadData();
if (verified) {
confetti({ particleCount: 40, colors: ['#10b981'] });
setTimeout(async () => {
const reloaded = await FTLStorageService.getMissions();
const fresh = reloaded.find(m => m.id === missionId);
if (fresh) setSelectedMission({ ...fresh });
}, 200);
}
};
const handleResolveMission = async (missionId: string) => {
if (confirm("Confirm recovery? This mission will be marked as resolved.")) {
await FTLStorageService.resolveMission(missionId);
confetti({ particleCount: 150, origin: { y: 0.6 }, colors: ['#fbbf24', '#ef4444'] });
setSelectedMission(null);
await loadData();
}
};
const handleDeleteMission = async (mission: FTLMission) => {
if (confirm("Delete this listing? If you set a bounty, it will be refunded.")) {
await FTLStorageService.deleteMission(mission.id);
setSelectedMission(null);
await loadData();
}
};
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => setSelectedPhoto(reader.result as string);
reader.readAsDataURL(file);
}
};
return (
<div className="space-y-8 pb-20 font-sans">
<section className="relative rounded-[3rem] overflow-hidden bg-black text-white shadow-2xl border-4 border-gray-900 group">
<div className="absolute inset-0 opacity-10 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')]"></div>
<div className="absolute inset-0 bg-gradient-to-br from-red-600/20 via-transparent to-indigo-600/10"></div>
<div className="relative z-10 p-8 md:p-14 flex flex-col lg:flex-row items-center gap-12">
<div className="flex-1 text-center lg:text-left space-y-6">
<div className="inline-flex items-center gap-2 bg-red-600/20 border border-red-500/30 px-4 py-2 rounded-full text-xs font-black uppercase tracking-widest text-red-400 animate-pulse">
<Radio size={14} className="animate-bounce" /> Emergency Network Active
</div>
<h1 className="text-5xl md:text-8xl font-black tracking-tighter leading-none italic">
FIND THE <br/> <span className="text-red-600">LOST</span>
</h1>
<p className="max-w-xl text-gray-400 text-lg font-medium leading-relaxed">
{t("Rudraksha's specialized neighborhood response protocol. Support your community, report sightings, and earn Karma rewards for every verified recovery.", "Rudraksha's specialized neighborhood response protocol. Support your community, report sightings, and earn Karma rewards for every verified recovery.")}
</p>
<div className="flex flex-wrap justify-center lg:justify-start gap-4">
<Button onClick={() => handleFTLClick('lost')} className="bg-red-600 hover:bg-red-700 h-16 px-10 rounded-2xl text-xl font-black shadow-2xl shadow-red-600/40">
<Search size={24} className="mr-2"/> {t("Report Loss", "Report Loss")}
</Button>
<button onClick={() => setShowQRModal(true)} className="h-16 px-10 rounded-2xl border-2 border-indigo-600/30 bg-indigo-50/5 hover:bg-indigo-500/10 text-indigo-400 font-black text-lg transition-all flex items-center gap-3 group">
<QrCode size={22} className="group-hover:rotate-90 transition-transform" />
{t("Generate Rescue Tag", "Generate Rescue Tag")}
</button>
</div>
</div>
<div className="shrink-0 flex flex-col items-center gap-4" onClick={() => handleFTLClick('sighting')}>
<FTLLogo />
<p className="text-xs font-black uppercase tracking-[0.4em] text-red-500 animate-pulse">Tap Radar to Log Sighting</p>
</div>
</div>
</section>
<div className="flex flex-wrap justify-center md:justify-start gap-2 p-2 bg-white/50 dark:bg-gray-800/50 backdrop-blur-xl rounded-[2.5rem] border border-gray-100 dark:border-gray-700 w-fit">
{[
{ id: 'feed', label: 'Active Feed', icon: Activity, color: 'text-red-500' },
{ id: 'my-logs', label: 'My Rescue Logs', icon: ClipboardCheck, color: 'text-indigo-500' },
{ id: 'vault', label: 'Community Vault', icon: History, color: 'text-amber-500' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-3 px-6 py-3 rounded-full text-sm font-black transition-all ${activeTab === tab.id ? 'bg-white dark:bg-gray-700 shadow-xl text-gray-900 dark:text-white' : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}`}
>
<tab.icon size={18} className={tab.color} /> {t(tab.label, tab.label)}
</button>
))}
</div>
<div className="space-y-6">
{activeTab === 'feed' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in fade-in slide-in-from-left-4 duration-500">
{missions.filter(m => m.status === 'active').map(mission => (
<div key={mission.id} className="group bg-white dark:bg-gray-800 rounded-[2.5rem] p-6 border-2 border-gray-50 dark:border-gray-700 hover:border-red-500 shadow-sm hover:shadow-2xl transition-all flex flex-col gap-6 relative overflow-hidden">
<div onClick={() => setSelectedMission(mission)} className="w-full h-64 rounded-[2rem] overflow-hidden shrink-0 shadow-lg relative cursor-pointer">
<img src={mission.image} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-1000" alt={mission.title}/>
<div className="absolute top-4 left-4 bg-red-600 text-white px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-[0.2em] shadow-lg">Live Mission</div>
{mission.bounty > 0 && (
<div className="absolute bottom-4 right-4 bg-yellow-400 text-black px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider shadow-xl flex items-center gap-2">
<Coins size={14}/> Bounty: {mission.bounty}
</div>
)}
</div>
<div className="flex-1 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest ${mission.type === 'pet' ? 'bg-green-100 text-green-600' : 'bg-blue-100 text-blue-600'}`}>{mission.type}</span>
<span className="text-xs text-gray-400 font-bold flex items-center gap-1"><Clock size={12}/> {mission.time}</span>
</div>
<button onClick={() => handleFTLClick('sighting', mission.id)} className="bg-blue-600 hover:bg-blue-700 text-white p-2.5 rounded-2xl shadow-lg transition-transform active:scale-95 flex items-center gap-2 px-4">
<Eye size={18} /> <span className="text-xs font-black uppercase">Log Sighting</span>
</button>
</div>
<h3 className="text-3xl font-black tracking-tight text-gray-900 dark:text-white leading-none cursor-pointer" onClick={() => setSelectedMission(mission)}>{mission.title}</h3>
<p className="text-gray-500 dark:text-gray-400 line-clamp-2 text-lg leading-relaxed">{mission.description}</p>
</div>
</div>
))}
</div>
)}
{activeTab === 'my-logs' && (
<div className="bg-white dark:bg-gray-800 rounded-[2.5rem] shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden">
<div className="p-10 border-b border-gray-100 dark:border-gray-700 flex justify-between items-center bg-gray-50/50 dark:bg-gray-950/30">
<h2 className="text-xl font-black uppercase tracking-widest italic">My Broadcast History</h2>
<span className="text-xs font-bold text-gray-400">{missions.filter(m => m.userId === profile?.id).length} Entries</span>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{missions.filter(m => m.userId === profile?.id).length === 0 ? (
<div className="p-20 text-center text-gray-400">
<ClipboardCheck size={48} className="mx-auto mb-4 opacity-20"/>
<p className="font-bold uppercase">No loss reports broadcasted by you.</p>
</div>
) : (
missions.filter(m => m.userId === profile?.id).map(mission => (
<div key={mission.id} className="p-8 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group cursor-pointer" onClick={() => setSelectedMission(mission)}>
<div className="flex items-center gap-6">
<div className="w-16 h-16 rounded-2xl overflow-hidden border-2 border-white dark:border-gray-700 shadow-md">
<img src={mission.image} className="w-full h-full object-cover" />
</div>
<div>
<h3 className="font-black uppercase italic tracking-tighter text-lg">{mission.title}</h3>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest">{mission.status} {mission.sightings.length} Sightings</p>
</div>
</div>
<button className="p-3 rounded-2xl bg-gray-100 dark:bg-gray-800 text-gray-400 group-hover:bg-indigo-600 group-hover:text-white transition-all"><ArrowUpRight size={20}/></button>
</div>
))
)}
</div>
</div>
)}
</div>
{selectedMission && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/90 backdrop-blur-md" onClick={() => setSelectedMission(null)}></div>
<div className="relative w-full max-w-4xl bg-white dark:bg-gray-950 rounded-[3rem] shadow-2xl overflow-hidden animate-in zoom-in duration-300 flex flex-col md:flex-row max-h-[90vh]">
<div className="md:w-1/2 relative h-64 md:h-auto">
<img src={selectedMission.image} className="w-full h-full object-cover" alt="" />
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent"></div>
<div className="absolute bottom-8 left-8 right-8 text-white">
{selectedMission.bounty > 0 && (
<div className="inline-flex items-center gap-2 bg-yellow-500 text-black px-4 py-2 rounded-xl font-black uppercase text-xs tracking-widest mb-4 shadow-lg animate-pulse">
<Coins size={16}/> Reward: {selectedMission.bounty} Karma
</div>
)}
<h2 className="text-4xl font-black uppercase italic tracking-tighter drop-shadow-lg">{selectedMission.title}</h2>
<p className="text-lg opacity-80 mt-2 flex items-center gap-2"><MapPin size={18}/> {selectedMission.location}</p>
</div>
</div>
<div className="md:w-1/2 p-8 md:p-12 overflow-y-auto flex flex-col">
<div className="flex justify-between items-start mb-8">
<div className="flex items-center gap-3">
<div className="p-3 bg-red-100 dark:bg-red-900/40 rounded-2xl text-red-600"><AlertTriangle size={24}/></div>
<div>
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest">Mission Status</p>
<p className="text-xl font-black uppercase tracking-tighter text-red-600 italic">{selectedMission.status}</p>
</div>
</div>
<button onClick={() => setSelectedMission(null)} className="p-3 bg-gray-100 dark:bg-gray-800 rounded-full hover:bg-red-500 hover:text-white transition-all"><X size={24}/></button>
</div>
<div className="flex-1 space-y-8">
<section>
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400 mb-2">Detailed Protocol</h3>
<p className="text-lg text-gray-700 dark:text-gray-300 leading-relaxed font-medium italic">"{selectedMission.description}"</p>
</section>
<section>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400">Community Sightings</h3>
<div className="flex gap-2">
<button onClick={() => handleFTLClick('sighting', selectedMission.id)} className="bg-blue-600 text-white px-3 py-1 rounded-lg text-[10px] font-black uppercase hover:bg-blue-700 transition-colors">
+ Add Info
</button>
<span className="bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 px-2 py-0.5 rounded text-[10px] font-black flex items-center">{selectedMission.sightings.length}</span>
</div>
</div>
<div className="space-y-4">
{selectedMission.sightings.map(s => (
<div key={s.id} className={`p-5 rounded-2xl border transition-all ${(s as any).isVerified ? 'bg-green-50 dark:bg-green-900/10 border-green-200 shadow-lg' : 'bg-gray-50 dark:bg-gray-900 border-gray-100'}`}>
<div className="flex justify-between items-start mb-2">
<div>
<p className="text-sm font-black dark:text-white flex items-center gap-2">
<MapPin size={14} className="text-blue-500"/> {s.location}
</p>
<p className="text-[10px] text-gray-400 font-bold mt-0.5">By {s.userName || 'Anonymous'}</p>
</div>
{(s as any).isVerified && <span className="flex items-center gap-1 bg-green-500 text-white px-2 py-0.5 rounded-full text-[9px] font-black uppercase"><CheckIcon size={10}/> Verified</span>}
</div>
<p className="text-xs text-gray-500 mb-3 leading-relaxed">{s.info}</p>
<div className="flex justify-between items-center text-[9px] font-bold text-gray-400">
<span>{s.time}</span>
{selectedMission.userId === profile?.id && !(s as any).isVerified && (
<div className="flex gap-3">
<button onClick={() => handleVerifySighting(selectedMission.id, s.id, true)} className="flex items-center gap-1 text-green-500 hover:text-green-600 font-black uppercase tracking-widest bg-green-50 dark:bg-green-950 p-2 rounded-xl border border-green-200 transition-all active:scale-95">
{selectedMission.bounty > 0 ? "Confirm & Reward" : "Confirm"} <CheckCircle size={12}/>
</button>
</div>
)}
</div>
</div>
))}
</div>
</section>
</div>
{selectedMission.userId === profile?.id && (
<div className="mt-10 pt-8 border-t border-gray-100 dark:border-gray-800 flex gap-4">
{selectedMission.status === 'active' && (
<Button onClick={() => handleResolveMission(selectedMission.id)} className="flex-1 h-14 bg-green-600 hover:bg-green-700 text-lg font-black uppercase italic shadow-2xl shadow-green-600/30">
Mark Resolved
</Button>
)}
<Button onClick={() => handleDeleteMission(selectedMission)} variant="ghost" className="h-14 w-14 rounded-2xl bg-red-50 text-red-500 hover:bg-red-100 hover:text-red-600">
<Trash2 size={24}/>
</Button>
</div>
)}
</div>
</div>
</div>
)}
{showFTLModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/95 backdrop-blur-xl" onClick={() => setShowFTLModal(false)}></div>
<div className="relative w-full max-w-3xl bg-[#0a0a0a] text-white rounded-[3rem] shadow-2xl overflow-hidden border border-gray-800 animate-in zoom-in duration-300 max-h-[95vh] flex flex-col">
<header className="p-8 md:p-10 border-b border-gray-800 flex justify-between items-center bg-[#111]">
<div className="flex items-center gap-6">
<div className={`w-16 h-16 rounded-[1.5rem] flex items-center justify-center shadow-2xl ${reportMode === 'lost' ? 'bg-red-600/20 text-red-500 border border-red-500/30' : reportMode === 'found' ? 'bg-green-600/20 text-green-500 border border-green-500/30' : 'bg-blue-600/20 text-blue-500 border border-blue-500/30'}`}>
{reportMode === 'lost' ? <AlertTriangle size={32} /> : reportMode === 'found' ? <CheckCircle size={32}/> : <Eye size={32}/>}
</div>
<div>
<h2 className="text-3xl font-black uppercase italic tracking-tighter">MISSION COMMAND</h2>
<p className="text-[10px] font-black text-gray-500 uppercase tracking-widest opacity-70">Protocol: {reportMode} entry</p>
</div>
</div>
<button onClick={() => setShowFTLModal(false)} className="p-3 hover:bg-red-500/20 text-gray-500 hover:text-red-500 rounded-full transition-all active:scale-95"><X size={32}/></button>
</header>
<div className="flex-1 overflow-y-auto p-8 md:p-14 custom-scrollbar">
{!reportType && reportMode !== 'sighting' ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{['person', 'pet', 'object', 'ai'].map(type => (
<button key={type} onClick={() => setReportType(type as any)} className="bg-[#1a1a1a] p-10 rounded-[2.5rem] border-2 border-gray-800 hover:border-blue-600 transition-all text-left">
<span className="text-3xl font-black uppercase italic mb-2 block">{type}</span>
<p className="text-sm text-gray-500">Report {reportMode} {type}.</p>
</button>
))}
</div>
) : isBroadcasting ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-40 h-40 border-8 border-red-600 border-t-transparent rounded-full animate-spin mb-10"></div>
<h3 className="text-5xl font-black uppercase tracking-tighter italic">BROADCASTING...</h3>
</div>
) : isCheckingMatch ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-32 h-32 bg-blue-500/10 rounded-full flex items-center justify-center mb-10 animate-pulse">
<Sparkles size={64} className="text-blue-500"/>
</div>
<h3 className="text-4xl font-black uppercase italic text-blue-500">AI GUARDIAN SCANNING...</h3>
<p className="text-gray-500 mt-4">Comparing your description with existing lost reports.</p>
</div>
) : isSubmitted ? (
<div className="text-center py-20">
{potentialMatch ? (
<div className="space-y-8 animate-in zoom-in duration-500">
<div className="p-8 bg-blue-500/10 border-4 border-blue-500 rounded-[3rem] shadow-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10"><Sparkles size={100}/></div>
<h3 className="text-4xl font-black uppercase italic text-blue-500 mb-6 flex items-center justify-center gap-3">
<Zap className="animate-bounce" /> Match Detected!
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-left">
<div className="p-5 bg-white/5 rounded-2xl">
<p className="text-[10px] uppercase font-black text-gray-500 mb-2">Your Found Item</p>
<p className="text-lg font-bold leading-tight">"{formData.description}"</p>
</div>
<div className="p-5 bg-blue-500/20 rounded-2xl border border-blue-500/30">
<p className="text-[10px] uppercase font-black text-blue-300 mb-2">Matched Lost Report</p>
<p className="text-lg font-black text-white italic">"{potentialMatch.mission.title}"</p>
<p className="text-sm text-gray-300 mt-2 line-clamp-2">"{potentialMatch.mission.description}"</p>
</div>
</div>
<div className="mt-8 pt-6 border-t border-white/10">
<p className="text-sm font-bold text-blue-300 italic mb-6">"AI Logic: {potentialMatch.reasoning}"</p>
<Button className="w-full h-16 text-xl bg-blue-600" onClick={() => setSelectedMission(potentialMatch.mission)}>Contact Owner Immediately</Button>
</div>
</div>
</div>
) : (
<div className="animate-in zoom-in duration-300">
<CheckCircle size={80} className="text-green-500 mx-auto mb-10" />
<h3 className="text-6xl font-black uppercase italic mb-4">LOGGED</h3>
<Button onClick={() => setShowFTLModal(false)} className="w-full h-20 text-2xl font-black">RETURN</Button>
</div>
)}
</div>
) : (
<form onSubmit={handleReportSubmit} className="space-y-8">
<div className="bg-[#1a1a1a] border-4 border-dashed border-gray-800 rounded-[2.5rem] p-8 text-center group hover:border-red-600/40 transition-colors mb-4">
{!selectedPhoto ? (
<label className="flex flex-col items-center justify-center cursor-pointer">
<ImageIcon size={48} className="text-gray-500 mb-4" />
<h3 className="font-black text-xl mb-1 uppercase italic">Attach Photo</h3>
<input type="file" className="hidden" accept="image/*" onChange={handlePhotoChange} />
</label>
) : (
<div className="relative">
<img src={selectedPhoto} className="w-full h-48 object-cover rounded-2xl" />
<button type="button" onClick={() => setSelectedPhoto(null)} className="absolute top-2 right-2 bg-black/60 p-1.5 rounded-full"><X size={16}/></button>
</div>
)}
</div>
{reportMode === 'sighting' ? (
<div className="p-4 bg-blue-900/20 border border-blue-500/30 rounded-2xl text-center mb-4">
<p className="text-sm font-bold text-blue-400 uppercase tracking-wide">Logging Sighting for Mission</p>
</div>
) : (
<input required className="w-full bg-[#111] border-2 border-gray-800 p-5 rounded-[1.5rem] focus:border-red-600 outline-none font-bold text-lg text-white" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} placeholder="Title / Subject Name" />
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input required className="w-full bg-[#111] border-2 border-gray-800 p-5 rounded-[1.5rem] focus:border-red-600 outline-none font-bold text-lg text-white" value={formData.location} onChange={e => setFormData({...formData, location: e.target.value})} placeholder="Location (e.g. Biratnagar Center)" />
<input className="w-full bg-[#111] border-2 border-gray-800 p-5 rounded-[1.5rem] focus:border-red-600 outline-none font-bold text-lg text-white" value={formData.contact} onChange={e => setFormData({...formData, contact: e.target.value})} placeholder="Contact Info (Optional)" />
</div>
{reportMode === 'lost' && (
<div className="bg-[#111] border-2 border-gray-800 p-6 rounded-[2rem] space-y-3">
<div className="flex justify-between items-center">
<label className="font-black uppercase tracking-widest text-xs text-yellow-500 flex items-center gap-2"><Coins size={14}/> Bounty Reward</label>
<span className="text-xs font-bold text-gray-500 uppercase">Avail: {profile?.points || 0} pts</span>
</div>
<input
type="number"
min="0"
max={profile?.points || 0}
step="1"
value={bountyAmount}
onChange={(e) => setBountyAmount(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 p-4 rounded-xl text-yellow-400 font-mono font-bold text-2xl focus:border-yellow-500 outline-none placeholder-gray-700"
placeholder="0"
/>
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wide">Bounty is deducted immediately and held in escrow until verification.</p>
</div>
)}
<textarea required rows={5} className="w-full bg-[#111] border-2 border-gray-800 p-6 rounded-[2.5rem] focus:border-red-600 outline-none resize-none font-medium text-lg text-white" value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} placeholder="Describe details (color, size, unique marks)..."></textarea>
<Button type="submit" className="w-full h-20 text-2xl bg-red-600">INITIATE BROADCAST</Button>
</form>
)}
</div>
</div>
</div>
)}
{showQRModal && <QRGenerator onClose={() => setShowQRModal(false)} />}
</div>
);
};
export default Safety;

401
pages/Settings.tsx Normal file
View File

@ -0,0 +1,401 @@
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import {
Bell, Moon, Globe, Shield, Smartphone, Trash2, LogOut,
Info, ChevronRight, Save, Database, Palette, Volume2,
VolumeX, Radio, Zap, Lock, Eye, EyeOff, ShieldCheck,
HelpCircle, User, MessageSquare, Headphones, Construction,
Clock, Share2, Award, Heart, Mic, Camera, MapPin, Signal,
Gamepad2, History, ArrowDownLeft, ArrowUpRight, Coins, BookOpen, Crown, Star
} from 'lucide-react';
import { StorageService } from '../services/storageService';
import { useNavigate } from 'react-router-dom';
import { Button } from '../components/ui/Button';
import { AppSettings, UserProfile, Transaction } from '../types';
import { requestMicPermission } from '../services/geminiService';
import confetti from 'canvas-confetti';
const SettingSection = ({ title, icon: Icon, children, className = "" }: { title: string, icon?: any, children?: React.ReactNode, className?: string }) => {
const { t } = useLanguage();
return (
<div className={`bg-white dark:bg-gray-800 rounded-[2rem] shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden mb-8 animate-in slide-in-from-bottom-4 ${className}`}>
<div className="px-8 py-5 border-b border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50 flex items-center gap-3">
{Icon && <Icon size={20} className="text-indigo-500" />}
<h3 className="font-black text-gray-900 dark:text-white text-xs uppercase tracking-[0.3em]">{t(title, title)}</h3>
</div>
<div className="p-4 md:p-6 space-y-1">
{children}
</div>
</div>
);
};
const SettingItem = ({
icon: Icon,
label,
description,
action,
onClick,
badge
}: {
icon: any,
label: string,
description?: string,
action?: React.ReactNode,
onClick?: () => void,
badge?: string
}) => {
const { t } = useLanguage();
return (
<div
onClick={onClick}
className={`flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded-[1.5rem] transition-all group ${onClick ? 'cursor-pointer' : ''}`}
>
<div className="flex items-center gap-4">
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-2xl text-gray-600 dark:text-gray-300 group-hover:bg-indigo-100 group-hover:text-indigo-600 transition-colors">
<Icon size={20} />
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-black text-gray-900 dark:text-white uppercase tracking-tighter italic">{t(label, label)}</p>
{badge && <span className="text-[8px] font-black uppercase tracking-widest bg-yellow-400 text-yellow-900 px-1.5 py-0.5 rounded-full shadow-sm">{badge}</span>}
</div>
{description && <p className="text-xs text-gray-500 dark:text-gray-400 mt-1 font-medium leading-tight">{t(description, description)}</p>}
</div>
</div>
<div className="shrink-0 ml-4">
{action}
</div>
</div>
);
};
const Toggle = ({ checked, onChange }: { checked: boolean, onChange: () => void }) => (
<button
onClick={(e) => { e.stopPropagation(); onChange(); }}
className={`w-14 h-7 rounded-full p-1 transition-all duration-300 ease-in-out relative ${checked ? 'bg-indigo-600 shadow-[0_0_10px_rgba(79,70,229,0.5)]' : 'bg-gray-300 dark:bg-gray-600'}`}
>
<div className={`bg-white w-5 h-5 rounded-full shadow-xl transform transition-transform duration-300 ${checked ? 'translate-x-7' : 'translate-x-0'}`} />
</button>
);
const BadgeItem = ({ name, desc, icon: Icon, unlocked, color }: { name: string, desc: string, icon: any, unlocked: boolean, color: string }) => (
<div className={`flex flex-col items-center justify-center p-6 rounded-[2rem] border-2 transition-all min-h-[160px] ${unlocked ? `bg-white dark:bg-gray-700/50 border-${color}-100 dark:border-${color}-900` : 'bg-gray-50 dark:bg-gray-800 border-dashed border-gray-200 dark:border-gray-700 opacity-50'}`}>
<div className={`w-16 h-16 rounded-full flex items-center justify-center mb-4 ${unlocked ? `bg-${color}-100 dark:bg-${color}-900/50 text-${color}-600 dark:text-${color}-400 shadow-sm` : 'bg-gray-200 dark:bg-gray-700 text-gray-400'}`}>
<Icon size={32} />
</div>
<h4 className={`text-sm font-black uppercase tracking-wider mb-2 text-center ${unlocked ? 'text-gray-900 dark:text-white' : 'text-gray-400'}`}>{name}</h4>
<p className="text-[10px] text-center font-medium text-gray-500 leading-tight max-w-[120px]">{unlocked ? desc : 'Locked'}</p>
</div>
);
const SubscriptionCard = ({ tier, price, karmaCost, perks, active, onBuy }: any) => (
<div className={`relative p-6 rounded-[2.5rem] border-4 flex flex-col justify-between h-full ${active ? 'bg-indigo-600 text-white border-indigo-400 shadow-2xl' : 'bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-700'}`}>
{active && <div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-yellow-400 text-yellow-900 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest shadow-lg">Current Plan</div>}
<div>
<h3 className={`text-2xl font-black italic uppercase tracking-tighter mb-2 ${active ? 'text-white' : 'text-gray-900 dark:text-white'}`}>{tier}</h3>
<p className={`text-xs font-bold uppercase tracking-widest mb-6 ${active ? 'text-indigo-200' : 'text-gray-400'}`}>Support Us!!</p>
<ul className="space-y-3 mb-8">
{perks.map((p: string, i: number) => (
<li key={i} className="flex items-center gap-2 text-sm font-medium">
<CheckCircle size={16} className={active ? 'text-white' : 'text-green-500'} /> {p}
</li>
))}
</ul>
</div>
<div className="space-y-3">
<button onClick={() => onBuy('karma')} disabled={active} className={`w-full py-3 rounded-xl font-black uppercase text-xs flex items-center justify-center gap-2 transition-all ${active ? 'bg-white/20 cursor-default' : 'bg-yellow-500 hover:bg-yellow-600 text-white shadow-lg'}`}>
<Coins size={14}/> {karmaCost} Karma
</button>
<button onClick={() => onBuy('money')} disabled={active} className={`w-full py-3 rounded-xl font-black uppercase text-xs transition-all ${active ? 'hidden' : 'bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'}`}>
{price} (Simulate)
</button>
</div>
</div>
);
import { CheckCircle } from 'lucide-react';
const Settings: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'general' | 'account' | 'privacy'>('general');
useEffect(() => {
const init = async () => {
const [p, s] = await Promise.all([
StorageService.getProfile(),
StorageService.getSettings()
]);
setProfile(p);
setSettings(s);
if (p) {
const txs = await StorageService.getTransactions(p.id);
setTransactions(txs);
}
setLoading(false);
};
init();
}, []);
const updateSettings = async (updates: Partial<AppSettings>) => {
if (!settings) return;
const newSettings = { ...settings, ...updates };
setSettings(newSettings);
await StorageService.saveSettings(newSettings);
if (updates.language) setLanguage(updates.language);
};
const updatePermission = async (key: keyof AppSettings['permissions']) => {
if (!settings) return;
const currentState = settings.permissions[key];
if (!currentState) {
if (key === 'microphone') await requestMicPermission();
// Other permissions would be requested here
}
const newPermissions = { ...settings.permissions, [key]: !currentState };
await updateSettings({ permissions: newPermissions });
};
const handleLogout = async () => {
if (confirm("Sign out of Rudraksha?")) {
await StorageService.logout();
navigate('/auth');
}
};
const handleSubscription = async (tier: 'weekly' | 'monthly' | 'lifetime', cost: number, currency: 'karma' | 'money') => {
const res = await StorageService.purchaseSubscription(tier, cost, currency);
if (res.success) {
confetti({ particleCount: 150, spread: 70, origin: { y: 0.6 } });
const p = await StorageService.getProfile();
setProfile(p);
alert(`Successfully subscribed to ${tier} plan!`);
} else {
alert(res.error || "Transaction Failed");
}
};
const clearCache = () => {
if (confirm("Clear local cache? This will reset some local preferences but keep your account data.")) {
alert("Cache cleared successfully!");
}
};
if (loading || !settings) return <div className="h-screen flex items-center justify-center"><Zap className="animate-spin text-red-600"/></div>;
const hasScholarBadge = profile ? (profile.unlockedItems || []).some(item => item === 'badge_scholar') : false;
const hasCustomTheme = profile ? (profile.unlockedItems || []).some(item => item.startsWith('theme_')) : false;
const hasDonated = profile ? (profile.unlockedItems || []).some(item => item.startsWith('donate_')) : false;
return (
<div className="max-w-5xl mx-auto space-y-10 pb-20 animate-in fade-in duration-700">
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div>
<h1 className="text-4xl font-black text-gray-900 dark:text-white flex items-center gap-4 italic tracking-tighter uppercase">
<div className="p-3 bg-red-600 rounded-2xl text-white shadow-xl shadow-red-600/20">
<Smartphone size={32} />
</div>
{t("Control Room", "Control Room")}
</h1>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium mt-1 ml-1">
{t("Optimize your Nepali companion experience.", "Optimize your Nepali companion experience.")}
</p>
</div>
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-xl p-1.5 rounded-[1.5rem] flex gap-1 shadow-inner border border-gray-100 dark:border-gray-700">
{[
{ id: 'general', label: 'App', icon: Smartphone },
{ id: 'account', label: 'Account', icon: User },
{ id: 'privacy', label: 'Safety', icon: ShieldCheck }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`px-5 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all ${activeTab === tab.id ? 'bg-white dark:bg-gray-700 text-indigo-600 shadow-xl' : 'text-gray-400 hover:text-gray-600'}`}
>
<tab.icon size={14}/> {tab.label}
</button>
))}
</div>
</header>
<div className="grid grid-cols-1 gap-0">
{activeTab === 'general' && (
<div className="animate-in slide-in-from-right-4 duration-500 space-y-8">
<SettingSection title="Visual Identity" icon={Palette}>
<SettingItem
icon={Palette}
label="Theme Studio"
description="Access 50+ custom UI aesthetics"
action={<ChevronRight size={20} className="text-gray-400 group-hover:text-indigo-600" />}
onClick={() => navigate('/settings/themes')}
/>
<SettingItem
icon={Globe}
label="Primary Language"
description="Nepali (Devanagari) or English"
action={
<div className="flex bg-gray-100 dark:bg-gray-700 p-1 rounded-xl">
<button onClick={() => updateSettings({ language: 'en' })} className={`px-3 py-1 rounded-lg text-[10px] font-black uppercase transition-all ${settings.language === 'en' ? 'bg-white dark:bg-gray-600 text-indigo-600 shadow-md' : 'text-gray-400'}`}>EN</button>
<button onClick={() => updateSettings({ language: 'ne' })} className={`px-3 py-1 rounded-lg text-[10px] font-black uppercase transition-all ${settings.language === 'ne' ? 'bg-white dark:bg-gray-600 text-indigo-600 shadow-md' : 'text-gray-400'}`}>NE</button>
</div>
}
/>
<SettingItem
icon={Smartphone}
label="Data Saver"
description="Limit high-res image loading on Terai/Mountain networks"
action={<Toggle checked={settings.dataSaver} onChange={() => updateSettings({ dataSaver: !settings.dataSaver })} />}
/>
</SettingSection>
<SettingSection title="Immersion" icon={Volume2}>
<SettingItem
icon={settings.soundEnabled ? Volume2 : VolumeX}
label="Arcade Sounds"
description="Game audio and system feedback"
action={<Toggle checked={settings.soundEnabled} onChange={() => updateSettings({ soundEnabled: !settings.soundEnabled })} />}
/>
<SettingItem
icon={Zap}
label="Hardcore Focus"
description="Auto-kick distraction apps (Culture, Social) during study"
action={<Toggle checked={settings.autoFocusMode} onChange={() => updateSettings({ autoFocusMode: !settings.autoFocusMode })} />}
/>
</SettingSection>
<SettingSection title="Intelligence" icon={Database}>
<SettingItem
icon={Database}
label="Clear App Cache"
description="Frees up space (local images & AI logs)"
action={<Button size="sm" variant="ghost" onClick={clearCache} className="text-red-500 font-black">RESET</Button>}
/>
</SettingSection>
</div>
)}
{activeTab === 'account' && (
<div className="animate-in slide-in-from-right-4 duration-500 space-y-8">
{/* Profile Header */}
<div className="bg-white dark:bg-gray-800 rounded-[2.5rem] p-10 shadow-xl border border-gray-100 dark:border-gray-700 flex flex-col md:flex-row items-center gap-10 mb-8">
<div className="relative group">
<img src={profile?.avatarUrl || `https://api.dicebear.com/7.x/initials/svg?seed=${profile?.name}`} className="w-32 h-32 rounded-full object-cover border-4 border-indigo-100 shadow-xl" />
<div className="absolute inset-0 bg-black/40 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity cursor-pointer" onClick={() => navigate('/profile')}>
<Palette size={24} className="text-white"/>
</div>
</div>
<div className="flex-1 text-center md:text-left">
<h2 className="text-3xl font-black text-gray-900 dark:text-white uppercase tracking-tighter italic mb-1">{profile?.name}</h2>
<p className="text-gray-500 font-medium text-lg">{profile?.email}</p>
<div className="mt-6 flex flex-wrap justify-center md:justify-start gap-3">
<span className="px-4 py-1.5 bg-indigo-50 text-indigo-600 rounded-xl text-xs font-black uppercase tracking-widest border border-indigo-100">{profile?.role}</span>
<span className="px-4 py-1.5 bg-yellow-50 text-yellow-600 rounded-xl text-xs font-black uppercase tracking-widest border border-yellow-100">Level {Math.floor((profile?.xp || 0)/500)+1}</span>
</div>
</div>
<Button onClick={() => navigate('/profile')} className="bg-indigo-600 hover:bg-indigo-700 h-14 px-8 rounded-2xl text-sm font-black uppercase tracking-widest">EDIT PROFILE</Button>
</div>
{/* Membership Plans */}
<div className="mb-12">
<h3 className="text-xl font-black text-gray-900 dark:text-white uppercase italic tracking-tighter mb-6 flex items-center gap-2"><Crown size={24} className="text-yellow-500"/> Membership Tiers</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<SubscriptionCard
tier="Supporter"
price="$1"
karmaCost={500}
perks={['Supporter Badge', 'Weekly Bonus', 'Ad-Free (Simulated)']}
active={profile?.subscription?.tier === 'weekly'}
onBuy={(currency: any) => handleSubscription('weekly', 500, currency)}
/>
<SubscriptionCard
tier="Believer"
price="$3"
karmaCost={1500}
perks={['Believer Badge', 'Monthly Bonus', 'Priority AI Response']}
active={profile?.subscription?.tier === 'monthly'}
onBuy={(currency: any) => handleSubscription('monthly', 1500, currency)}
/>
<SubscriptionCard
tier="Guardian"
price="$20"
karmaCost={10000}
perks={['Golden Frame', 'Lifetime Access', 'Dev Support']}
active={profile?.subscription?.tier === 'lifetime'}
onBuy={(currency: any) => handleSubscription('lifetime', 10000, currency)}
/>
</div>
</div>
<SettingSection title="Achievement Badges" icon={Award}>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<BadgeItem name="Diligent Scholar" desc="Complete 3 Assignments" icon={BookOpen} unlocked={hasScholarBadge} color="blue" />
<BadgeItem name="Theme Enthusiast" desc="Acquired a custom theme" icon={Palette} unlocked={hasCustomTheme} color="pink" />
<BadgeItem name="Philanthropist" desc="Donated via Karma Bazaar" icon={Heart} unlocked={hasDonated} color="red" />
</div>
</SettingSection>
<SettingSection title="Karma History" icon={History}>
<div className="max-h-[400px] overflow-y-auto custom-scrollbar divide-y divide-gray-100 dark:divide-gray-700">
{transactions.length === 0 ? (
<div className="p-12 text-center text-gray-400">
<History size={48} className="mx-auto mb-4 opacity-20"/>
<p className="text-sm font-bold uppercase tracking-widest">No recent activity</p>
</div>
) : (
transactions.map(tx => (
<div key={tx.id} className="p-6 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-2xl ${tx.amount > 0 ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}>
{tx.amount > 0 ? <ArrowDownLeft size={20}/> : <ArrowUpRight size={20}/>}
</div>
<div>
<p className="font-bold text-gray-900 dark:text-white text-sm uppercase tracking-tight">{tx.description || tx.type}</p>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-1">{new Date(tx.timestamp).toLocaleDateString()}</p>
</div>
</div>
<div className={`text-right font-black italic tracking-tighter text-xl ${tx.amount > 0 ? 'text-green-600' : 'text-red-500'}`}>
{tx.amount > 0 ? '+' : ''}{tx.amount}
</div>
</div>
))
)}
</div>
</SettingSection>
<div className="bg-red-50 dark:bg-red-900/10 rounded-[2rem] p-10 border-2 border-red-100 dark:border-red-900/30">
<h4 className="text-red-600 dark:text-red-400 font-black uppercase text-xs tracking-widest mb-8 italic">Danger Zone</h4>
<div className="space-y-4">
<Button variant="secondary" onClick={handleLogout} className="w-full justify-between h-16 bg-white dark:bg-gray-800 text-red-600 border border-red-100 dark:border-red-800 rounded-2xl px-8">
<span className="font-black italic uppercase tracking-tighter text-lg">Exit Companion</span>
<LogOut size={24}/>
</Button>
</div>
</div>
</div>
)}
{activeTab === 'privacy' && (
<div className="animate-in slide-in-from-right-4 duration-500 space-y-8">
<SettingSection title="Device Permissions" icon={ShieldCheck}>
<SettingItem icon={Mic} label="Microphone Access" description="Required for Rudra Voice & Study Buddy" action={<Toggle checked={settings.permissions.microphone} onChange={() => updatePermission('microphone')} />} />
<SettingItem icon={Camera} label="Camera Access" description="Required for AR scans and Profile photo" action={<Toggle checked={settings.permissions.camera} onChange={() => updatePermission('camera')} />} />
<SettingItem icon={MapPin} label="Location Access" description="Used for Weather & FTL Rescue radius" action={<Toggle checked={settings.permissions.location} onChange={() => updatePermission('location')} />} />
</SettingSection>
</div>
)}
</div>
</div>
);
};
export default Settings;

549
pages/StudyBuddy.tsx Normal file
View File

@ -0,0 +1,549 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChatMessage, TriviaQuestion } from '../types';
import {
createStudyChat, generateTrivia, analyzeImage
} from '../services/geminiService';
import { StorageService } from '../services/storageService';
import { Button } from '../components/ui/Button';
import {
Send, Bot, Loader2,
X, Mic, RefreshCw,
Image as ImageIcon, MessageSquare, Trophy, Volume2, VolumeX, Eye
} from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import confetti from 'canvas-confetti';
// --- MESSAGE PARSER FOR DUAL LANGUAGE ---
const parseMessage = (rawText: string) => {
try {
const json = JSON.parse(rawText);
if (json.en || json.ne) return json;
return { en: rawText, ne: rawText, type: 'text' };
} catch {
return { en: rawText, ne: rawText, type: 'text' };
}
};
const StudyBuddy: React.FC = () => {
const { language } = useLanguage(); // 'en' or 'ne'
// -- STATE --
const [activeTab, setActiveTab] = useState<'chat' | 'voice' | 'trivia'>('chat');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
// Image Analysis State
const [selectedImage, setSelectedImage] = useState<string | null>(null);
// Trivia Game State
const [triviaState, setTriviaState] = useState<'LOBBY' | 'LANG_SELECT' | 'PLAYING' | 'GAMEOVER'>('LOBBY');
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [triviaLang, setTriviaLang] = useState<'en' | 'ne'>('en');
const [questions, setQuestions] = useState<TriviaQuestion[]>([]);
const [currentQIndex, setCurrentQIndex] = useState(0);
const [score, setScore] = useState(0);
const [streak, setStreak] = useState(0);
const [triviaLoading, setTriviaLoading] = useState(false);
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
const [soundEnabled, setSoundEnabled] = useState(true);
// Refs
const chatEndRef = useRef<HTMLDivElement>(null);
const chatSessionRef = useRef<any>(null);
// -- INITIALIZATION --
useEffect(() => {
// Load history if exists
const history = StorageService.getStudyChatHistory();
if (history.length > 0) {
setMessages(history);
}
// Initialize Chat Session
if (!chatSessionRef.current) {
chatSessionRef.current = createStudyChat();
}
}, []);
useEffect(() => {
StorageService.saveStudyChatHistory(messages);
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// -- SOUND EFFECTS --
const playSound = (type: 'correct' | 'wrong' | 'click' | 'win') => {
if (!soundEnabled) return;
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContext) return;
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
const now = ctx.currentTime;
if (type === 'correct') {
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(1000, now + 0.1);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
osc.start();
osc.stop(now + 0.3);
} else if (type === 'wrong') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(150, now);
osc.frequency.linearRampToValueAtTime(100, now + 0.3);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
osc.start();
osc.stop(now + 0.3);
} else if (type === 'click') {
osc.frequency.setValueAtTime(800, now);
gain.gain.setValueAtTime(0.05, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.05);
osc.start();
osc.stop(now + 0.05);
} else if (type === 'win') {
osc.type = 'triangle';
osc.frequency.setValueAtTime(400, now);
osc.frequency.linearRampToValueAtTime(800, now + 0.2);
osc.frequency.linearRampToValueAtTime(600, now + 0.4);
gain.gain.setValueAtTime(0.1, now);
gain.gain.linearRampToValueAtTime(0, now + 0.6);
osc.start();
osc.stop(now + 0.6);
}
} catch(e) {}
};
// -- HANDLERS --
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setSelectedImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSend = async (e?: React.FormEvent, overrideText?: string) => {
if (e) e.preventDefault();
const textToSend = overrideText || input;
// Allow empty text if image is selected (visual query)
if ((!textToSend.trim() && !selectedImage) || isTyping) return;
// Display optimistic user message
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
text: JSON.stringify({ en: textToSend, ne: textToSend, type: 'text' }),
timestamp: Date.now(),
image: selectedImage || undefined // Store image in history if present
};
setMessages(prev => [...prev, userMsg]);
setInput('');
const tempImage = selectedImage; // Local capture
setSelectedImage(null); // Clear input immediately
setIsTyping(true);
try {
let responseText = "{}";
if (tempImage) {
// Multimodal Analysis request via specialized function
// The prompt defaults to "Analyze this image" if text is empty
const prompt = textToSend.trim() || "What is in this image? Analyze it in detail.";
responseText = await analyzeImage(tempImage, prompt);
} else if (chatSessionRef.current) {
// Standard Text Chat
const result = await chatSessionRef.current.sendMessage({ message: textToSend });
responseText = result.text || "{}";
}
const botMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'model',
text: responseText,
timestamp: Date.now()
};
setMessages(prev => [...prev, botMsg]);
} catch (error) {
console.error(error);
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'model',
text: JSON.stringify({ en: "Connection interrupted.", ne: "सम्पर्क विच्छेद भयो।", type: "text" }),
timestamp: Date.now()
}]);
} finally {
setIsTyping(false);
}
};
const handleClearChat = () => {
if(confirm("Clear chat history?")) {
setMessages([]);
StorageService.clearStudyChatHistory();
chatSessionRef.current = createStudyChat();
}
};
// -- TRIVIA LOGIC --
const handleCategorySelect = (category: string) => {
setSelectedCategory(category);
setTriviaState('LANG_SELECT');
};
const startTrivia = async (lang: 'en' | 'ne') => {
setTriviaLang(lang);
setTriviaLoading(true);
playSound('click');
try {
const qs = await generateTrivia(selectedCategory, lang);
setQuestions(qs);
setScore(0);
setStreak(0);
setCurrentQIndex(0);
setTriviaState('PLAYING');
} catch (e) {
alert("Failed to generate trivia. Check connection.");
setTriviaState('LOBBY');
} finally {
setTriviaLoading(false);
}
};
const handleTriviaAnswer = (index: number) => {
if (selectedAnswer !== null) return; // Prevent double click
setSelectedAnswer(index);
const correct = index === questions[currentQIndex].correctAnswer;
setIsCorrect(correct);
if (correct) {
playSound('correct');
setScore(prev => prev + 10 + (streak * 2));
setStreak(prev => prev + 1);
confetti({ particleCount: 50, spread: 60, origin: { y: 0.7 }, colors: ['#4ade80', '#ffffff'] });
} else {
playSound('wrong');
setStreak(0);
}
setTimeout(() => {
if (currentQIndex < questions.length - 1) {
setCurrentQIndex(prev => prev + 1);
setSelectedAnswer(null);
setIsCorrect(null);
} else {
playSound('win');
setTriviaState('GAMEOVER');
// Save Karma
StorageService.addPoints(score, score * 2, 'trivia', 'Rudra Trivia Reward');
}
}, 1500);
};
return (
<div className="h-[calc(100vh-140px)] flex flex-col bg-white dark:bg-gray-900 rounded-[2.5rem] shadow-2xl border border-gray-200 dark:border-gray-800 overflow-hidden relative font-sans">
{/* --- HEADER --- */}
<header className="px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center bg-white/80 dark:bg-gray-900/80 backdrop-blur-md shrink-0 z-20">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg text-white">
<Bot size={20}/>
</div>
<div>
<h1 className="text-sm font-black uppercase tracking-widest text-gray-900 dark:text-white">Rudra AI</h1>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></span>
<span className="text-[9px] font-bold text-gray-400 uppercase tracking-wider">{language === 'ne' ? 'नेपाली मोड' : 'English Mode'}</span>
</div>
</div>
</div>
{/* Compact Tab Switcher */}
<div className="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-xl gap-1">
{[
{ id: 'chat', icon: MessageSquare },
{ id: 'trivia', icon: Trophy },
{ id: 'voice', icon: Mic },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`p-2 rounded-lg transition-all ${
activeTab === tab.id
? 'bg-white dark:bg-gray-700 text-indigo-600 shadow-sm'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
<tab.icon size={16} />
</button>
))}
</div>
<button onClick={handleClearChat} className="p-2 text-gray-300 hover:text-red-500 transition-colors">
<RefreshCw size={16} />
</button>
</header>
{/* --- MAIN CONTENT --- */}
<div className="flex-1 overflow-hidden relative bg-gray-50 dark:bg-black/20">
{/* CHAT INTERFACE */}
{activeTab === 'chat' && (
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center opacity-50 p-8">
<Bot size={48} className="mb-4 text-indigo-400"/>
<p className="text-sm font-bold uppercase tracking-widest text-gray-500">
Start a conversation or Scan an image
</p>
</div>
)}
{messages.map((msg) => {
const content = parseMessage(msg.text);
const displayText = language === 'ne' ? (content.ne || content.en) : content.en;
const isUser = msg.role === 'user';
return (
<div key={msg.id} className={`flex ${isUser ? 'justify-end' : 'justify-start'} animate-in slide-in-from-bottom-1`}>
<div
className={`max-w-[85%] px-4 py-3 rounded-2xl text-sm font-medium leading-relaxed shadow-sm flex flex-col gap-2 ${
isUser
? 'bg-indigo-600 text-white rounded-br-sm'
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-bl-sm border border-gray-100 dark:border-gray-700'
}`}
>
{msg.image && (
<div className="rounded-lg overflow-hidden mb-1 max-w-full border-2 border-white/20">
<img src={msg.image} alt="User Upload" className="max-h-60 object-contain" />
</div>
)}
<div className="whitespace-pre-wrap">{displayText}</div>
{/* Show Newari if available and relevant */}
{content.newa && !isUser && (
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 text-xs text-indigo-500 font-bold">
Newari: {content.newa}
</div>
)}
</div>
</div>
);
})}
{isTyping && (
<div className="flex justify-start">
<div className="bg-white dark:bg-gray-800 px-4 py-3 rounded-2xl rounded-bl-sm shadow-sm flex gap-1">
<div className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce delay-75"></div>
<div className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce delay-150"></div>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Image Preview Overlay */}
{selectedImage && (
<div className="px-4 py-2 bg-gray-100 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 flex items-center justify-between animate-in slide-in-from-bottom-2">
<div className="flex items-center gap-3">
<img src={selectedImage} alt="Preview" className="h-12 w-12 rounded-lg object-cover border border-gray-300 dark:border-gray-600"/>
<span className="text-xs font-bold text-gray-500 uppercase tracking-wide">Image Selected</span>
</div>
<button onClick={() => setSelectedImage(null)} className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full"><X size={16}/></button>
</div>
)}
{/* Input Area */}
<div className="p-3 bg-white dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800">
<form onSubmit={(e) => handleSend(e)} className="flex items-end gap-2 bg-gray-100 dark:bg-gray-800 p-1.5 rounded-[1.5rem]">
<label className="p-2.5 text-gray-400 hover:text-indigo-600 cursor-pointer transition-colors">
<ImageIcon size={20} />
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} />
</label>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Type a message or describe the image..."
className="flex-1 bg-transparent border-none outline-none text-sm py-2.5 px-2 text-gray-900 dark:text-white placeholder-gray-500"
/>
<button
type="submit"
disabled={(!input.trim() && !selectedImage) || isTyping}
className={`p-2.5 rounded-full transition-all ${input.trim() || selectedImage ? 'bg-indigo-600 text-white shadow-md' : 'bg-gray-200 dark:bg-gray-700 text-gray-400'}`}
>
<Send size={18} />
</button>
</form>
</div>
</div>
)}
{/* TRIVIA SECTION */}
{activeTab === 'trivia' && (
<div className="h-full relative flex flex-col">
<button onClick={() => setSoundEnabled(!soundEnabled)} className="absolute top-4 right-4 z-10 p-2 bg-white/20 hover:bg-white/40 rounded-full text-gray-500 dark:text-gray-300">
{soundEnabled ? <Volume2 size={16}/> : <VolumeX size={16}/>}
</button>
{triviaState === 'LOBBY' && (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center space-y-8 animate-in fade-in zoom-in">
<div className="w-24 h-24 bg-gradient-to-tr from-yellow-400 to-orange-500 rounded-3xl flex items-center justify-center shadow-xl rotate-3">
<Trophy size={48} className="text-white"/>
</div>
<div>
<h2 className="text-3xl font-black text-gray-900 dark:text-white uppercase italic tracking-tighter">Nepal Trivia</h2>
<p className="text-gray-500 dark:text-gray-400 font-medium">Test your knowledge. Earn Karma.</p>
</div>
<div className="grid grid-cols-2 gap-4 w-full max-w-xs">
{['History', 'Geography', 'Culture', 'Nature'].map(cat => (
<button
key={cat}
onClick={() => handleCategorySelect(cat)}
className="p-4 bg-white dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-2xl font-bold text-gray-700 dark:text-gray-200 hover:border-indigo-500 hover:text-indigo-600 transition-all active:scale-95 shadow-sm uppercase text-xs tracking-widest"
>
{cat}
</button>
))}
</div>
</div>
)}
{triviaState === 'LANG_SELECT' && (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center space-y-8 animate-in fade-in slide-in-from-right-4">
<div>
<h2 className="text-2xl font-black text-gray-900 dark:text-white uppercase italic tracking-tighter">Select Language</h2>
<p className="text-gray-500 dark:text-gray-400 font-medium">Which language should we use?</p>
</div>
{triviaLoading ? (
<div className="flex flex-col items-center gap-3">
<Loader2 className="animate-spin text-indigo-500" size={32} />
<p className="text-xs font-bold uppercase tracking-widest text-indigo-500">Generating Quiz...</p>
</div>
) : (
<div className="flex gap-4">
<Button onClick={() => startTrivia('en')} className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white border-2 border-gray-200 dark:border-gray-700 hover:border-indigo-500 px-8 py-4 rounded-2xl font-black">
English
</Button>
<Button onClick={() => startTrivia('ne')} className="bg-indigo-600 text-white px-8 py-4 rounded-2xl font-black">
(Nepali)
</Button>
</div>
)}
<button onClick={() => setTriviaState('LOBBY')} className="text-gray-400 hover:text-red-500 text-xs font-bold uppercase tracking-widest">Back</button>
</div>
)}
{triviaState === 'PLAYING' && questions.length > 0 && (
<div className="flex-1 flex flex-col p-6 animate-in slide-in-from-right">
{/* Progress Bar */}
<div className="w-full bg-gray-200 dark:bg-gray-800 h-2 rounded-full mb-6 overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500"
style={{ width: `${((currentQIndex + 1) / questions.length) * 100}%` }}
></div>
</div>
<div className="flex-1 flex flex-col justify-center">
<span className="text-indigo-500 font-black uppercase tracking-widest text-xs mb-4 block">Question {currentQIndex + 1} of {questions.length}</span>
<h3 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-8 leading-relaxed">
{questions[currentQIndex].question}
</h3>
<div className="space-y-3">
{questions[currentQIndex].options.map((opt, idx) => {
let btnClass = "bg-white dark:bg-gray-800 border-gray-100 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700";
if (selectedAnswer !== null) {
if (idx === questions[currentQIndex].correctAnswer) btnClass = "bg-green-500 border-green-600 text-white";
else if (idx === selectedAnswer) btnClass = "bg-red-500 border-red-600 text-white animate-shake";
else btnClass = "opacity-50 bg-gray-100 dark:bg-gray-800";
}
return (
<button
key={idx}
onClick={() => handleTriviaAnswer(idx)}
disabled={selectedAnswer !== null}
className={`w-full p-4 rounded-2xl border-2 font-bold text-left transition-all ${btnClass}`}
>
{opt}
</button>
);
})}
</div>
</div>
<div className="mt-6 flex justify-between items-center text-sm font-bold text-gray-500">
<span>Score: {score}</span>
{streak > 1 && <span className="text-orange-500 animate-bounce">🔥 {streak} Streak!</span>}
</div>
</div>
)}
{triviaState === 'GAMEOVER' && (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in zoom-in">
<h2 className="text-4xl font-black italic uppercase tracking-tighter text-indigo-600 mb-2">Quiz Complete!</h2>
<p className="text-gray-500 font-medium mb-8">Knowledge verified.</p>
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-3xl w-full max-w-xs mb-8">
<p className="text-xs font-black text-gray-400 uppercase tracking-widest mb-1">Total Score</p>
<p className="text-5xl font-black text-gray-900 dark:text-white">{score}</p>
<p className="text-xs font-bold text-green-500 mt-2">+ {score} Karma Earned</p>
</div>
<Button onClick={() => setTriviaState('LOBBY')} className="w-full max-w-xs h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-black uppercase tracking-widest shadow-xl">
Play Again
</Button>
</div>
)}
</div>
)}
{/* VOICE VIEW (Simple Placeholder to direct to main button) */}
{activeTab === 'voice' && (
<div className="h-full flex flex-col items-center justify-center p-8 text-center bg-gradient-to-b from-indigo-900/10 to-transparent">
<div className="w-32 h-32 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center animate-pulse mb-6">
<Mic size={48} className="text-indigo-600 dark:text-indigo-400"/>
</div>
<h2 className="text-2xl font-black text-gray-900 dark:text-white uppercase italic tracking-tighter">Voice Command</h2>
<p className="text-gray-500 mt-2 max-w-xs mx-auto text-sm">
Use the global Rudra button (bottom right) for real-time voice conversations.
</p>
</div>
)}
</div>
<style>{`
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
`}</style>
</div>
);
};
export default StudyBuddy;

212
pages/ThemeSelection.tsx Normal file
View File

@ -0,0 +1,212 @@
import React, { useState, useEffect } from 'react';
import { StorageService } from '../services/storageService';
import { UserProfile } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { ArrowLeft, Check, Lock, Palette, Search, RefreshCw, Sparkles, ChevronRight } from 'lucide-react';
import { useNavigate, Link } from 'react-router-dom';
import { Button } from '../components/ui/Button';
import { THEME_REGISTRY, THEME_CATEGORIES } from '../config/themes';
const ThemeSelection: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [activeTheme, setActiveTheme] = useState('default');
const [activeCategory, setActiveCategory] = useState('All');
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
const loadData = async () => {
const p = await StorageService.getProfile();
setProfile(p);
if (p?.activeTheme) setActiveTheme(p.activeTheme);
};
loadData();
}, []);
const handleSelectTheme = async (themeId: string) => {
if (!profile) return;
const theme = THEME_REGISTRY[themeId];
if (theme?.isPremium && !profile.unlockedItems?.includes(themeId)) return;
setActiveTheme(themeId);
await StorageService.updateProfile({ activeTheme: themeId });
setProfile(prev => prev ? { ...prev, activeTheme: themeId } : null);
window.dispatchEvent(new Event('rudraksha-profile-update'));
};
const handleRandomize = () => {
const unlockedThemes = Object.values(THEME_REGISTRY).filter(theme =>
!theme.isPremium || profile?.unlockedItems?.includes(theme.id)
);
const randomTheme = unlockedThemes[Math.floor(Math.random() * unlockedThemes.length)];
handleSelectTheme(randomTheme.id);
};
const categoriesToRender = activeCategory === 'All'
? THEME_CATEGORIES.filter(c => c !== 'All')
: [activeCategory];
// Defined filteredThemes to solve the "Cannot find name 'filteredThemes'" error.
const filteredThemes = Object.values(THEME_REGISTRY).filter(theme =>
(activeCategory === 'All' || theme.category === activeCategory) &&
theme.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="max-w-6xl mx-auto space-y-12 pb-20 animate-in fade-in duration-500">
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => navigate('/settings')} className="hover:bg-black/5 dark:hover:bg-white/5 rounded-xl">
<ArrowLeft size={20} />
</Button>
<div>
<h1 className="text-3xl font-black text-gray-900 dark:text-white flex items-center gap-3 italic tracking-tighter uppercase">
<Palette className="text-red-600" /> {t("Theme Studio", "Theme Studio")}
</h1>
<p className="text-gray-500 dark:text-gray-400 font-medium">Customize your ritual environment.</p>
</div>
</div>
<div className="flex gap-2 w-full md:w-auto">
<div className="relative flex-1 md:w-64">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Find aesthetic..."
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 rounded-xl outline-none focus:border-red-500 transition-all"
/>
</div>
<Button onClick={handleRandomize} className="bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-200 dark:shadow-none whitespace-nowrap">
<RefreshCw size={18} className="mr-2"/> Lucky Pick
</Button>
</div>
</header>
{/* Category Navigation */}
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none no-scrollbar sticky top-0 z-30 bg-gray-50/80 dark:bg-gray-950/80 backdrop-blur-md py-4 -mx-4 px-4">
{THEME_CATEGORIES.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-6 py-2.5 rounded-full text-xs font-black uppercase tracking-widest whitespace-nowrap transition-all ${activeCategory === cat ? 'bg-red-600 text-white shadow-xl' : 'bg-white dark:bg-gray-800 text-gray-500 border border-gray-100 dark:border-gray-700 hover:border-red-200'}`}
>
{cat}
</button>
))}
</div>
{/* Section Divisions */}
{categoriesToRender.map(cat => {
const catThemes = Object.values(THEME_REGISTRY).filter(theme =>
theme.category === cat &&
theme.name.toLowerCase().includes(searchQuery.toLowerCase())
);
if (catThemes.length === 0) return null;
return (
<div key={cat} className="space-y-6">
<div className="flex items-center gap-4">
<h2 className="text-xl font-black uppercase tracking-widest text-gray-400 dark:text-gray-500 italic">{cat} Collection</h2>
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-800"></div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{catThemes.map((theme) => {
const isLocked = theme.isPremium && !profile?.unlockedItems?.includes(theme.id);
const isActive = activeTheme === theme.id;
const palette = Object.values(theme.colors);
return (
<div
key={theme.id}
onClick={() => !isLocked && handleSelectTheme(theme.id)}
className={`
relative overflow-hidden rounded-[2.5rem] border-4 transition-all cursor-pointer group h-72 flex flex-col
${isActive ? 'border-red-500 shadow-2xl scale-[1.02] ring-4 ring-red-500/10' : 'border-white dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-600 shadow-lg'}
${isLocked ? 'grayscale-[0.5] opacity-80' : ''}
`}
>
<div
className="flex-1 relative p-6 flex flex-col justify-between overflow-hidden"
style={{
backgroundColor: theme.bgColor,
backgroundImage: theme.bgPattern || 'none',
backgroundSize: 'cover',
backgroundPosition: 'center'
}}
>
{theme.isAnimated && (
<div className="absolute inset-0 bg-white/5 animate-pulse pointer-events-none"></div>
)}
<div className="flex justify-between items-start relative z-10">
{isActive ? (
<div className="bg-white text-gray-900 text-[10px] font-black px-3 py-1 rounded-full shadow-lg flex items-center gap-1 border-2 border-red-500 animate-pop">
<Check size={10} strokeWidth={4} /> ACTIVE
</div>
) : isLocked ? (
<div className="bg-black/80 backdrop-blur-md text-white text-[10px] font-black px-3 py-1 rounded-full shadow-lg flex items-center gap-1">
<Lock size={10} /> BAZAAR
</div>
) : (
<div className="bg-white/60 backdrop-blur-md text-gray-600 text-[8px] font-black px-2 py-0.5 rounded-lg border border-black/5 uppercase tracking-widest">{theme.category}</div>
)}
{theme.isAnimated && (
<span className="bg-indigo-600 text-white text-[8px] font-black px-2 py-0.5 rounded-lg">NEON FLOW</span>
)}
</div>
<div className="space-y-3 relative z-10">
<h3 className="font-black text-2xl italic tracking-tighter text-gray-900 dark:text-white uppercase drop-shadow-md">{theme.name}</h3>
<div className="flex gap-1">
{palette.map((c, i) => (
<div key={i} className="w-6 h-1.5 rounded-full border border-black/5 shadow-inner" style={{ backgroundColor: c }}></div>
))}
</div>
</div>
</div>
<div className="p-4 bg-white dark:bg-gray-900 border-t border-gray-50 dark:border-gray-800 flex items-center justify-between">
<div className="flex flex-col">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">{isLocked ? 'Premium' : 'Standard'}</span>
<span className="text-xs font-black text-gray-700 dark:text-gray-300 uppercase">{theme.uiMode} mode</span>
</div>
{isLocked ? (
<Link to="/rewards">
<button className="p-2 bg-indigo-50 text-indigo-600 rounded-xl hover:bg-indigo-600 hover:text-white transition-all">
<ChevronRight size={20}/>
</button>
</Link>
) : (
<button
onClick={(e) => { e.stopPropagation(); handleSelectTheme(theme.id); }}
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-all ${isActive ? 'bg-red-600 text-white' : 'bg-gray-100 dark:bg-gray-800 text-gray-400 hover:bg-red-50 hover:text-red-600'}`}
disabled={isActive}
>
{isActive ? <Check size={20} strokeWidth={3}/> : <Palette size={20}/>}
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
{filteredThemes.length === 0 && (
<div className="py-20 text-center text-gray-400">
<Sparkles size={48} className="mx-auto mb-4 opacity-20"/>
<p className="font-black text-xl uppercase tracking-widest">No matching aesthetic found</p>
<Button onClick={() => { setSearchQuery(''); setActiveCategory('All'); }} variant="ghost" className="mt-4">Reset Studio</Button>
</div>
)}
</div>
);
};
export default ThemeSelection;

120
pages/Welcome.tsx Normal file
View File

@ -0,0 +1,120 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { TypewriterLoop } from '../components/animations/TextReveal';
import { ArrowRight, Sparkles, MousePointer2 } from 'lucide-react';
import { Logo } from '../components/ui/Logo';
const Welcome: React.FC = () => {
const navigate = useNavigate();
const handleStart = () => {
navigate('/auth');
};
return (
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-black relative overflow-hidden font-sans text-white selection:bg-red-500/30 p-6">
{/* High-Resolution Dynamic Background Decor - Mountain Night (Same as Greeting) */}
<div
className="absolute inset-0 bg-cover bg-center opacity-70"
style={{ backgroundImage: "url('https://images.unsplash.com/photo-1486870591958-9b9d0d1dda99?q=80&w=2400&auto=format&fit=crop')" }}
></div>
{/* Overlays - Darker for text legibility without boxes */}
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-transparent to-black/90"></div>
<div className="absolute inset-0 bg-black/20"></div>
{/* Massive Background Logo Watermark */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none overflow-hidden opacity-[0.05]">
<Logo className="w-[120vh] h-[120vh] animate-bg-pulse" />
</div>
{/* Main Content */}
<div className="z-10 flex flex-col items-center justify-center w-full max-w-5xl relative pb-20">
<div className="mb-12 animate-in zoom-in duration-1000 flex flex-col items-center gap-6">
{/* Interactive Logo Container */}
<div
onClick={handleStart}
className="w-40 h-40 md:w-56 md:h-56 flex items-center justify-center relative group cursor-pointer active:scale-95 transition-all duration-500"
>
<div className="absolute inset-0 bg-red-600 rounded-full blur-[80px] opacity-20 group-hover:opacity-40 group-hover:scale-110 transition-all animate-pulse"></div>
<Logo className="w-full h-full relative z-10 drop-shadow-[0_0_50px_rgba(220,38,38,0.6)] group-hover:drop-shadow-[0_0_80px_rgba(220,38,38,0.9)] transition-all" />
{/* Visual Cue for interaction */}
<div className="absolute -bottom-6 bg-white/10 backdrop-blur-md px-5 py-2 rounded-full border border-white/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-2 shadow-xl">
<MousePointer2 size={14} className="text-red-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-white">Tap to Start</span>
</div>
</div>
<p className="text-red-500 font-black text-xs md:text-sm tracking-[0.6em] uppercase animate-in fade-in slide-in-from-top-4 duration-1000 flex items-center justify-center gap-3">
<Sparkles size={16} /> Heritage in High Definition
</p>
</div>
<div className="text-center w-full max-w-4xl animate-in fade-in slide-in-from-bottom-4 duration-1000 mb-12">
{/* Clean Typography without glassy box */}
<h1 className="text-6xl md:text-[7rem] font-black tracking-tighter text-white text-center flex items-center justify-center drop-shadow-[0_10px_30px_rgba(0,0,0,0.8)] uppercase leading-none min-h-[1.2em]">
<TypewriterLoop
words={[
"नमस्ते",
"Welcome",
"ज्वजलपा",
"सेवारो",
"टाशी देलेक",
"लसकुस"
]}
typingSpeed={80}
deletingSpeed={40}
className="text-transparent bg-clip-text bg-gradient-to-b from-white to-gray-400"
/>
</h1>
<p className="text-gray-300 text-lg md:text-2xl font-bold text-center mt-6 leading-relaxed drop-shadow-md tracking-wide">
The Ultimate Nepali Companion for <span className="text-red-500 font-black uppercase">Culture</span>, <span className="text-blue-400 font-black uppercase">Health</span>, and <span className="text-white font-black uppercase">Excellence</span>.
</p>
</div>
<div className="w-full max-w-md z-10 animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-300 flex flex-col items-center gap-8">
<button
onClick={handleStart}
className="group relative w-full h-20 md:h-24 bg-white text-black rounded-[4rem] flex items-center justify-center gap-8 overflow-hidden transition-all hover:scale-105 active:scale-95 shadow-[0_0_60px_rgba(255,255,255,0.2)] border-4 border-transparent hover:border-red-600"
>
{/* Visual Effects */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-red-500/20 to-transparent -translate-x-full group-hover:animate-shine transition-all duration-1000"></div>
<span className="relative z-10 text-3xl md:text-4xl font-black uppercase tracking-[0.2em] italic">Enter</span>
<div className="relative z-10 w-12 h-12 md:w-16 md:h-16 bg-red-600 rounded-full flex items-center justify-center group-hover:rotate-12 transition-all shadow-xl">
<ArrowRight size={32} className="text-white group-hover:scale-125 transition-transform" />
</div>
</button>
<div className="flex flex-col items-center gap-3 opacity-60 hover:opacity-100 transition-opacity">
<p className="text-center text-[10px] font-black text-gray-400 uppercase tracking-[0.5em]">
PROJECT RUDRAKSHA v3.0
</p>
</div>
</div>
</div>
<style>{`
@keyframes shine {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes bg-pulse {
0%, 100% { opacity: 0.05; transform: scale(1); }
50% { opacity: 0.1; transform: scale(1.05); }
}
.animate-bg-pulse {
animation: bg-pulse 12s ease-in-out infinite;
}
`}</style>
</div>
);
};
export default Welcome;

View File

@ -0,0 +1,225 @@
import { AQIData, WeatherData } from '../types';
import { StorageService } from './storageService';
// Robust Geolocation Helper
const getCoordinates = (): Promise<{lat: number, lon: number, error?: string}> => {
return new Promise(async (resolve) => {
// Check App Settings Permission First
const settings = await StorageService.getSettings();
if (!settings.permissions.location) {
// Fallback to Kathmandu if privacy is on
resolve({ lat: 27.7172, lon: 85.3240, error: "Location access disabled in settings." });
return;
}
if (!navigator.geolocation) {
resolve({ lat: 27.7172, lon: 85.3240, error: "Geolocation is not supported by this browser." });
return;
}
const success = (position: GeolocationPosition) => {
resolve({
lat: position.coords.latitude,
lon: position.coords.longitude
});
};
const error = (err: GeolocationPositionError) => {
let errorMsg = "An unknown error occurred.";
switch(err.code) {
case err.PERMISSION_DENIED:
errorMsg = "User denied the request for Geolocation.";
break;
case err.POSITION_UNAVAILABLE:
errorMsg = "Location information is unavailable.";
break;
case err.TIMEOUT:
errorMsg = "The request to get user location timed out.";
break;
default:
errorMsg = "An unknown error occurred.";
break;
}
console.warn("Geolocation Error:", errorMsg);
// Fallback to Kathmandu
resolve({ lat: 27.7172, lon: 85.3240, error: errorMsg });
};
navigator.geolocation.getCurrentPosition(success, error, {
enableHighAccuracy: settings.gpsAccuracy === 'high',
timeout: 10000,
maximumAge: 0
});
});
};
const getReverseGeocoding = async (lat: number, lon: number): Promise<string> => {
try {
// Using OpenStreetMap Nominatim for reverse geocoding (free, requires attribution)
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=12&addressdetails=1`, {
headers: {
'User-Agent': 'RudrakshaApp/1.0'
}
});
if (response.ok) {
const data = await response.json();
const addr = data.address;
// Prioritize meaningful location names
return addr.city || addr.town || addr.village || addr.municipality || addr.suburb || addr.county || "Unknown Location";
}
} catch (e) {
console.warn("Reverse geocoding failed", e);
}
return `Lat: ${lat.toFixed(2)}, Lon: ${lon.toFixed(2)}`;
};
const mapWmoCodeToCondition = (code: number): WeatherData['condition'] => {
// WMO Weather interpretation codes (WW)
if (code === 0 || code === 1) return 'Sunny';
if (code === 2 || code === 3) return 'Cloudy';
if (code === 45 || code === 48) return 'Foggy';
if (code >= 51 && code <= 67) return 'Rainy';
if (code >= 80 && code <= 82) return 'Rainy';
if (code >= 71 && code <= 77) return 'Rainy'; // Snow/Sleet mapped to Rainy for simplicity
if (code >= 95) return 'Stormy';
return 'Cloudy'; // Default
};
export const AirQualityService = {
getAQI: async (): Promise<AQIData> => {
try {
const { lat, lon } = await getCoordinates();
// Open-Meteo Air Quality API
const response = await fetch(
`https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}&current=us_aqi,pm2_5,pm10,nitrogen_dioxide,ozone&timezone=auto`
);
if (!response.ok) throw new Error('AQI API failed');
const data = await response.json();
const current = data.current;
// Use US AQI standard which is widely recognized
const aqiValue = current.us_aqi || 0;
// Determine dominant pollutant (simple logic)
let pollutant = 'PM2.5';
if (current.pm10 > current.pm2_5 && current.pm10 > current.nitrogen_dioxide) pollutant = 'PM10';
if (current.nitrogen_dioxide > current.pm10) pollutant = 'NO2';
if (current.ozone > current.us_aqi) pollutant = 'O3';
const locationName = await getReverseGeocoding(lat, lon);
return getNepalAQIStatus(aqiValue, locationName, pollutant);
} catch (error) {
console.warn("Using mock AQI data due to API error", error);
// Fallback
return new Promise((resolve) => {
setTimeout(() => {
resolve(generateMockAQI("Kathmandu (Simulated)"));
}, 800);
});
}
},
getWeather: async (): Promise<WeatherData> => {
try {
const { lat, lon } = await getCoordinates();
const locationName = await getReverseGeocoding(lat, lon);
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m&daily=uv_index_max&timezone=auto`
);
if (!response.ok) throw new Error('Weather API failed');
const data = await response.json();
const current = data.current;
const daily = data.daily;
return {
temp: Math.round(current.temperature_2m),
condition: mapWmoCodeToCondition(current.weather_code),
humidity: current.relative_humidity_2m,
windSpeed: current.wind_speed_10m,
uvIndex: daily.uv_index_max && daily.uv_index_max.length > 0 ? daily.uv_index_max[0] : 0,
feelsLike: Math.round(current.apparent_temperature),
location: locationName
};
} catch (error) {
console.warn("Using mock weather data due to API error", error);
// Fallback to mock data if API fails
return new Promise((resolve) => {
setTimeout(() => {
const conditions: WeatherData['condition'][] = ['Sunny', 'Cloudy', 'Rainy', 'Foggy'];
const randomCondition = conditions[Math.floor(Math.random() * conditions.length)];
const temp = Math.floor(Math.random() * (30 - 15 + 1)) + 15;
resolve({
temp: temp,
condition: randomCondition,
humidity: Math.floor(Math.random() * (90 - 40 + 1)) + 40,
windSpeed: Math.floor(Math.random() * 15) + 2,
uvIndex: randomCondition === 'Sunny' ? Math.floor(Math.random() * 5) + 3 : 1,
feelsLike: temp + 2,
location: "Kathmandu (Simulated)"
});
}, 800);
});
}
}
};
const generateMockAQI = (locationName: string): AQIData => {
// Simulate AQI typical for Nepal context (often Moderate to Poor)
const aqi = Math.floor(Math.random() * (180 - 45 + 1)) + 45;
return getNepalAQIStatus(aqi, locationName, 'PM2.5');
};
const getNepalAQIStatus = (aqi: number, location: string, pollutant: string = 'PM2.5'): AQIData => {
let status = '';
let color = '';
let advice = '';
// Nepal Government (Department of Environment) Scale / US AQI Scale interpretation
if (aqi <= 50) {
status = 'Good';
color = '#00B050'; // Green
advice = 'Air is fresh. No mask needed. Enjoy the outdoors!';
} else if (aqi <= 100) {
status = 'Satisfactory';
color = '#A8D08D'; // Yellow-Green
advice = 'Acceptable quality. Sensitive people should check air before prolonged exertion.';
} else if (aqi <= 150) {
status = 'Moderately Polluted';
color = '#F4B083'; // Orange
advice = 'Sensitive groups (children, elderly) should wear masks. Limit outdoor exertion.';
} else if (aqi <= 200) {
status = 'Poor';
color = '#FFC000'; // Amber/Yellow (as requested) usually implies caution
advice = 'Everyone should wear a mask (N95 recommended). Avoid outdoor activities.';
} else if (aqi <= 300) {
status = 'Very Poor';
color = '#FF0000'; // Red
advice = 'Health alert: serious health effects possible. Wear N95 mask. Stay indoors.';
} else {
status = 'Severe';
color = '#C00000'; // Dark Red
advice = 'Emergency conditions. The entire population is likely to be affected.';
}
return {
aqi,
status,
color,
advice,
pollutant,
location
};
};

183
services/calendarService.ts Normal file
View File

@ -0,0 +1,183 @@
import { NepaliDate } from '../types';
// DATA FOR 2082 BS (2025-2026) extracted from Official Calendar PDF
export const NEPALI_MONTHS_DATA_2082 = [
{ id: 1, nameEn: 'Baishakh', nameNp: 'बैशाख', days: 31, startAdDate: '2025-04-14', startWeekday: 1 }, // Monday
{ id: 2, nameEn: 'Jestha', nameNp: 'जेठ', days: 32, startAdDate: '2025-05-15', startWeekday: 4 }, // Thursday
{ id: 3, nameEn: 'Ashadh', nameNp: 'असार', days: 32, startAdDate: '2025-06-16', startWeekday: 0 }, // Sunday
{ id: 4, nameEn: 'Shrawan', nameNp: 'साउन', days: 32, startAdDate: '2025-07-18', startWeekday: 4 }, // Thursday
{ id: 5, nameEn: 'Bhadra', nameNp: 'भदौ', days: 31, startAdDate: '2025-08-19', startWeekday: 0 }, // Sunday
{ id: 6, nameEn: 'Ashwin', nameNp: 'असोज', days: 30, startAdDate: '2025-09-19', startWeekday: 3 }, // Wednesday
{ id: 7, nameEn: 'Kartik', nameNp: 'कात्तिक', days: 30, startAdDate: '2025-10-19', startWeekday: 5 }, // Friday
{ id: 8, nameEn: 'Mangsir', nameNp: 'मंसिर', days: 29, startAdDate: '2025-11-18', startWeekday: 0 }, // Sunday
{ id: 9, nameEn: 'Poush', nameNp: 'पुष', days: 30, startAdDate: '2025-12-17', startWeekday: 1 }, // Monday
{ id: 10, nameEn: 'Magh', nameNp: 'माघ', days: 29, startAdDate: '2026-01-16', startWeekday: 3 }, // Wednesday
{ id: 11, nameEn: 'Falgun', nameNp: 'फागुन', days: 30, startAdDate: '2026-02-14', startWeekday: 4 }, // Thursday
{ id: 12, nameEn: 'Chaitra', nameNp: 'चैत', days: 30, startAdDate: '2026-03-16', startWeekday: 6 }, // Saturday
];
const HOLIDAYS_2082: Record<string, {en: string, ne: string}> = {
// Baishakh
"1-1": {en: "New Year 2082", ne: "नयाँ वर्ष २०८२"},
"1-11": {en: "Loktantra Diwas", ne: "लोकतन्त्र दिवस"},
"1-15": {en: "Matatirtha Aunsi (Mother's Day)", ne: "मातातीर्थ औंसी"},
"1-18": {en: "Labour Day", ne: "विश्व मजदुर दिवस"},
"1-25": {en: "Buddha Jayanti", ne: "बुद्ध जयन्ती"},
// Jestha
"2-15": {en: "Republic Day", ne: "गणतन्त्र दिवस"},
// Ashadh
"3-15": {en: "Dhan Diwas", ne: "धान दिवस"},
"3-29": {en: "Bhanu Jayanti", ne: "भानु जयन्ती"},
// Shrawan
"4-15": {en: "Khir Khane Din", ne: "खिर खाने दिन"},
"4-24": {en: "Janai Purnima", ne: "जनै पूर्णिमा"},
"4-25": {en: "Gai Jatra", ne: "गाईजात्रा"},
// Bhadra
"5-7": {en: "Gaura Parva", ne: "गौरा पर्व"},
"5-10": {en: "Haritalika Teej", ne: "हरितालिका तीज"},
"5-24": {en: "Indra Jatra", ne: "इन्द्रजात्रा"},
// Ashwin
"6-3": {en: "Constitution Day", ne: "संविधान दिवस"},
"6-28": {en: "Ghatasthapana", ne: "घटस्थापना"},
// Kartik (Dashain & Tihar)
"7-7": {en: "Vijaya Dashami", ne: "विजया दशमी"},
"7-30": {en: "Laxmi Puja", ne: "लक्ष्मी पूजा"},
// Mangsir
"8-1": {en: "Mha Puja / Nepal Sambat", ne: "म्हपूजा / नेपाल संवत्"},
"8-6": {en: "Chhath Parva", ne: "छठ पर्व"},
// Poush
"9-10": {en: "Christmas Day", ne: "क्रिसमस डे"},
"9-15": {en: "Tamu Lhosar", ne: "तमु ल्होछार"},
"9-27": {en: "Prithvi Jayanti", ne: "पृथ्वी जयन्ती"},
// Magh
"10-1": {en: "Maghe Sankranti", ne: "माघे संक्रान्ति"},
"10-16": {en: "Martyrs Day", ne: "शहिद दिवस"},
"10-28": {en: "Sonam Lhosar", ne: "सोनाम ल्होछार"},
// Falgun
"11-7": {en: "Democracy Day", ne: "प्रजातन्त्र दिवस"},
"11-15": {en: "Maha Shivaratri", ne: "महा शिवरात्री"}, // Corrected Date
"11-24": {en: "Women's Day", ne: "नारी दिवस"},
"11-28": {en: "Gyalpo Lhosar", ne: "ग्याल्पो ल्होछार"},
// Chaitra
"12-13": {en: "Holi (Hilly)", ne: "फागु पूर्णिमा (पहाड)"},
"12-14": {en: "Holi (Terai)", ne: "फागु पूर्णिमा (तराई)"},
"12-25": {en: "Ghode Jatra", ne: "घोडेजात्रा"},
};
const HOLIDAY_CACHE_KEY = 'rudraksha_holiday_explanations';
export const CalendarService = {
// Helper to calculate current Nepali date based on Anchor (April 14 2025 = 1/1/2082)
getCurrentNepaliDate: (): { year: number, month: number, day: number } => {
const anchorAd = new Date('2025-04-14');
const now = new Date();
// Difference in days
const diffTime = Math.abs(now.getTime() - anchorAd.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// If we are before the start of 2082 (unlikely in this simulation context, but handle it)
if (now < anchorAd) return { year: 2081, month: 12, day: 30 }; // Fallback
let remainingDays = diffDays;
let currentMonthIndex = 0;
// Iterate through months to find current
while (remainingDays > 0 && currentMonthIndex < NEPALI_MONTHS_DATA_2082.length) {
const daysInMonth = NEPALI_MONTHS_DATA_2082[currentMonthIndex].days;
if (remainingDays <= daysInMonth) {
return {
year: 2082,
month: currentMonthIndex + 1,
day: remainingDays
};
}
remainingDays -= daysInMonth;
currentMonthIndex++;
}
// Fallback if date is beyond the array
return { year: 2082, month: 12, day: 30 };
},
getDatesForMonth: async (year: number, month: number): Promise<NepaliDate[]> => {
// We are using the 2082 Calendar data explicitly
const displayYear = 2082;
// Find month data, fallback to first month if not found
const monthData = NEPALI_MONTHS_DATA_2082.find(m => m.id === month) || NEPALI_MONTHS_DATA_2082[0];
const dates: NepaliDate[] = [];
let currentWeekday = monthData.startWeekday; // 0=Sun, 1=Mon, etc.
let adDate = new Date(monthData.startAdDate);
for (let day = 1; day <= monthData.days; day++) {
const dateKey = `${month}-${day}`;
const holiday = HOLIDAYS_2082[dateKey];
const weekdayEn = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][currentWeekday % 7];
const weekdayNp = ['आइत', 'सोम', 'मंगल', 'बुध', 'बिही', 'शुक्र', 'शनि'][currentWeekday % 7];
// Saturdays are always holidays in Nepal
const isSaturday = currentWeekday % 7 === 6;
dates.push({
bs_year: displayYear,
bs_month: month,
bs_day: day,
ad_year: adDate.getFullYear(),
ad_month: adDate.getMonth() + 1,
ad_day: adDate.getDate(),
weekday_str_en: weekdayEn,
weekday_str_np: weekdayNp,
bs_month_str_en: monthData.nameEn,
bs_month_str_np: monthData.nameNp,
tithi_str_en: '',
tithi_str_np: '',
is_holiday: !!holiday || isSaturday,
events: holiday ? [{
strEn: holiday.en,
strNp: holiday.ne,
isHoliday: true
}] : []
});
// Increment AD Date
adDate.setDate(adDate.getDate() + 1);
currentWeekday++;
}
return dates;
},
getHolidayExplanation: async (holidayName: string): Promise<{en: string, ne: string} | null> => {
const cachedStr = localStorage.getItem(HOLIDAY_CACHE_KEY);
if (cachedStr) {
const cache = JSON.parse(cachedStr);
if (cache[holidayName]) {
return cache[holidayName];
}
}
return null;
},
saveHolidayExplanation: async (holidayName: string, en: string, ne: string) => {
const cachedStr = localStorage.getItem(HOLIDAY_CACHE_KEY);
const cache = cachedStr ? JSON.parse(cachedStr) : {};
cache[holidayName] = { en, ne };
localStorage.setItem(HOLIDAY_CACHE_KEY, JSON.stringify(cache));
}
};

58
services/ftl/storage.ts Normal file
View File

@ -0,0 +1,58 @@
import { FTLMission, Sighting } from '../../types';
const MISSIONS_KEY = 'rudraksha_ftl_missions';
export const FTLStorageService = {
getMissions: async (): Promise<FTLMission[]> => {
const stored = localStorage.getItem(MISSIONS_KEY);
return stored ? JSON.parse(stored) : [];
},
saveMission: async (mission: FTLMission): Promise<void> => {
const missions = await FTLStorageService.getMissions();
const idx = missions.findIndex(m => m.id === mission.id);
if (idx >= 0) missions[idx] = mission;
else missions.unshift(mission);
localStorage.setItem(MISSIONS_KEY, JSON.stringify(missions));
},
deleteMission: async (missionId: string): Promise<void> => {
const missions = await FTLStorageService.getMissions();
localStorage.setItem(MISSIONS_KEY, JSON.stringify(missions.filter(m => m.id !== missionId)));
},
addSighting: async (missionId: string, sighting: Sighting): Promise<void> => {
const missions = await FTLStorageService.getMissions();
const idx = missions.findIndex(m => m.id === missionId);
if (idx >= 0) {
missions[idx].sightings.unshift(sighting);
localStorage.setItem(MISSIONS_KEY, JSON.stringify(missions));
}
},
verifySighting: async (missionId: string, sightingId: string, verified: boolean): Promise<void> => {
const missions = await FTLStorageService.getMissions();
const mIdx = missions.findIndex(m => m.id === missionId);
if (mIdx >= 0) {
const sIdx = missions[mIdx].sightings.findIndex(s => s.id === sightingId);
if (sIdx >= 0) {
if (verified) {
missions[mIdx].sightings[sIdx].isVerified = true;
} else {
missions[mIdx].sightings[sIdx].isVerified = false;
}
localStorage.setItem(MISSIONS_KEY, JSON.stringify(missions));
}
}
},
resolveMission: async (missionId: string): Promise<void> => {
const missions = await FTLStorageService.getMissions();
const idx = missions.findIndex(m => m.id === missionId);
if (idx >= 0) {
missions[idx].status = 'resolved';
localStorage.setItem(MISSIONS_KEY, JSON.stringify(missions));
}
}
};

326
services/geminiService.ts Normal file
View File

@ -0,0 +1,326 @@
import { GoogleGenAI, Chat, GenerateContentResponse, Type } from "@google/genai";
import { TriviaQuestion, FTLMission, ChatMessage } from '../types';
import { LOCAL_LEXICON } from '../ai-voice-model/ai';
// --- UNIFIED RUDRA AI SYSTEM INSTRUCTION ---
const RUDRA_SYSTEM_INSTRUCTION = `You are Rudra (रुद्र), a witty, warm, and knowledgeable Nepali companion.
**CORE PERSONA:**
- **Tone:** You are NOT a robot. You are a "Janne Manchhe" (Knowledgeable person) or a helpful "Sathi" (Friend).
- **Style:** Speak naturally. Use short, punchy sentences. Avoid long, boring lists unless specifically asked.
- **Language:**
- In English: Use a slight South Asian flair but keep it professional.
- In Nepali: Use "Hajur", "Tapai" respectfully but naturally.
- In Newari (optional): Use "Jwajalapa" for greetings.
**CRITICAL OUTPUT PROTOCOL:**
You MUST respond in a strict JSON format for EVERY text-based interaction (unless using tools).
Do NOT output plain text.
Structure:
{
"en": "English conversational response (keep it short!)",
"ne": "Nepali conversational response (Devanagari, keep it natural)",
"newa": "Optional Newari phrase if relevant",
"type": "text" | "quiz"
}
**KNOWLEDGE BASE:**
${LOCAL_LEXICON}
**TRIVIA MODE:**
If asked for a quiz, set "type": "quiz" and provide valid JSON data. Focus heavily on Nepal's History, Geography, and Culture.
`;
export const createStudyChat = (): Chat => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
return ai.chats.create({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: RUDRA_SYSTEM_INSTRUCTION,
temperature: 0.8, // Slightly higher for more "human" variance
responseMimeType: 'application/json'
},
});
};
export const getCookingChatSession = (): Chat => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
return ai.chats.create({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: `You are **Bhanse Dai (भान्से दाइ)**, an expert Nepali Chef.
**CORE INSTRUCTIONS:**
1. You are warm, encouraging, and passionate about Nepali heritage cuisine.
2. You MUST provide responses in BOTH English and Nepali (Devanagari) for every turn.
3. Use the following JSON format strictly:
{
"en": "English response here...",
"ne": "नेपाली जवाफ यहाँ..."
}
**CONTENT GUIDELINES:**
- If asked for a recipe, provide ingredients and brief steps in both languages within the JSON fields.
- Use terms like "Mitho cha!" (Tasty!) or "Bhat pakauna garo chaina" (Cooking rice isn't hard).
- Keep recipes concise.
`,
responseMimeType: 'application/json'
}
});
};
export const generateSummary = async (text: string): Promise<string> => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: `Summarize this text. Return JSON: { "en": "English summary", "ne": "Nepali summary" }.\n\n${text}`,
config: { responseMimeType: 'application/json' }
});
const json = JSON.parse(response.text || '{}');
return JSON.stringify(json); // Return stringified JSON for component to parse
};
export const translateText = async (text: string, targetLang: 'en' | 'ne'): Promise<string> => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const prompt = `Translate "${text}" to ${targetLang === 'en' ? 'English' : 'Nepali'}. Return plain text string.`;
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: prompt
});
return response.text || text;
};
export const analyzeImage = async (base64Image: string, prompt: string): Promise<string> => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
// Extract proper base64 data (remove header if present)
const base64Data = base64Image.split(',')[1] || base64Image;
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-image', // Specialized visual model
contents: {
parts: [
{ text: prompt },
{
inlineData: {
mimeType: 'image/jpeg',
data: base64Data
}
}
]
},
config: {
systemInstruction: RUDRA_SYSTEM_INSTRUCTION,
responseMimeType: 'application/json'
}
});
return response.text || "{}";
} catch (e) {
console.error("Image Analysis Error", e);
return JSON.stringify({
en: "I'm having trouble seeing the image clearly. Please try again.",
ne: "मैले तस्विर स्पष्ट देख्न सकिन। कृपया पुनः प्रयास गर्नुहोस्।"
});
}
};
export const analyzeVideo = async (base64Video: string, mimeType: string, prompt: string): Promise<string> => {
// Deprecated for UI but kept for API completeness if needed elsewhere
return "{}";
};
export const generateTrivia = async (topic: string, language: 'en' | 'ne' = 'en'): Promise<TriviaQuestion[]> => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const langInstruction = language === 'ne' ? 'Generate questions and options in Nepali language (Devanagari).' : 'Generate questions and options in English.';
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: `Generate 5 multiple choice trivia questions about "${topic}".
${langInstruction}
Focus on Nepal context if possible (History, Geography, Culture, Nature).
Return ONLY a JSON array with objects containing:
- question (string)
- options (array of 4 strings)
- correctAnswer (index 0-3, number)
- explanation (short string in ${language === 'ne' ? 'Nepali' : 'English'})
Ensure questions are interesting and not too easy.`,
config: { responseMimeType: 'application/json' }
});
try {
return JSON.parse(response.text || "[]");
} catch (e) {
return [];
}
};
export const explainHoliday = async (holidayName: string): Promise<{ en: string, ne: string }> => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: `Explain the significance of the Nepali festival/holiday "${holidayName}".
Provide a short explanation in English and Nepali.
Return JSON: { "en": "string", "ne": "string" }`,
config: { responseMimeType: 'application/json' }
});
try {
return JSON.parse(response.text || '{"en": "Info unavailable", "ne": "विवरण उपलब्ध छैन"}');
} catch {
return { en: "AI Error", ne: "त्रुटि" };
}
};
export const matchEmergencyReports = async (foundDescription: string, activeMissions: FTLMission[]) => {
const lostMissions = activeMissions.filter(m => m.status === 'active' && m.isLost);
if (lostMissions.length === 0) return { matchId: null, confidence: 0, reasoning: "No active lost reports." };
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const prompt = `
I have found an item/person with this description: "${foundDescription}".
Here is a list of active "Lost" reports:
${JSON.stringify(lostMissions.map(m => ({ id: m.id, title: m.title, desc: m.description, type: m.type })))}
Analyze if the found item matches any of the lost reports.
Return JSON: { "matchId": "string or null", "confidence": number (0-100), "reasoning": "string" }
`;
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: prompt,
config: { responseMimeType: 'application/json' }
});
try {
return JSON.parse(response.text || '{}');
} catch {
return { matchId: null, confidence: 0 };
}
};
export const interpretDream = async (dreamText: string) => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const prompt = `
Act as a mystical interpreter of dreams, combining Nepali folklore/superstition with modern psychology.
Dream: "${dreamText}"
Return JSON with both English (en) and Nepali (ne) translations:
{
"folklore": {
"en": "Traditional interpretation in English",
"ne": "Traditional interpretation in Nepali (Devanagari)"
},
"psychology": {
"en": "Modern psychological view in English",
"ne": "Modern psychological view in Nepali (Devanagari)"
},
"symbol": "One key symbol from the dream"
}
`;
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: prompt,
config: { responseMimeType: 'application/json' }
});
try {
return JSON.parse(response.text || '{}');
} catch {
return {
folklore: { en: "The mists are too thick.", ne: "कुहिरो धेरै बाक्लो छ।" },
psychology: { en: "Unclear data.", ne: "स्पष्ट डाटा छैन।" },
symbol: "?"
};
}
};
export const createHealthAssistant = (history: any[], isNepali: boolean) => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
return ai.chats.create({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: `You are Prana AI (प्राण), a holistic health coach for a user in Nepal.
Context: The user has provided their recent health logs: ${JSON.stringify(history)}.
Style:
- Language: ${isNepali ? 'Nepali' : 'English'}
- Tone: Encouraging, knowledgeable about Ayurveda and modern science.
- If data shows low water/sleep, gently remind them.
- Keep responses conversational and short (under 50 words).`
}
});
};
export const searchPustakalaya = async (query: string) => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: `User is searching for "${query}" in the context of the Nepali National Library (pustakalaya.org) or CDC.
Provide a helpful summary of what materials likely exist for this topic (e.g., Grade 10 Science textbooks, Munamadan, etc.).
Then provide 3 plausible direct links (mock them as likely URLs on pustakalaya.org).
Return JSON: { "text": "Summary string...", "links": [{ "title": "Book Title", "uri": "https://pustakalaya.org/..." }] }`,
config: { responseMimeType: 'application/json' }
});
try {
return JSON.parse(response.text || '{"text": "Search failed.", "links": []}');
} catch {
return { text: "Could not access library database.", links: [] };
}
};
export const connectToGuruLive = (params: any) => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
return ai.live.connect(params);
};
export const encodeAudio = (data: Uint8Array) => {
let binary = '';
const len = data.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(data[i]);
}
return btoa(binary);
};
export const decodeAudio = (base64: string) => {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
export const decodeAudioData = async (
data: Uint8Array,
ctx: AudioContext,
sampleRate: number,
numChannels: number,
): Promise<AudioBuffer> => {
const dataInt16 = new Int16Array(data.buffer);
const frameCount = dataInt16.length / numChannels;
const buffer = ctx.createBuffer(numChannels, frameCount, sampleRate);
for (let channel = 0; channel < numChannels; channel++) {
const channelData = buffer.getChannelData(channel);
for (let i = 0; i < frameCount; i++) {
channelData[i] = dataInt16[i * numChannels + channel] / 32768.0;
}
}
return buffer;
};
export const requestMicPermission = async () => {
try {
await navigator.mediaDevices.getUserMedia({ audio: true });
return true;
} catch {
return false;
}
};

View File

@ -0,0 +1,10 @@
import { HeritageSite } from '../types';
import { HERITAGE_SITES } from '../data/staticData';
export const HeritageService = {
getAllSites: async (): Promise<HeritageSite[]> => {
// Return high quality local data for the demo to ensure images load
return HERITAGE_SITES;
}
};

View File

@ -0,0 +1,36 @@
export const NotificationService = {
requestPermission: async (): Promise<boolean> => {
if (!('Notification' in window)) {
console.warn('This browser does not support desktop notification');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
},
send: (title: string, options?: NotificationOptions) => {
if (!('Notification' in window)) return;
if (Notification.permission === 'granted') {
try {
new Notification(title, {
icon: '/favicon.ico', // Fallback icon
badge: '/favicon.ico',
...options
});
} catch (e) {
console.error("Notification failed", e);
}
}
},
hasPermission: (): boolean => {
return 'Notification' in window && Notification.permission === 'granted';
}
};

View File

@ -0,0 +1,61 @@
// LOCAL STORAGE KEY
const PLATFORM_KEY = 'rudraksha_linked_platforms';
export interface ConnectedAccount {
provider: 'facebook' | 'google';
connected: boolean;
username?: string;
email?: string;
friends?: string[]; // Mock list of friends on that platform
}
export const PlatformService = {
getConnections: (): ConnectedAccount[] => {
const stored = localStorage.getItem(PLATFORM_KEY);
return stored ? JSON.parse(stored) : [];
},
isConnected: (provider: 'facebook' | 'google'): boolean => {
const connections = PlatformService.getConnections();
return connections.some(c => c.provider === provider && c.connected);
},
connect: (provider: 'facebook' | 'google', data?: Partial<ConnectedAccount>): void => {
const connections = PlatformService.getConnections();
const existingIdx = connections.findIndex(c => c.provider === provider);
const newConnection: ConnectedAccount = {
provider,
connected: true,
username: data?.username || (provider === 'facebook' ? 'fb_user' : 'google_user'),
email: data?.email,
friends: provider === 'facebook' ? ['Aarav', 'Sita', 'Ramesh', 'Hari', 'Gita'] : []
};
if (existingIdx >= 0) {
connections[existingIdx] = { ...connections[existingIdx], ...newConnection };
} else {
connections.push(newConnection);
}
localStorage.setItem(PLATFORM_KEY, JSON.stringify(connections));
},
disconnect: (provider: 'facebook' | 'google'): void => {
const connections = PlatformService.getConnections();
const filtered = connections.filter(c => c.provider !== provider);
localStorage.setItem(PLATFORM_KEY, JSON.stringify(filtered));
},
// Simulates searching for a friend on the external platform
findFriend: (provider: 'facebook', query: string): string | null => {
if (!PlatformService.isConnected(provider)) return null;
const connections = PlatformService.getConnections();
const account = connections.find(c => c.provider === provider);
if (!account || !account.friends) return null;
const match = account.friends.find(f => f.toLowerCase().includes(query.toLowerCase()));
return match || null;
}
};

33
services/rewardService.ts Normal file
View File

@ -0,0 +1,33 @@
import { StorageService } from './storageService';
export const RewardService = {
transferKarma: async (senderId: string, receiverId: string, amount: number): Promise<{ success: boolean; message: string }> => {
try {
if (senderId === receiverId) return { success: false, message: "Cannot send karma to self." };
if (amount <= 0) return { success: false, message: "Amount must be positive." };
const users = await StorageService.getAvailableUsers(); // This will help us find the receiver safely if in list
// Note: We need full access to update users, so we'll access StorageService internal logic via public methods where possible
// But StorageService.rewardUser updates ANY user by ID, which is what we want.
const sender = await StorageService.getProfile();
if (!sender || sender.id !== senderId) return { success: false, message: "Authentication error." };
if (sender.points < amount) {
return { success: false, message: `Insufficient Karma. Balance: ${sender.points}` };
}
// Deduct from sender
await StorageService.updateProfile({ points: sender.points - amount });
// Add to receiver
await StorageService.rewardUser(receiverId, amount);
return { success: true, message: `Transferred ${amount} Karma successfully.` };
} catch (e) {
console.error(e);
return { success: false, message: "Transaction failed." };
}
}
};

861
services/storageService.ts Normal file
View File

@ -0,0 +1,861 @@
import {
UserProfile, Task, FTLMission, Sighting, StudyNote, AppSettings,
Recipe, Review, ChatMessage, HealthLog, ChatGroup, CommunityMessage,
Book, StudySession, TaskStatus, RescueTag, ArcadeTask, DirectMessage, Transaction
} from '../types';
import { INITIAL_RECIPES } from '../data/staticData';
// LOCAL STORAGE KEYS
const TASKS_KEY = 'rudraksha_tasks';
const PROFILE_KEY = 'rudraksha_profile';
const USERS_KEY = 'rudraksha_users';
const HEALTH_KEY = 'rudraksha_health';
const RECIPES_KEY = 'rudraksha_recipes';
const NOTES_KEY = 'rudraksha_notes';
const REVIEWS_KEY = 'rudraksha_reviews';
const SESSIONS_KEY = 'rudraksha_study_sessions';
const GAME_SESSIONS_KEY = 'rudraksha_game_sessions';
const BOOKS_KEY = 'rudraksha_books';
const CHAT_KEY = 'rudraksha_community_chat';
const DM_KEY = 'rudraksha_direct_messages';
const GROUPS_KEY = 'rudraksha_groups';
const MISSIONS_KEY = 'rudraksha_ftl_missions';
const TAGS_KEY = 'rudraksha_rescue_tags';
const ARCADE_TASKS_KEY = 'rudraksha_arcade_daily_tasks';
const SETTINGS_KEY = 'rudraksha_settings';
const AUTH_KEY = 'rudraksha_is_authenticated';
const STUDY_CHAT_HISTORY_KEY = 'rudraksha_study_chat_history';
const RUDRA_GLOBAL_CHAT_KEY = 'rudraksha_global_chat_history';
const TRANSACTIONS_KEY = 'rudraksha_transactions';
const YOGA_TRACKING_KEY = 'rudraksha_yoga_daily_tracking';
const DEFAULT_SETTINGS: AppSettings = {
soundEnabled: true,
hapticFeedback: true,
autoFocusMode: false,
dataSaver: false,
broadcastRadius: 5,
language: 'en',
gpsAccuracy: 'high',
notifications: {
studyReminders: true,
communityAlerts: true,
arcadeTasks: false
},
permissions: {
camera: false,
microphone: false,
location: false
}
};
const safeSetItem = (key: string, value: string, useSession: boolean = false) => {
try {
if (useSession) sessionStorage.setItem(key, value);
else localStorage.setItem(key, value);
} catch (e: any) {
console.error("Storage Quota Exceeded.");
}
};
const getItem = (key: string): string | null => {
return localStorage.getItem(key) || sessionStorage.getItem(key);
};
export const StorageService = {
isAuthenticated: async (): Promise<boolean> => {
return getItem(AUTH_KEY) === 'true';
},
login: async (email: string, password?: string, remember: boolean = true): Promise<{ success: boolean; error?: string }> => {
const usersStr = localStorage.getItem(USERS_KEY);
let users: any[] = usersStr ? JSON.parse(usersStr) : [];
const localUser = users.find(u => u.email === email && u.password === password);
localStorage.removeItem(AUTH_KEY);
localStorage.removeItem(PROFILE_KEY);
sessionStorage.removeItem(AUTH_KEY);
sessionStorage.removeItem(PROFILE_KEY);
if (localUser) {
safeSetItem(AUTH_KEY, 'true', !remember);
safeSetItem(PROFILE_KEY, JSON.stringify(localUser), !remember);
return { success: true };
}
let demoProfile: UserProfile | null = null;
if (email === 'admin@gmail.com' && password === 'admin123@') {
demoProfile = {
id: 'admin-test-id', name: 'Super Admin', username: 'admin', email: 'admin@gmail.com', role: 'teacher', schoolName: 'Rudraksha HQ', points: 9999, xp: 50000, createdAt: Date.now(), avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Admin', profession: 'Teacher',
bio: "Building the future of digital Nepal, one block at a time. System Administrator.",
highScores: {} // Reset to 0
};
} else if (email === 'student@demo.com' && password === 'demo123') {
demoProfile = {
id: 'student-demo-id', name: 'Aarav Sharma', username: 'aarav_sharma', email: 'student@demo.com', role: 'student', schoolName: 'Durbar High School', grade: '10', points: 450, xp: 1200, createdAt: Date.now(), avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aarav', profession: 'Student',
bio: "Science enthusiast and avid gamer. Dreaming of Space Exploration! 🚀",
highScores: {} // Reset to 0
};
} else if (email === 'teacher@demo.com' && password === 'demo123') {
demoProfile = {
id: 'teacher-demo-id', name: 'Sita Miss', username: 'sita_guru', email: 'teacher@demo.com', role: 'teacher', schoolName: 'Durbar High School', points: 1200, xp: 2500, createdAt: Date.now(), avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=unicorn&backgroundColor=transparent', profession: 'Teacher', frameId: 'unicorn',
bio: "Teaching is the greatest act of optimism. Passionate about History and Nepali Literature.",
highScores: {} // Reset to 0
};
}
if (demoProfile) {
const existingIndex = users.findIndex(u => u.id === demoProfile!.id);
if (existingIndex === -1) { users.push({ ...demoProfile, password }); }
else {
// Preserve existing high scores if any, otherwise use empty object
const existing = users[existingIndex];
demoProfile = { ...existing, ...demoProfile, highScores: existing.highScores || {} };
users[existingIndex] = demoProfile;
}
safeSetItem(USERS_KEY, JSON.stringify(users), false);
safeSetItem(AUTH_KEY, 'true', !remember);
safeSetItem(PROFILE_KEY, JSON.stringify(demoProfile), !remember);
return { success: true };
}
return { success: false, error: "Invalid credentials." };
},
logout: async () => {
localStorage.removeItem(AUTH_KEY);
localStorage.removeItem(PROFILE_KEY);
sessionStorage.removeItem(AUTH_KEY);
sessionStorage.removeItem(PROFILE_KEY);
},
register: async (profile: UserProfile, password?: string, remember: boolean = true): Promise<{ success: boolean; error?: string }> => {
const usersStr = localStorage.getItem(USERS_KEY);
const users: any[] = usersStr ? JSON.parse(usersStr) : [];
if (users.find(u => u.email === profile.email)) return { success: false, error: "Email exists." };
const newProfile = { ...profile, id: Date.now().toString(), password, points: 0, xp: 0, createdAt: Date.now() };
users.push(newProfile);
safeSetItem(USERS_KEY, JSON.stringify(users), false);
safeSetItem(AUTH_KEY, 'true', !remember);
safeSetItem(PROFILE_KEY, JSON.stringify(newProfile), !remember);
return { success: true };
},
getProfile: async (): Promise<UserProfile | null> => {
const stored = getItem(PROFILE_KEY);
return stored ? JSON.parse(stored) : null;
},
updateProfile: async (updates: Partial<UserProfile>): Promise<UserProfile | null> => {
const current = await StorageService.getProfile();
if (!current) return null;
const updated = { ...current, ...updates };
if (sessionStorage.getItem(PROFILE_KEY)) safeSetItem(PROFILE_KEY, JSON.stringify(updated), true);
else safeSetItem(PROFILE_KEY, JSON.stringify(updated), false);
const usersStr = localStorage.getItem(USERS_KEY);
if (usersStr) {
let users: any[] = JSON.parse(usersStr);
const idx = users.findIndex(u => u.id === current.id);
if (idx !== -1) { users[idx] = { ...users[idx], ...updates }; safeSetItem(USERS_KEY, JSON.stringify(users), false); }
}
return updated;
},
getSettings: async (): Promise<AppSettings> => {
const stored = localStorage.getItem(SETTINGS_KEY);
return stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : DEFAULT_SETTINGS;
},
saveSettings: async (settings: AppSettings): Promise<void> => {
safeSetItem(SETTINGS_KEY, JSON.stringify(settings));
},
addPoints: async (amount: number, xpAmount: number = 0, type: string = 'achievement', description: string = 'Points earned'): Promise<void> => {
const profile = await StorageService.getProfile();
if (profile) {
await StorageService.updateProfile({
points: (profile.points || 0) + amount,
xp: (profile.xp || 0) + (xpAmount || amount * 5)
});
// Log transaction
const txsStr = localStorage.getItem(TRANSACTIONS_KEY);
const txs: Transaction[] = txsStr ? JSON.parse(txsStr) : [];
txs.push({
id: Date.now(),
userId: profile.id,
amount,
type,
description,
timestamp: Date.now()
});
safeSetItem(TRANSACTIONS_KEY, JSON.stringify(txs), false);
}
},
// --- NEW YOGA TRACKING LOGIC ---
trackYogaSession: async (poseId: string): Promise<{ awarded: boolean, points: number, message: string }> => {
const today = new Date().toISOString().split('T')[0];
const trackingStr = localStorage.getItem(YOGA_TRACKING_KEY);
const trackingData = trackingStr ? JSON.parse(trackingStr) : {};
// Initialize or retrieve today's record
const todayLog = trackingData[today] || { totalPoints: 0, completedPoses: [] };
const DAILY_LIMIT = 100;
const POSE_REWARD = 50;
// Check constraints
if (todayLog.completedPoses.includes(poseId)) {
return { awarded: false, points: 0, message: "Already completed this flow today." };
}
if (todayLog.totalPoints >= DAILY_LIMIT) {
return { awarded: false, points: 0, message: "Daily Yoga Karma limit reached (100/100)." };
}
// Process Award
await StorageService.addPoints(POSE_REWARD, 20, 'yoga_session', `Yoga Flow Completed`);
// Update Log
todayLog.totalPoints += POSE_REWARD;
todayLog.completedPoses.push(poseId);
trackingData[today] = todayLog;
// Clean up old entries to save space (keep last 7 days)
const keys = Object.keys(trackingData).sort();
if (keys.length > 7) {
delete trackingData[keys[0]];
}
safeSetItem(YOGA_TRACKING_KEY, JSON.stringify(trackingData), false);
return { awarded: true, points: POSE_REWARD, message: "Flow Complete! Karma Awarded." };
},
// ------------------------------
claimDailyBonus: async (): Promise<{ success: boolean; amount: number; message: string }> => {
const profile = await StorageService.getProfile();
if (!profile) return { success: false, amount: 0, message: "Not logged in" };
const now = Date.now();
const lastClaim = profile.lastDailyClaim || 0;
const hoursSince = (now - lastClaim) / (1000 * 60 * 60);
if (hoursSince < 24) {
const hoursLeft = Math.ceil(24 - hoursSince);
return { success: false, amount: 0, message: `Wait ${hoursLeft} hours` };
}
const bonus = 100;
await StorageService.addPoints(bonus, 50, 'daily_bonus', 'Daily Login Reward');
await StorageService.updateProfile({ lastDailyClaim: now });
return { success: true, amount: bonus, message: "Daily Bonus Claimed!" };
},
purchaseSubscription: async (tier: 'weekly' | 'monthly' | 'lifetime', cost: number, currency: 'karma' | 'money'): Promise<{ success: boolean; error?: string }> => {
const profile = await StorageService.getProfile();
if (!profile) return { success: false, error: "Not logged in" };
if (currency === 'karma') {
if (profile.points < cost) return { success: false, error: "Insufficient Karma points." };
// Deduct points
await StorageService.updateProfile({ points: profile.points - cost });
// Log Transaction
const txs = JSON.parse(localStorage.getItem(TRANSACTIONS_KEY) || '[]');
txs.push({
id: Date.now(),
userId: profile.id,
amount: -cost,
type: 'subscription',
description: `${tier.charAt(0).toUpperCase() + tier.slice(1)} Subscription`,
timestamp: Date.now()
});
safeSetItem(TRANSACTIONS_KEY, JSON.stringify(txs), false);
}
// Update Subscription Status
let expiry = Date.now();
if (tier === 'weekly') expiry += 7 * 24 * 60 * 60 * 1000;
else if (tier === 'monthly') expiry += 30 * 24 * 60 * 60 * 1000;
else if (tier === 'lifetime') expiry = 9999999999999;
await StorageService.updateProfile({
subscription: { tier, expiry }
});
return { success: true };
},
rewardUser: async (userId: string, amount: number): Promise<void> => {
const usersStr = localStorage.getItem(USERS_KEY);
if (usersStr) {
const users: UserProfile[] = JSON.parse(usersStr);
const idx = users.findIndex(u => u.id === userId);
if (idx !== -1) {
users[idx].points = (users[idx].points || 0) + amount;
safeSetItem(USERS_KEY, JSON.stringify(users), false);
const txs = JSON.parse(localStorage.getItem(TRANSACTIONS_KEY) || '[]');
txs.push({ id: Date.now(), userId, amount, type: 'reward', timestamp: Date.now() });
safeSetItem(TRANSACTIONS_KEY, JSON.stringify(txs), false);
const current = await StorageService.getProfile();
if (current && current.id === userId) await StorageService.updateProfile({ points: users[idx].points });
}
}
},
getTransactions: async (userId: string): Promise<Transaction[]> => {
const txsStr = localStorage.getItem(TRANSACTIONS_KEY);
const allTxs: Transaction[] = txsStr ? JSON.parse(txsStr) : [];
return allTxs.filter(tx => tx.userId === userId).sort((a, b) => b.timestamp - a.timestamp);
},
getAvailableUsers: async (): Promise<UserProfile[]> => {
const current = await StorageService.getProfile();
const usersStr = localStorage.getItem(USERS_KEY);
const allUsers: UserProfile[] = usersStr ? JSON.parse(usersStr) : [];
// virtual Rudra Profile
const rudra: UserProfile = {
id: 'rudra-ai-system',
name: 'Rudra Core',
username: 'rudra',
email: 'system@rudraksha.ai',
role: 'citizen',
points: 9999,
xp: 9999,
profession: 'System AI',
bio: 'I am the core intelligence of Rudraksha. I help guide, protect, and educate.',
avatarUrl: 'https://iili.io/fgyxLsn.md.png'
};
if (!current) return [rudra, ...allUsers];
const filtered = allUsers.filter(u => u.id !== current.id);
return [rudra, ...filtered];
},
getDirectMessages: async (otherUserId: string): Promise<DirectMessage[]> => {
const current = await StorageService.getProfile();
if (!current) return [];
const stored = localStorage.getItem(DM_KEY);
const allMsgs: DirectMessage[] = stored ? JSON.parse(stored) : [];
return allMsgs.filter(m =>
(m.senderId === current.id && m.receiverId === otherUserId) ||
(m.senderId === otherUserId && m.receiverId === current.id)
).sort((a, b) => a.timestamp - b.timestamp);
},
sendDirectMessage: async (receiverId: string, text: string, type: 'text' | 'image' | 'karma' = 'text', meta?: { imageUrl?: string, amount?: number, senderOverride?: string }): Promise<void> => {
const current = await StorageService.getProfile();
if (!current && !meta?.senderOverride) return;
const stored = localStorage.getItem(DM_KEY);
const allMsgs: DirectMessage[] = stored ? JSON.parse(stored) : [];
const newMsg: DirectMessage = {
id: Date.now().toString() + Math.random().toString().slice(2, 6),
senderId: meta?.senderOverride || current!.id,
receiverId: receiverId,
text: text,
timestamp: Date.now(),
read: false,
type: type,
imageUrl: meta?.imageUrl,
amount: meta?.amount
};
allMsgs.push(newMsg);
safeSetItem(DM_KEY, JSON.stringify(allMsgs));
},
sendFriendRequest: async (targetUserId: string): Promise<{ success: boolean; message: string }> => {
const current = await StorageService.getProfile();
if (!current) return { success: false, message: "Not logged in" };
const usersStr = localStorage.getItem(USERS_KEY);
if (!usersStr) return { success: false, message: "Error" };
const users: UserProfile[] = JSON.parse(usersStr);
const targetIndex = users.findIndex(u => u.id === targetUserId);
const currentIndex = users.findIndex(u => u.id === current.id);
if (targetIndex === -1 || currentIndex === -1) return { success: false, message: "User not found" };
// Update Target's friendRequests
const targetUser = users[targetIndex];
const requests = targetUser.friendRequests || [];
if (!requests.includes(current.id)) {
targetUser.friendRequests = [...requests, current.id];
}
// Update Current's sentRequests
const currentUser = users[currentIndex];
const sent = currentUser.sentRequests || [];
if (!sent.includes(targetUser.id)) {
currentUser.sentRequests = [...sent, targetUser.id];
}
users[targetIndex] = targetUser;
users[currentIndex] = currentUser;
safeSetItem(USERS_KEY, JSON.stringify(users), false);
await StorageService.updateProfile({ sentRequests: currentUser.sentRequests });
return { success: true, message: "Friend request sent!" };
},
acceptFriendRequest: async (requesterId: string): Promise<{ success: boolean; message: string }> => {
const current = await StorageService.getProfile();
if (!current) return { success: false, message: "Not logged in" };
const usersStr = localStorage.getItem(USERS_KEY);
if (!usersStr) return { success: false, message: "Error" };
const users: UserProfile[] = JSON.parse(usersStr);
const requesterIndex = users.findIndex(u => u.id === requesterId);
const currentIndex = users.findIndex(u => u.id === current.id);
if (requesterIndex === -1 || currentIndex === -1) return { success: false, message: "User not found" };
// Update Current User (Receiver)
const currentUser = users[currentIndex];
currentUser.friendRequests = (currentUser.friendRequests || []).filter(id => id !== requesterId);
currentUser.friends = [...(currentUser.friends || []), requesterId];
// Update Requester
const requester = users[requesterIndex];
requester.sentRequests = (requester.sentRequests || []).filter(id => id !== current.id);
requester.friends = [...(requester.friends || []), current.id];
users[currentIndex] = currentUser;
users[requesterIndex] = requester;
safeSetItem(USERS_KEY, JSON.stringify(users), false);
await StorageService.updateProfile({ friendRequests: currentUser.friendRequests, friends: currentUser.friends });
return { success: true, message: "Friend request accepted!" };
},
rejectFriendRequest: async (requesterId: string): Promise<{ success: boolean; message: string }> => {
const current = await StorageService.getProfile();
if (!current) return { success: false, message: "Not logged in" };
const usersStr = localStorage.getItem(USERS_KEY);
if (!usersStr) return { success: false, message: "Error" };
const users: UserProfile[] = JSON.parse(usersStr);
const requesterIndex = users.findIndex(u => u.id === requesterId);
const currentIndex = users.findIndex(u => u.id === current.id);
if (requesterIndex === -1 || currentIndex === -1) return { success: false, message: "User not found" };
// Update Current User
const currentUser = users[currentIndex];
currentUser.friendRequests = (currentUser.friendRequests || []).filter(id => id !== requesterId);
// Update Requester (remove sent request)
const requester = users[requesterIndex];
requester.sentRequests = (requester.sentRequests || []).filter(id => id !== current.id);
users[currentIndex] = currentUser;
users[requesterIndex] = requester;
safeSetItem(USERS_KEY, JSON.stringify(users), false);
await StorageService.updateProfile({ friendRequests: currentUser.friendRequests });
return { success: true, message: "Request declined." };
},
getMissions: async (): Promise<FTLMission[]> => {
const stored = localStorage.getItem(MISSIONS_KEY);
return stored ? JSON.parse(stored) : [];
},
saveMission: async (mission: FTLMission): Promise<void> => {
const missions = await StorageService.getMissions();
const idx = missions.findIndex(m => m.id === mission.id);
if (idx >= 0) missions[idx] = mission;
else missions.unshift(mission);
safeSetItem(MISSIONS_KEY, JSON.stringify(missions));
},
addSighting: async (missionId: string, sighting: Sighting): Promise<void> => {
const missions = await StorageService.getMissions();
const idx = missions.findIndex(m => m.id === missionId);
if (idx >= 0) {
missions[idx].sightings.unshift(sighting);
safeSetItem(MISSIONS_KEY, JSON.stringify(missions));
}
},
verifySighting: async (missionId: string, sightingId: string, verified: boolean): Promise<void> => {
const missions = await StorageService.getMissions();
const mIdx = missions.findIndex(m => m.id === missionId);
if (mIdx >= 0) {
const sIdx = missions[mIdx].sightings.findIndex(s => s.id === sightingId);
if (sIdx >= 0) {
(missions[mIdx].sightings[sIdx] as any).isVerified = verified;
safeSetItem(MISSIONS_KEY, JSON.stringify(missions));
}
}
},
resolveMission: async (missionId: string): Promise<void> => {
const missions = await StorageService.getMissions();
const idx = missions.findIndex(m => m.id === missionId);
if (idx >= 0) {
missions[idx].status = 'resolved';
safeSetItem(MISSIONS_KEY, JSON.stringify(missions));
}
},
getRescueTags: async (): Promise<RescueTag[]> => {
const stored = localStorage.getItem(TAGS_KEY);
return stored ? JSON.parse(stored) : [];
},
saveRescueTag: async (tag: RescueTag): Promise<void> => {
const tags = await StorageService.getRescueTags();
const idx = tags.findIndex(t => t.id === tag.id);
if (idx >= 0) tags[idx] = tag;
else tags.unshift(tag);
safeSetItem(TAGS_KEY, JSON.stringify(tags));
},
deleteRescueTag: async (tagId: string): Promise<void> => {
const tags = await StorageService.getRescueTags();
safeSetItem(TAGS_KEY, JSON.stringify(tags.filter(t => t.id !== tagId)));
},
getArcadeTasks: async (): Promise<ArcadeTask[]> => {
const stored = localStorage.getItem(ARCADE_TASKS_KEY);
return stored ? JSON.parse(stored) : [];
},
completeArcadeTask: async (taskId: string): Promise<void> => {
const tasks = await StorageService.getArcadeTasks();
const taskIndex = tasks.findIndex(t => t.id === taskId);
if (taskIndex !== -1 && !tasks[taskIndex].completed) {
tasks[taskIndex].completed = true;
safeSetItem(ARCADE_TASKS_KEY, JSON.stringify(tasks));
await StorageService.addPoints(tasks[taskIndex].reward, 0, 'achievement', 'Arcade Task Complete');
}
},
getTasks: async (): Promise<Task[]> => {
const stored = localStorage.getItem(TASKS_KEY);
const allTasks: Task[] = stored ? JSON.parse(stored) : [];
const profile = await StorageService.getProfile();
if (!profile) return [];
return profile.role === 'teacher' ? allTasks : allTasks.filter(t => t.userId === profile.id || t.isAssignment);
},
saveTask: async (task: Task): Promise<void> => {
const stored = localStorage.getItem(TASKS_KEY);
const tasks: Task[] = stored ? JSON.parse(stored) : [];
const idx = tasks.findIndex(t => t.id === task.id);
const newTasks = idx >= 0 ? tasks.map((t, i) => i === idx ? task : t) : [task, ...tasks];
safeSetItem(TASKS_KEY, JSON.stringify(newTasks));
if (task.status === TaskStatus.COMPLETED) {
const profile = await StorageService.getProfile();
if (profile) {
const completedCount = newTasks.filter(t => t.status === TaskStatus.COMPLETED && (t.userId === profile.id || t.isAssignment)).length;
if (completedCount >= 3 && !profile.unlockedItems?.includes('badge_scholar')) {
const newItems = [...(profile.unlockedItems || []), 'badge_scholar'];
await StorageService.updateProfile({ unlockedItems: newItems });
window.dispatchEvent(new CustomEvent('rudraksha-badge-unlock', {
detail: { title: 'Diligent Scholar', icon: 'graduation' }
}));
}
}
}
},
deleteTask: async (taskId: string): Promise<void> => {
const stored = localStorage.getItem(TASKS_KEY);
const tasks: Task[] = stored ? JSON.parse(stored) : [];
safeSetItem(TASKS_KEY, JSON.stringify(tasks.filter(t => t.id !== taskId)));
},
saveStudySession: async (session: StudySession): Promise<void> => {
const stored = localStorage.getItem(SESSIONS_KEY);
const sessions = stored ? JSON.parse(stored) : [];
sessions.push(session);
safeSetItem(SESSIONS_KEY, JSON.stringify(sessions));
},
getStudySessions: async (): Promise<StudySession[]> => {
const stored = localStorage.getItem(SESSIONS_KEY);
return stored ? JSON.parse(stored) : [];
},
// --- NEW GAME SESSION TRACKING ---
saveGameSession: async (gameId: string, durationSeconds: number): Promise<void> => {
const profile = await StorageService.getProfile();
if (!profile) return;
const stored = localStorage.getItem(GAME_SESSIONS_KEY);
const sessions = stored ? JSON.parse(stored) : [];
sessions.push({
id: Date.now().toString(),
userId: profile.id,
gameId,
durationSeconds,
timestamp: Date.now()
});
safeSetItem(GAME_SESSIONS_KEY, JSON.stringify(sessions));
},
getGameSessions: async (): Promise<{id: string, userId: string, gameId: string, durationSeconds: number, timestamp: number}[]> => {
const stored = localStorage.getItem(GAME_SESSIONS_KEY);
return stored ? JSON.parse(stored) : [];
},
// --------------------------------
getHealthLog: async (date: string): Promise<HealthLog> => {
const stored = localStorage.getItem(HEALTH_KEY);
const logs = stored ? JSON.parse(stored) : {};
return logs[date] || { date, waterGlasses: 0, mood: 'Neutral', sleepHours: 7 };
},
saveHealthLog: async (log: HealthLog): Promise<void> => {
const stored = localStorage.getItem(HEALTH_KEY);
const logs = stored ? JSON.parse(stored) : {};
logs[log.date] = log;
safeSetItem(HEALTH_KEY, JSON.stringify(logs));
},
getRecipes: async (sort?: string): Promise<Recipe[]> => {
const stored = localStorage.getItem(RECIPES_KEY);
const recipes: Recipe[] = stored ? JSON.parse(stored) : INITIAL_RECIPES;
if (sort === 'recent') return [...recipes].sort((a, b) => b.id.localeCompare(a.id));
return recipes;
},
saveRecipe: async (recipe: Recipe): Promise<void> => {
const recipes = await StorageService.getRecipes();
const idx = recipes.findIndex(r => r.id === recipe.id);
if (idx >= 0) recipes[idx] = recipe;
else recipes.unshift(recipe);
safeSetItem(RECIPES_KEY, JSON.stringify(recipes));
},
getNotes: async (): Promise<StudyNote[]> => {
const profile = await StorageService.getProfile();
if (!profile) return [];
const notesStr = localStorage.getItem(NOTES_KEY);
const allNotes: StudyNote[] = notesStr ? JSON.parse(notesStr) : [];
return allNotes.filter(n => n.userId === profile.id);
},
saveNote: async (note: Partial<StudyNote>): Promise<void> => {
const profile = await StorageService.getProfile();
if (!profile) return;
const notesStr = localStorage.getItem(NOTES_KEY);
const notes: StudyNote[] = notesStr ? JSON.parse(notesStr) : [];
if (note.id) {
const idx = notes.findIndex(n => n.id === note.id);
if (idx !== -1) notes[idx] = { ...notes[idx], ...note };
} else {
notes.push({ id: Date.now().toString(), userId: profile.id, title: note.title || 'Untitled', content: note.content || '', timestamp: Date.now(), color: note.color });
}
safeSetItem(NOTES_KEY, JSON.stringify(notes));
},
deleteNote: async (noteId: string): Promise<void> => {
const notesStr = localStorage.getItem(NOTES_KEY);
if (!notesStr) return;
const notes: StudyNote[] = JSON.parse(notesStr);
safeSetItem(NOTES_KEY, JSON.stringify(notes.filter(n => n.id !== noteId)));
},
getReviews: async (targetId: string): Promise<Review[]> => {
const reviewsStr = localStorage.getItem(REVIEWS_KEY);
if (!reviewsStr) return [];
const allReviews: Review[] = JSON.parse(reviewsStr);
return allReviews.filter(r => r.targetId === targetId).sort((a, b) => b.timestamp - a.timestamp);
},
addReview: async (review: Review): Promise<void> => {
const reviewsStr = localStorage.getItem(REVIEWS_KEY);
const allReviews: Review[] = reviewsStr ? JSON.parse(reviewsStr) : [];
allReviews.push(review);
safeSetItem(REVIEWS_KEY, JSON.stringify(allReviews));
},
getBooks: async (): Promise<Book[]> => {
const stored = localStorage.getItem(BOOKS_KEY);
return stored ? JSON.parse(stored) : [];
},
saveBook: async (book: Book): Promise<void> => {
const books = await StorageService.getBooks();
const idx = books.findIndex(b => b.id === book.id);
if (idx >= 0) books[idx] = book;
else books.unshift(book);
safeSetItem(BOOKS_KEY, JSON.stringify(books));
},
deleteBook: async (bookId: string): Promise<void> => {
const books = await StorageService.getBooks();
safeSetItem(BOOKS_KEY, JSON.stringify(books.filter(b => b.id !== bookId)));
},
createGroup: async (name: string, memberUsernames: string[]): Promise<{success: boolean, groupId?: string, error?: string}> => {
const profile = await StorageService.getProfile();
if (!profile) return { success: false, error: "Not logged in" };
const usersStr = localStorage.getItem(USERS_KEY);
const users: UserProfile[] = usersStr ? JSON.parse(usersStr) : [];
const memberIds = [profile.id];
for (const uname of memberUsernames) {
const u = users.find(user => user.username === uname);
if (u) memberIds.push(u.id);
}
const newGroup: ChatGroup = { id: Date.now().toString(), name, createdBy: profile.id, members: memberIds, createdAt: Date.now() };
const groupsStr = localStorage.getItem(GROUPS_KEY);
const groups: ChatGroup[] = groupsStr ? JSON.parse(groupsStr) : [];
groups.push(newGroup);
safeSetItem(GROUPS_KEY, JSON.stringify(groups));
return { success: true, groupId: newGroup.id };
},
getGroups: async (): Promise<ChatGroup[]> => {
const profile = await StorageService.getProfile();
if (!profile) return [];
const groupsStr = localStorage.getItem(GROUPS_KEY);
const groups: ChatGroup[] = groupsStr ? JSON.parse(groupsStr) : [];
return groups.filter(g => g.members.includes(profile.id));
},
getCommunityMessages: async (groupId?: string): Promise<CommunityMessage[]> => {
const stored = localStorage.getItem(CHAT_KEY);
const allMessages: CommunityMessage[] = stored ? JSON.parse(stored) : [];
return groupId ? allMessages.filter(m => m.groupId === groupId) : allMessages.filter(m => !m.groupId);
},
sendCommunityMessage: async (text: string, type: 'text' | 'image' = 'text', imageUrl?: string, groupId?: string): Promise<CommunityMessage | null> => {
const profile = await StorageService.getProfile();
if (!profile) return null;
const stored = localStorage.getItem(CHAT_KEY);
const messages: CommunityMessage[] = stored ? JSON.parse(stored) : [];
const newMessage: CommunityMessage = { id: Date.now().toString(), userId: profile.id, userName: profile.name, userRole: profile.role, avatarUrl: profile.avatarUrl, text, type, imageUrl, groupId, timestamp: Date.now() };
const updatedMessages = [...messages, newMessage];
if (updatedMessages.length > 500) updatedMessages.shift();
safeSetItem(CHAT_KEY, JSON.stringify(updatedMessages));
return newMessage;
},
redeemReward: async (itemId: string, cost: number): Promise<{ success: boolean; error?: string }> => {
const profile = await StorageService.getProfile();
if (!profile) return { success: false, error: "Not logged in" };
if (profile.points < cost) return { success: false, error: "Insufficient Karma" };
const currentItems = profile.unlockedItems || [];
if (currentItems.includes(itemId) && !itemId.startsWith('donate')) return { success: false, error: "Item owned" };
// Log Transaction
const txs = JSON.parse(localStorage.getItem(TRANSACTIONS_KEY) || '[]');
txs.push({
id: Date.now(),
userId: profile.id,
amount: -cost,
itemId,
type: 'redemption',
description: `Redeemed: ${itemId}`,
timestamp: Date.now()
});
safeSetItem(TRANSACTIONS_KEY, JSON.stringify(txs), false);
const updatedProfile = await StorageService.updateProfile({ points: profile.points - cost, unlockedItems: [...currentItems, itemId] });
return updatedProfile ? { success: true } : { success: false, error: "Transaction failed" };
},
searchUsers: async (query: string): Promise<UserProfile[]> => {
const usersStr = localStorage.getItem(USERS_KEY);
const users: UserProfile[] = usersStr ? JSON.parse(usersStr) : [];
const lowerQ = query.toLowerCase();
return users.filter(u => (u.username?.toLowerCase().includes(lowerQ)) || u.name.toLowerCase().includes(lowerQ)).slice(0, 10);
},
getUserPublicProfile: async (userId: string): Promise<UserProfile | null> => {
const usersStr = localStorage.getItem(USERS_KEY);
const users: UserProfile[] = usersStr ? JSON.parse(usersStr) : [];
return users.find(u => u.id === userId) || null;
},
getLeaderboard: async (limit: number = 50, sortBy: 'points' | 'danphe' | 'gorilla' | 'truth' | 'mandala' = 'points'): Promise<UserProfile[]> => {
const usersStr = localStorage.getItem(USERS_KEY);
const users: UserProfile[] = usersStr ? JSON.parse(usersStr) : [];
return users.sort((a, b) => {
if (sortBy === 'points') return (b.points || 0) - (a.points || 0);
const scoreA = a.highScores?.[sortBy] || 0;
const scoreB = b.highScores?.[sortBy] || 0;
return scoreB - scoreA;
}).slice(0, limit);
},
saveHighScore: async (game: 'danphe' | 'gorilla' | 'truth' | 'mandala', score: number): Promise<void> => {
const profile = await StorageService.getProfile();
if (!profile) return;
const currentHigh = profile.highScores?.[game] || 0;
if (score > currentHigh) await StorageService.updateProfile({ highScores: { ...(profile.highScores || {}), [game]: score } });
},
verifyTask: async (taskId: string, approved: boolean): Promise<boolean> => {
const stored = localStorage.getItem(TASKS_KEY);
const tasks: Task[] = stored ? JSON.parse(stored) : [];
const taskIndex = tasks.findIndex(t => t.id === taskId);
if (taskIndex === -1) return false;
const task = tasks[taskIndex];
if (approved) {
task.status = TaskStatus.COMPLETED;
task.verificationStatus = 'approved';
const usersStr = localStorage.getItem(USERS_KEY);
if (usersStr) {
const users: UserProfile[] = JSON.parse(usersStr);
const studentIndex = users.findIndex(u => u.id === task.userId);
if (studentIndex !== -1) {
users[studentIndex].xp = (users[studentIndex].xp || 0) + 50;
users[studentIndex].points = (users[studentIndex].points || 0) + 10;
safeSetItem(USERS_KEY, JSON.stringify(users), false);
}
}
} else {
task.status = TaskStatus.IN_PROGRESS;
task.verificationStatus = 'rejected';
}
tasks[taskIndex] = task;
safeSetItem(TASKS_KEY, JSON.stringify(tasks));
return true;
},
getStudyChatHistory: (): ChatMessage[] => {
const stored = localStorage.getItem(STUDY_CHAT_HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
},
saveStudyChatHistory: (messages: ChatMessage[]) => {
safeSetItem(STUDY_CHAT_HISTORY_KEY, JSON.stringify(messages));
},
clearStudyChatHistory: () => {
localStorage.removeItem(STUDY_CHAT_HISTORY_KEY);
},
getGlobalChatHistory: (): ChatMessage[] => {
const stored = localStorage.getItem(RUDRA_GLOBAL_CHAT_KEY);
return stored ? JSON.parse(stored) : [];
},
saveGlobalChatHistory: (messages: ChatMessage[]) => {
safeSetItem(RUDRA_GLOBAL_CHAT_KEY, JSON.stringify(messages));
},
clearGlobalChatHistory: () => {
localStorage.removeItem(RUDRA_GLOBAL_CHAT_KEY);
}
};

View File

@ -0,0 +1,5 @@
// This file is deprecated. All data is now stored locally via StorageService.
// No imports or exports needed.
export const isSupabaseConfigured = (): boolean => false;
export const supabase = null;

0
spam/ai.tsx Normal file
View File

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

155
types.ts Normal file
View File

@ -0,0 +1,155 @@
export enum Priority {
LOW = 'Low',
MEDIUM = 'Medium',
HIGH = 'High'
}
export enum TaskStatus {
TODO = 'To Do',
IN_PROGRESS = 'In Progress',
SUBMITTED = 'Submitted', // Waiting for teacher
COMPLETED = 'Completed'
}
export interface Subtask {
id: string;
title: string;
completed: boolean;
}
export interface Task {
id: string;
userId: string; // Owner or Creator
title: string;
subject: string;
dueDate: string; // ISO string
priority: Priority;
status: TaskStatus;
verificationStatus?: 'pending' | 'approved' | 'rejected';
description?: string;
subtasks?: Subtask[];
isAssignment?: boolean;
targetClass?: string; // e.g., '10', '12', 'General'
estimatedMinutes?: number;
}
export type UserRole = 'student' | 'teacher' | 'citizen';
export interface UserProfile {
id: string;
name: string;
username?: string;
email: string;
role: UserRole;
avatarUrl?: string;
bannerUrl?: string;
frameId?: string;
schoolName?: string;
grade?: string;
profession?: string;
bio?: string; // New Bio Field
points: number;
xp: number;
unlockedItems?: string[];
activeTheme?: string;
createdAt?: number;
lastDailyClaim?: number; // Timestamp for daily reward
subscription?: {
tier: 'weekly' | 'monthly' | 'lifetime';
expiry: number; // Timestamp
};
highScores?: {
danphe?: number;
gorilla?: number;
truth?: number;
mandala?: number;
speed?: number;
memory?: number;
attention?: number;
flexibility?: number;
logic?: number;
};
birthCertificateId?: string;
studentId?: string;
guardianName?: string;
subjects?: string[];
friends?: string[]; // List of user IDs
friendRequests?: string[]; // List of user IDs who sent request
sentRequests?: string[]; // List of user IDs I sent request to
}
// Added Transaction interface for the Karma Ledger
export interface Transaction {
id: number;
userId: string;
amount: number;
type: string;
timestamp: number;
itemId?: string;
description?: string;
}
export interface StudyNote {
id: string;
userId: string;
title: string;
content: string;
color?: string; // Background color preset (class)
textColor?: string; // Text color preset
fontFamily?: 'sans' | 'serif' | 'mono';
timestamp: number;
}
export interface AppSettings {
soundEnabled: boolean;
hapticFeedback: boolean;
autoFocusMode: boolean;
dataSaver: boolean;
broadcastRadius: number;
language: 'en' | 'ne';
gpsAccuracy: 'high' | 'low'; // New Setting
notifications: {
studyReminders: boolean;
communityAlerts: boolean;
arcadeTasks: boolean;
};
permissions: {
camera: boolean;
microphone: boolean;
location: boolean;
};
}
export type RecipeCategory = 'daily' | 'far-west' | 'newari' | 'kirati' | 'tharu' | 'tamang' | 'veg' | 'non-veg' | 'beverages' | 'dessert' | 'snack' | 'festival';
export interface Sighting {
id: string;
time: string;
location: string;
info: string;
image?: string;
timestamp: number;
userId?: string;
userName?: string;
isVerified?: boolean;
}
export interface FTLMission { id: string; type: 'pet' | 'person' | 'object'; title: string; location: string; time: string; status: 'active' | 'resolved'; bounty: number; description: string; sightings: Sighting[]; image: string; userId: string; isLost: boolean; timestamp: number; }
export interface RescueTag { id: string; name: string; contact: string; type: 'pet' | 'object' | 'person'; info: string; image?: string; timestamp: number; }
export interface ArcadeTask { id: string; title: string; reward: number; completed: boolean; type: 'score' | 'play' | 'win'; }
export interface ChatGroup { id: string; name: string; createdBy: string; members: string[]; createdAt: number; }
export interface CommunityMessage { id: string; groupId?: string; userId: string; userName: string; userRole: UserRole; avatarUrl?: string; text?: string; imageUrl?: string; type: 'text' | 'image'; timestamp: number; }
export interface DirectMessage { id: string; senderId: string; receiverId: string; text: string; timestamp: number; read: boolean; type: 'text' | 'image' | 'karma'; imageUrl?: string; amount?: number; }
export interface StudySession { id: string; userId: string; subject: string; durationMinutes: number; timestamp: number; isFocusMode?: boolean; }
export interface ChatMessage { id: string; role: 'user' | 'model'; text: string; timestamp: number; image?: string; }
export interface HealthLog { date: string; waterGlasses: number; mood: 'Happy' | 'Neutral' | 'Stressed' | 'Tired'; sleepHours: number; }
export interface WeatherData { temp: number; condition: 'Sunny' | 'Cloudy' | 'Rainy' | 'Stormy' | 'Foggy'; humidity: number; windSpeed: number; uvIndex: number; feelsLike: number; location: string; }
export interface AQIData { aqi: number; status: string; color: string; advice: string; pollutant: string; location: string; }
export interface Recipe { id: string; title: string; author: string; description: string; ingredients: string[]; instructions: string; history?: string; videoUrl?: string; isPublic: boolean; likes: number; imageUrl?: string; prepTime?: number; tags?: RecipeCategory[]; }
export interface Review { id: string; targetId: string; userId: string; userName: string; rating: number; comment: string; timestamp: number; }
export interface HeritageSite { id: string; name: string; nameNe?: string; description: string; descriptionNe?: string; category: 'Temple' | 'Stupa' | 'Palace' | 'Nature' | 'Other'; region: string; latitude: number; longitude: number; imageUrl: string; history?: string; historyNe?: string; culturalSignificance?: string; culturalSignificanceNe?: string; }
export interface ProvinceData { id: number; name: string; nepaliName: string; capital: string; capitalNe: string; districts: number; area: string; population: string; density: string; color: string; borderColor: string; description: string; descriptionNe: string; attractions: string[]; attractionsNe: string[]; image: string; majorRivers: string; majorRiversNe: string; literacyRate: string; mainLanguages: string; mainLanguagesNe: string; lat: number; lng: number; }
export interface TriviaQuestion { question: string; options: string[]; correctAnswer: number; explanation: string; }
export interface Book { id: string; title: string; author: string; grade: string; subject: string; description: string; link: string; uploadedBy?: string; timestamp: number; }
export interface NepaliDate { bs_year: number; bs_month: number; bs_day: number; ad_year: number; ad_month: number; ad_day: number; weekday_str_en: string; weekday_str_np: string; bs_month_str_en: string; bs_month_str_np: string; tithi_str_en: string; tithi_str_np: string; is_holiday: boolean; events: { strEn: string; strNp: string; isHoliday: boolean; }[]; }

23
vite.config.ts Normal file
View File

@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});