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}
);
}