feat: implement 33 nice-to-have features + fix 37 code review bugs
5 SDD batches archived: - Batch 1: UI Polish (10 features, 14 tasks) - Batch 2: Study System (8 features, 23 tasks) - Batch 3: Infrastructure (5 features, 22 tasks) - Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers - Batch 5: Core Features (5 features, 19 tasks) 37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW): - SSE streaming now works (event.token check) - API keys no longer exposed via GET /api/models - FTS5 injection sanitized - DB backup/restore with admin auth - Buddy mode wired (buddy_meta column) - Exam auto-submit stale closure fixed - CSS variables aligned with design tokens - Progress data corruption fixed - WebSocket protocol auto-detection - Tests infrastructure completed (vitest + node:test)
This commit is contained in:
@@ -1,24 +1,73 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||
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 (
|
||||
<StudyBuddyPanel
|
||||
conversation={buddyHook.conversation}
|
||||
messages={buddyHook.messages}
|
||||
roleLabel={buddyHook.roleLabel}
|
||||
counterpartLabel={buddyHook.counterpartLabel}
|
||||
onShare={onShare}
|
||||
shareToken={shareToken}
|
||||
onJoin={onJoin}
|
||||
loading={buddyHook.loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [conversaciones, setConversaciones] = useState([]);
|
||||
const [conversationActiva, setConversationActiva] = useState(null);
|
||||
@@ -27,16 +76,43 @@ export default function App() {
|
||||
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;
|
||||
@@ -57,8 +133,13 @@ export default function App() {
|
||||
// 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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +153,63 @@ export default function App() {
|
||||
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);
|
||||
@@ -126,32 +264,118 @@ export default function App() {
|
||||
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');
|
||||
|
||||
return (
|
||||
const isHome = location.pathname === '/';
|
||||
|
||||
const appContent = (
|
||||
<div className="app-layout">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<>
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => 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')}
|
||||
/>
|
||||
<main className="app-main">
|
||||
{isMobile && isHome && (
|
||||
<button
|
||||
className="hamburger-btn"
|
||||
onClick={() => setMobileSidebarOpen((s) => !s)}
|
||||
title="Toggle sidebar"
|
||||
style={{ position: 'absolute', top: 10, left: 10, zIndex: 35 }}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
)}
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => 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}
|
||||
/>
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<>
|
||||
<MainChat
|
||||
conversation={conversationActiva}
|
||||
modelo={modeloSeleccionado}
|
||||
@@ -161,6 +385,12 @@ export default function App() {
|
||||
messages={chatHook.messages}
|
||||
isStreaming={chatHook.isStreaming}
|
||||
MessageBubbleComponent={MessageBubble}
|
||||
onRenameConversation={handleRenameConversation}
|
||||
/>
|
||||
<AutoForkPrompt
|
||||
topic={chatHook.autoForkPrompt?.topic}
|
||||
onAccept={handleAutoForkAccept}
|
||||
onDismiss={handleAutoForkDismiss}
|
||||
/>
|
||||
<ChatInput
|
||||
onSend={(text, pdfIds, attachments) =>
|
||||
@@ -168,19 +398,44 @@ export default function App() {
|
||||
}
|
||||
isStreaming={chatHook.isStreaming}
|
||||
availablePdfs={pdfsHook.pdfs}
|
||||
prefillText={pendingPrompt}
|
||||
/>
|
||||
</main>
|
||||
<ForkPanel
|
||||
forkId={forkActivo?.id ?? null}
|
||||
forkTitle={forkActivo?.title || ''}
|
||||
onClose={handleCloseFork}
|
||||
onMerge={handleMergeFork}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/exams" element={<ExamHistory exams={examsHook.exams} onDelete={examsHook.remove} onRefresh={examsHook.refresh} onGenerate={handleGenerateExam} />} />
|
||||
<Route path="/exam/:id" element={<ExamPanel exam={activeExam} useExamHook={examHook} />} />
|
||||
<Route path="/flashcards" element={<FlashcardReview cards={flashcardsHook.cards} queue={flashcardsHook.queue} onRefresh={flashcardsHook.fetchNext} onMarkSeen={flashcardsHook.markSeen} onUpdate={flashcardsHook.update} onDelete={flashcardsHook.remove} />} />
|
||||
<Route path="/heatmap" element={<CalendarHeatmap heatmapData={sessionsHook.heatmapData} onRefresh={sessionsHook.heatmap} />} />
|
||||
<Route path="/roadmap" element={<RoadmapVisual onForkTopic={handleRoadmapFork} />} />
|
||||
<Route path="/timer" element={<PomodoroTimer />} />
|
||||
<Route path="/compare" element={<MultiPdfCompare />} />
|
||||
<Route path="/buddy/:token" element={<BuddyRouteWrapper
|
||||
onJoin={handleJoinBuddy}
|
||||
buddyHook={buddyHook}
|
||||
shareToken={shareToken}
|
||||
onShare={handleShareConversation}
|
||||
/>} />
|
||||
</Routes>
|
||||
</main>
|
||||
<ForkPanel
|
||||
forkId={forkActivo?.id ?? null}
|
||||
forkTitle={forkActivo?.title || ''}
|
||||
onClose={handleCloseFork}
|
||||
onMerge={handleMergeFork}
|
||||
width={forkWidth}
|
||||
onResize={setForkWidth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ToastBridge onReady={handleToastReady} />
|
||||
<ReactionsProvider>
|
||||
{appContent}
|
||||
</ReactionsProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user