import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Routes, Route, useNavigate, useLocation, useParams } from 'react-router-dom'; import { Menu } from 'lucide-react'; import Sidebar from './components/Sidebar'; import MainChat from './components/MainChat'; import ChatInput from './components/ChatInput'; import MessageBubble from './components/MessageBubble'; import ForkPanel from './components/ForkPanel'; import Settings from './pages/Settings'; import ExamHistory from './components/ExamHistory'; import FlashcardReview from './components/FlashcardReview'; import CalendarHeatmap from './components/CalendarHeatmap'; import RoadmapVisual from './components/RoadmapVisual'; import PomodoroTimer from './components/PomodoroTimer'; import MultiPdfCompare from './components/MultiPdfCompare'; import AutoForkPrompt from './components/AutoForkPrompt'; import ExamPanel from './components/ExamPanel'; import StudyBuddyPanel from './components/StudyBuddyPanel'; import { ToastProvider, useToast } from './components/Toast'; import { ReactionsProvider } from './context/ReactionsContext'; import { useMediaQuery } from './hooks/useMediaQuery'; import useChat from './hooks/useChat'; import usePdfs from './hooks/usePdfs'; import useProgress from './hooks/useProgress'; import useExams from './hooks/useExams'; import useFlashcards from './hooks/useFlashcards'; import useStudySessions from './hooks/useStudySessions'; import useExam from './hooks/useExam'; import useStudyBuddy from './hooks/useStudyBuddy'; import { getConversations, createConversation, updateConversation, getModels, forkConversation, mergeConversation, getNotes, shareConversation, getSharedConversation, generateExam, } from './lib/api'; import './App.css'; function ToastBridge({ onReady }) { const { error } = useToast(); useEffect(() => { onReady(error); }, [onReady, error]); return null; } function BuddyRouteWrapper({ onJoin, buddyHook, shareToken, onShare }) { const { token } = useParams(); useEffect(() => { if (token) { onJoin(token); } }, [token, onJoin]); return ( ); } export default function App() { const [conversaciones, setConversaciones] = useState([]); const [conversationActiva, setConversationActiva] = useState(null); const [forkActivo, setForkActivo] = useState(null); const [modelos, setModelos] = useState([]); const [modeloSeleccionado, setModeloSeleccionado] = useState(null); const [notas, setNotas] = useState([]); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [forkWidth, setForkWidth] = useState(320); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [hasLoadedConversations, setHasLoadedConversations] = useState(false); const [hasLoadedPdfs, setHasLoadedPdfs] = useState(false); const [pendingPrompt, setPendingPrompt] = useState(''); const toastError = useRef(null); const searchInputRef = useRef(null); const handleToastReady = useCallback((fn) => { toastError.current = fn; }, []); const [activeExam, setActiveExam] = useState(null); const [buddyToken, setBuddyToken] = useState(null); const [shareToken, setShareToken] = useState(null); const isMobile = useMediaQuery('(max-width: 767px)'); const navigate = useNavigate(); const location = useLocation(); const pdfsHook = usePdfs(); const progressHook = useProgress(); const examsHook = useExams(); const flashcardsHook = useFlashcards(); const sessionsHook = useStudySessions(); const chatHook = useChat({ conversationId: conversationActiva?.id ?? null, onProgressUpdate: progressHook.updateExercise, onStudySession: (date, minutes) => sessionsHook.recordSession(date, minutes), onAutoFork: ({ topic }) => { console.log('[App] auto-fork suggested:', topic); }, onDifficultyChanged: ({ level }) => { console.log('[App] difficulty changed:', level); }, }); const examHook = useExam(activeExam); const buddyHook = useStudyBuddy(buddyToken); // Load initial data useEffect(() => { let mounted = true; async function load() { try { const [models, convs, notesList] = await Promise.all([ getModels(), getConversations(), getNotes(), ]); if (!mounted) return; setModelos(models); setConversaciones(convs); setNotas(notesList); const defaultModel = models.find((m) => m.is_default_main) || models[0] || null; setModeloSeleccionado(defaultModel); // Load PDFs and progress pdfsHook.refresh(); progressHook.refresh(); examsHook.refresh(); flashcardsHook.refresh(); setHasLoadedConversations(true); setHasLoadedPdfs(true); } catch (err) { console.error('[App] load error:', err.message); toastError.current?.('Error al cargar los datos. Verifica que el servidor esté funcionando.'); } } load(); return () => { mounted = false; }; }, []); // Load messages when active conversation changes useEffect(() => { if (!conversationActiva) return; chatHook.setActiveId(conversationActiva.id); }, [conversationActiva?.id]); // Auto-close mobile sidebar when resizing to desktop useEffect(() => { if (!isMobile) { setMobileSidebarOpen(false); } }, [isMobile]); // Global keyboard shortcuts useEffect(() => { function handleKeyDown(e) { const tag = e.target?.tagName; const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target?.isContentEditable; const ctrlOrMeta = e.ctrlKey || e.metaKey; if (ctrlOrMeta) { if (isInput && e.key !== 'Enter') return; switch (e.key.toLowerCase()) { case 'n': e.preventDefault(); handleNewConversation(); break; case 'k': e.preventDefault(); searchInputRef.current?.focus(); break; case 'enter': e.preventDefault(); window.dispatchEvent(new CustomEvent('studyos:send-message')); break; default: break; } } else if (e.key === 'Escape') { if (forkActivo) { setForkActivo(null); } } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [forkActivo, handleNewConversation]); // Listen for search result navigation: select conversation useEffect(() => { function handleSelectConversation(e) { const { id } = e.detail || {}; if (!id) return; const conv = conversaciones.find((c) => c.id === id); if (conv) { setConversationActiva(conv); setForkActivo(null); } } window.addEventListener('studyos:select-conversation', handleSelectConversation); return () => window.removeEventListener('studyos:select-conversation', handleSelectConversation); }, [conversaciones]); const handleSelectConversation = useCallback((conv) => { setConversationActiva(conv); setForkActivo(null); }, []); const handleNewConversation = useCallback(async () => { try { const conv = await createConversation({ title: 'Nueva conversación', model_id: modeloSeleccionado?.id ?? null, }); setConversaciones((prev) => [conv, ...prev]); setConversationActiva(conv); setForkActivo(null); } catch (err) { console.error('[App] new conversation error:', err.message); } }, [modeloSeleccionado]); const handleFork = useCallback(async (topic) => { if (!conversationActiva) return; try { const fork = await forkConversation(conversationActiva.id, { topic, model_id: modeloSeleccionado?.id ?? null, }); setConversaciones((prev) => [fork, ...prev]); setForkActivo(fork); } catch (err) { console.error('[App] fork error:', err.message); } }, [conversationActiva, modeloSeleccionado]); const handleMergeFork = useCallback(async () => { if (!forkActivo) return; try { await mergeConversation(forkActivo.id); setForkActivo(null); // Refresh conversations list (sidebar needs fresh data) const convs = await getConversations(); setConversaciones(convs); // Refresh messages of parent conversation if (conversationActiva) { chatHook.setActiveId(conversationActiva.id); } } catch (err) { console.error('[App] merge error:', err.message); } }, [forkActivo, conversationActiva, chatHook]); const handleCloseFork = useCallback(() => { setForkActivo(null); }, []); const handleAutoForkAccept = useCallback(() => { if (chatHook.autoForkPrompt?.topic) { handleFork(chatHook.autoForkPrompt.topic); chatHook.setAutoForkPrompt(null); } }, [chatHook.autoForkPrompt, handleFork, chatHook]); const handleAutoForkDismiss = useCallback(() => { chatHook.setAutoForkPrompt(null); }, [chatHook]); const handleShareConversation = useCallback(async () => { if (!conversationActiva) return; try { const data = await shareConversation(conversationActiva.id, 'compañero'); setShareToken(data.token); } catch (err) { console.error('[App] share error:', err.message); } }, [conversationActiva]); const handleJoinBuddy = useCallback(async (token) => { if (!token) return; try { setBuddyToken(token); navigate(`/buddy/${token}`); } catch (err) { console.error('[App] join buddy error:', err.message); } }, [navigate]); const handleGenerateExam = useCallback(async (topic, pdfIds, numQuestions, durationSeconds) => { try { const data = await generateExam({ conversation_id: conversationActiva?.id ?? null, topic, pdf_ids: pdfIds, num_questions: numQuestions, duration_seconds: durationSeconds, }); setActiveExam(data); navigate(`/exam/${data.id}`); } catch (err) { console.error('[App] generate exam error:', err.message); } }, [conversationActiva, navigate]); const handleRenameConversation = useCallback(async (id, title) => { try { await updateConversation(id, { title }); } catch (err) { console.error('[App] rename conversation error:', err.message); } setConversaciones((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)) ); if (conversationActiva?.id === id) { setConversationActiva((prev) => (prev ? { ...prev, title } : prev)); } }, [conversationActiva]); const handleRoadmapFork = useCallback((topic) => { setPendingPrompt(`Explica ${topic} en detalle`); setConversationActiva(null); navigate('/'); }, [navigate]); const mainConvs = conversaciones.filter((c) => c.type === 'main'); const isHome = location.pathname === '/'; const appContent = (
{isMobile && isHome && ( )} setSidebarCollapsed((s) => !s)} pdfs={pdfsHook.pdfs} progress={progressHook.progress} conversations={mainConvs} activeConversation={conversationActiva} notes={notas} onSelectConversation={handleSelectConversation} onNewConversation={handleNewConversation} onUploadPdf={pdfsHook.uploadPdf} onReorderPdf={pdfsHook.reorderPdf} onDeletePdf={pdfsHook.deletePdf} onResetTopic={progressHook.resetTopic} onNavigateSettings={() => navigate('/settings')} onNavigate={navigate} hasLoadedConversations={hasLoadedConversations} hasLoadedPdfs={hasLoadedPdfs} isMobileOpen={mobileSidebarOpen} onClose={() => setMobileSidebarOpen(false)} searchInputRef={searchInputRef} dueCount={flashcardsHook.dueCount} />
chatHook.sendMessage(text, pdfIds, attachments) } isStreaming={chatHook.isStreaming} availablePdfs={pdfsHook.pdfs} prefillText={pendingPrompt} /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } />
); return ( {appContent} ); }