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:
renato97
2026-06-08 18:18:47 -03:00
parent b7d1e7319f
commit 4ff4302a8c
79 changed files with 13667 additions and 389 deletions

View File

@@ -5,10 +5,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StudyOS</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📚</text></svg>" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" href="/pwa-icons/icon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" integrity="sha384-nB0miv6/jRmo5UMMR1wu3Gz7NLsoWkbqJmINEFemQVi4AvPgz4t1qAQ4N6BbKEX4" crossorigin="anonymous" />
<script>
(function() {
try {
const stored = localStorage.getItem('studyos-theme');
if (stored === 'light' || stored === 'dark') {
document.documentElement.dataset.theme = stored;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
document.documentElement.dataset.theme = 'light';
} else {
document.documentElement.dataset.theme = 'dark';
}
} catch (e) {
document.documentElement.dataset.theme = 'dark';
}
})();
</script>
</head>
<body style="background-color:var(--bg-base);">
<div id="root"></div>

5484
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
@@ -13,6 +15,7 @@
"@dnd-kit/utilities": "^3.2.2",
"katex": "^0.17.0",
"lucide-react": "^0.440.0",
"prismjs": "^1.30.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
@@ -20,9 +23,14 @@
"recharts": "^2.12.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
"jsdom": "^25.0.0",
"vite": "^5.4.0",
"vite-plugin-pwa": "^0.20.0",
"vitest": "^2.1.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

View File

@@ -65,7 +65,7 @@ html { background: var(--bg-base); }
/* Fork panel */
.fork-panel { width: 0; overflow: hidden; transition: all var(--transition-slow); background: linear-gradient(180deg, rgba(22,24,34,0.98), rgba(15,17,23,0.97)); border-left: 1px solid var(--border); flex-shrink: 0; backdrop-filter: blur(24px); }
.fork-panel.open { width: 320px; box-shadow: -12px 0 40px rgba(0,0,0,0.5); }
.fork-panel.open { display: flex; box-shadow: -12px 0 40px rgba(0,0,0,0.5); }
.fork-header { display: flex; align-items: center; padding: 14px; border-bottom: 1px solid var(--border); font-size: 13px; gap: 10px; background: linear-gradient(90deg, rgba(129,140,248,0.06), transparent); }
.fork-chat { flex: 1; overflow-y: auto; padding: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 10px; }
@@ -195,6 +195,7 @@ html { background: var(--bg-base); }
.fork-resize-handle {
width: 4px; cursor: col-resize; background: transparent;
transition: background var(--transition-fast); position: relative; z-index: 10;
flex-shrink: 0; align-self: stretch;
}
.fork-resize-handle:hover, .fork-resize-handle:active {
background: var(--accent-info); box-shadow: 0 0 12px rgba(129,140,248,0.4);
@@ -205,6 +206,77 @@ html { background: var(--bg-base); }
.sidebar-pdf-item:hover { transform: translateX(2px); }
.sidebar-note-item:hover { transform: translateX(2px); }
/* Copy button */
.message-bubble:hover .copy-btn { opacity: 1; }
.copy-btn {
position: absolute; top: 8px; right: 8px;
display: flex; align-items: center; gap: 4px;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text-tertiary);
font-size: 11px; padding: 4px 8px; cursor: pointer;
opacity: 0; transition: opacity var(--transition-fast), color var(--transition-fast);
z-index: 5;
}
.copy-btn:hover { color: var(--text-primary); background: var(--bg-elevated); }
/* Reaction pills */
.reaction-pill {
display: inline-flex; align-items: center; justify-content: center;
gap: 4px; padding: 3px 10px; border-radius: var(--radius-pill);
background: var(--bg-surface); border: 1px solid var(--border);
color: var(--text-tertiary); font-size: 13px; cursor: pointer;
transition: all var(--transition-fast); user-select: none;
}
.reaction-pill:hover { background: var(--bg-elevated); color: var(--text-primary); border-color: var(--border-glow); }
.reaction-pill--active { background: rgba(129,140,248,0.15); border-color: rgba(129,140,248,0.4); color: var(--accent-info); }
.reaction-pill--active:hover { background: rgba(129,140,248,0.22); }
/* Inline rename input */
.input-rename {
background: var(--bg-elevated); border: 1.5px solid var(--accent-info);
border-radius: var(--radius-sm); color: var(--text-primary);
font-family: var(--font-ui); font-size: 14px; font-weight: 600;
padding: 4px 8px; outline: none; width: 100%; max-width: 400px;
}
.input-rename:focus { box-shadow: 0 0 0 3px rgba(129,140,248,0.15); }
/* Scroll FAB within MainChat */
.scroll-fab {
position: absolute; bottom: 20px; right: 20px;
display: flex; align-items: center; gap: 6px;
padding: 8px 14px; border-radius: var(--radius-pill);
background: var(--accent-info); color: #fff;
border: none; font-size: 12px; font-weight: 600;
cursor: pointer; z-index: 40;
box-shadow: var(--shadow-md);
opacity: 0; pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.scroll-fab.scroll-fab--visible { opacity: 1; pointer-events: auto; }
/* Sidebar backdrop */
.sidebar-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
backdrop-filter: blur(2px); z-index: 25;
animation: fadeIn 0.2s ease;
}
/* Hamburger button */
.hamburger-btn {
display: none; align-items: center; justify-content: center;
background: none; border: none; color: var(--text-secondary);
cursor: pointer; padding: 8px; border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.hamburger-btn:hover { color: var(--text-primary); background: var(--bg-elevated); }
/* Mobile sidebar open state override */
@media (max-width: 767px) {
.app-sidebar.sidebar--open { transform: translateX(0); }
.hamburger-btn { display: flex; }
}
/* Modal improved */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 100; animation: fadeIn 0.2s ease; }
.modal-content { background: linear-gradient(145deg, rgba(30,32,48,0.99), rgba(22,24,34,0.99)); border: 1px solid var(--border-glow); border-radius: var(--radius-lg); padding: 28px; min-width: 440px; max-width: 90vw; max-height: 85vh; overflow: auto; box-shadow: 0 0 60px rgba(129,140,248,0.1); animation: scaleIn 0.2s ease; }
@@ -246,3 +318,54 @@ html { background: var(--bg-base); }
margin: 0 4px;
}
.fork-inline-badge:hover { box-shadow: 0 0 12px rgba(251,191,36,0.2); border-color: var(--accent-amber); }
/* Heatmap grid */
.heatmap-grid { display: flex; gap: 2px; }
.heatmap-week { display: flex; flex-direction: column; gap: 2px; }
.heatmap-cell { width: 12px; height: 12px; border-radius: 2px; }
/* Timer dial */
.timer-dial { width: 220px; height: 220px; border-radius: 50%; position: relative; display: flex; align-items: center; justify-content: center; }
.timer-dial-inner { width: 190px; height: 190px; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
/* Flashcard flip */
.flashcard-container { perspective: 1000px; min-height: 220px; }
.flashcard-inner { position: relative; width: 100%; min-height: 200px; transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); transform-style: preserve-3d; }
.flashcard-front, .flashcard-back { position: absolute; inset: 0; backface-visibility: hidden; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 24px; }
.flashcard-back { transform: rotateY(180deg); }
/* Roadmap SVG */
.roadmap-node { cursor: pointer; transition: all var(--transition-fast); }
.roadmap-node:hover { filter: brightness(1.2); }
.roadmap-edge { stroke: var(--border-glow); stroke-width: 1.5; opacity: 0.6; }
/* Search dropdown */
.search-dropdown {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius-md); box-shadow: var(--shadow-lg);
z-index: 20; max-height: 320px; overflow-y: auto;
backdrop-filter: blur(24px);
}
.search-group-header {
padding: 6px 12px; font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--accent-info); border-bottom: 1px solid var(--border);
}
.search-result-item {
display: flex; align-items: flex-start; gap: 8px;
width: 100%; padding: 8px 12px; border: none;
background: transparent; color: var(--text-secondary);
font-size: 12px; text-align: left; cursor: pointer;
border-bottom: 1px solid var(--border); font-family: var(--font-ui);
transition: background var(--transition-fast);
}
.search-result-item:hover { background: var(--bg-glass); }
/* PDF compare panels */
.pdf-compare-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
.pdf-compare-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--bg-surface); }
/* LaTeX preview */
.latex-preview { background: var(--bg-elevated); border-radius: var(--radius-md); padding: 12px 16px; min-height: 60px; font-size: 14px; overflow-x: auto; }
.latex-preview--error { border-color: var(--accent-coral); }

View File

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

View File

@@ -0,0 +1,60 @@
.auto-fork-toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 100;
max-width: 90vw;
flex-wrap: wrap;
}
.auto-fork-content {
display: flex;
align-items: center;
gap: 8px;
}
.auto-fork-icon {
font-size: 20px;
}
.auto-fork-text {
font-size: 14px;
color: var(--text-primary);
}
.auto-fork-actions {
display: flex;
gap: 8px;
}
.auto-fork-btn {
border: none;
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
transition: opacity 0.2s;
}
.auto-fork-btn.accept {
background: var(--accent-info);
color: #fff;
}
.auto-fork-btn.dismiss {
background: var(--text-tertiary);
color: #fff;
}
.auto-fork-btn:hover {
opacity: 0.85;
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import './AutoForkPrompt.css';
export default function AutoForkPrompt({ topic, onAccept, onDismiss }) {
if (!topic) return null;
return (
<div className="auto-fork-toast">
<div className="auto-fork-content">
<span className="auto-fork-icon">🍴</span>
<span className="auto-fork-text">
¿Querés practicar <strong>{topic}</strong>?
</span>
</div>
<div className="auto-fork-actions">
<button className="auto-fork-btn accept" onClick={onAccept}>
, practicar
</button>
<button className="auto-fork-btn dismiss" onClick={onDismiss}>
Ahora no
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
export default function CalendarHeatmap({ heatmapData, onRefresh }) {
const [hovered, setHovered] = useState(null);
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
const containerRef = useRef(null);
useEffect(() => {
onRefresh(365);
}, [onRefresh]);
const { grid, maxMinutes, monthLabels } = useMemo(() => {
const today = new Date();
const days = [];
for (let i = 364; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
days.push(d);
}
const dataMap = new Map();
for (const row of heatmapData) {
dataMap.set(row.date, row.minutes);
}
const weeks = [];
const monthLbls = [];
let lastMonth = -1;
for (let w = 0; w < 53; w++) {
const week = [];
for (let dow = 0; dow < 7; dow++) {
const dayIndex = w * 7 + dow - today.getDay();
const day = days[Math.max(0, Math.min(days.length - 1, dayIndex + 364))];
if (!day) {
week.push(null);
continue;
}
const iso = day.toISOString().split('T')[0];
const minutes = dataMap.get(iso) || 0;
if (day.getMonth() !== lastMonth && dow === 0) {
monthLbls.push({ label: day.toLocaleString('default', { month: 'short' }), week: w });
lastMonth = day.getMonth();
}
week.push({ date: iso, minutes, day });
}
weeks.push(week);
}
const maxMin = Math.max(1, ...heatmapData.map((d) => d.minutes)) || 1;
return { grid: weeks, maxMinutes: maxMin, monthLabels: monthLbls };
}, [heatmapData]);
const getIntensity = (minutes) => {
if (minutes === 0) return 0;
const ratio = minutes / maxMinutes;
if (ratio <= 0.25) return 1;
if (ratio <= 0.5) return 2;
if (ratio <= 0.75) return 3;
return 4;
};
const intensityColors = [
'var(--bg-elevated)',
'rgba(52,211,153,0.25)',
'rgba(52,211,153,0.45)',
'rgba(52,211,153,0.65)',
'rgba(52,211,153,0.9)',
];
return (
<div style={{ padding: '24px', maxWidth: 900, margin: '0 auto' }}>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20 }}>Actividad de Estudio</h2>
<div style={{ overflowX: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 720 }}>
{/* Month labels */}
<div style={{ display: 'flex', gap: 2, paddingLeft: 24, position: 'relative' }}>
{monthLabels.map((m) => (
<div key={`${m.label}-${m.week}`} style={{ position: 'absolute', left: m.week * 14 + 24, fontSize: 10, color: 'var(--text-tertiary)' }}>
{m.label}
</div>
))}
</div>
<div style={{ height: 14 }} />
{/* Grid */}
<div style={{ display: 'flex', gap: 2 }}>
{/* Day labels */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginRight: 4 }}>
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map((d, i) => (
<div key={i} style={{ height: 12, fontSize: 9, color: 'var(--text-tertiary)', lineHeight: '12px' }}>{d}</div>
))}
</div>
{/* Weeks */}
{grid.map((week, wi) => (
<div key={wi} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{week.map((day, di) => (
<div
key={di}
style={{
width: 12,
height: 12,
borderRadius: 2,
background: day ? intensityColors[getIntensity(day.minutes)] : 'transparent',
cursor: day ? 'pointer' : 'default',
}}
onMouseEnter={() => day && setHovered(day)}
onMouseLeave={() => setHovered(null)}
onMouseMove={(e) => setTooltipPos({ x: e.clientX, y: e.clientY })}
/>
))}
</div>
))}
</div>
{/* Legend */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12 }}>
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>Menos</span>
{intensityColors.map((c, i) => (
<div key={i} style={{ width: 12, height: 12, borderRadius: 2, background: c }} />
))}
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>Más</span>
</div>
</div>
</div>
{/* Tooltip */}
{hovered && (
<div
style={{
position: 'fixed',
left: tooltipPos.x + 16,
top: tooltipPos.y + 16,
background: 'var(--bg-surface)',
border: '1px solid var(--border-glow)',
borderRadius: 'var(--radius-sm)',
padding: '8px 12px',
fontSize: 12,
color: 'var(--text-primary)',
zIndex: 60,
pointerEvents: 'none',
boxShadow: 'var(--shadow-md)',
}}
>
<div style={{ fontWeight: 600 }}>{new Date(hovered.date).toLocaleDateString()}</div>
<div style={{ color: 'var(--text-secondary)' }}>{hovered.minutes} min</div>
</div>
)}
</div>
);
}

View File

@@ -1,14 +1,22 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, X, ChevronDown, Check } from 'lucide-react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Paperclip, X, ChevronDown, Check, Sigma } from 'lucide-react';
import VoiceInput from './VoiceInput';
import katex from 'katex';
import 'katex/dist/katex.min.css';
export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
export default function ChatInput({ onSend, isStreaming, availablePdfs = [], prefillText = '' }) {
const [text, setText] = useState('');
const [attachedFiles, setAttachedFiles] = useState([]);
const [selectedPdfIds, setSelectedPdfIds] = useState([]);
const [pdfDropdownOpen, setPdfDropdownOpen] = useState(false);
const [latexMode, setLatexMode] = useState(false);
const [latexPreview, setLatexPreview] = useState('');
const [latexError, setLatexError] = useState(false);
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
const dropdownRef = useRef(null);
const latexDebounceRef = useRef(null);
const prefillApplied = useRef(false);
// Auto-resize textarea (max ~5 lines ~120px)
useEffect(() => {
@@ -30,6 +38,51 @@ export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Apply prefill text once
useEffect(() => {
if (prefillText && !prefillApplied.current) {
setText(prefillText);
prefillApplied.current = true;
}
}, [prefillText]);
const handleSendRef = useRef(handleSend);
handleSendRef.current = handleSend;
// Listen for custom send event from global shortcuts
useEffect(() => {
function handleCustomSend() {
handleSendRef.current();
}
window.addEventListener('studyos:send-message', handleCustomSend);
return () => window.removeEventListener('studyos:send-message', handleCustomSend);
}, []);
// LaTeX preview debounce
useEffect(() => {
if (!latexMode) return;
if (latexDebounceRef.current) clearTimeout(latexDebounceRef.current);
latexDebounceRef.current = setTimeout(() => {
if (!text.trim()) {
setLatexPreview('');
setLatexError(false);
return;
}
try {
const html = katex.renderToString(text, { throwOnError: false, displayMode: true });
setLatexPreview(html);
setLatexError(false);
} catch {
setLatexError(true);
}
}, 200);
return () => clearTimeout(latexDebounceRef.current);
}, [text, latexMode]);
const appendTranscript = useCallback((transcript) => {
setText((prev) => (prev ? prev + ' ' + transcript : transcript));
}, []);
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -105,6 +158,9 @@ export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
{/* Voice input */}
<VoiceInput onTranscript={appendTranscript} disabled={isStreaming} />
{/* Attachment button */}
<button
onClick={() => fileInputRef.current?.click()}
@@ -193,12 +249,25 @@ export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe un mensaje..."
placeholder={latexMode ? 'Escribe LaTeX...' : 'Escribe un mensaje...'}
rows={1}
disabled={isStreaming}
className="chat-input-textarea"
/>
{/* LaTeX toggle */}
<button
className="icon-btn"
onClick={() => setLatexMode((s) => !s)}
title={latexMode ? 'Cerrar editor LaTeX' : 'Editor LaTeX'}
style={{
color: latexMode ? 'var(--accent-purple)' : undefined,
background: latexMode ? 'rgba(192,132,252,0.08)' : undefined,
}}
>
<Sigma size={18} />
</button>
{/* Send button */}
<button
onClick={handleSend}
@@ -212,6 +281,28 @@ export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
<Send size={18} />
</button>
</div>
{/* LaTeX preview panel */}
{latexMode && (
<div
style={{
marginTop: 8,
background: 'var(--bg-elevated)',
border: `1px solid ${latexError ? 'var(--accent-coral)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
padding: '12px 16px',
minHeight: 60,
fontSize: 14,
overflowX: 'auto',
}}
>
{latexPreview ? (
<div dangerouslySetInnerHTML={{ __html: latexPreview }} />
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>Vista previa...</span>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Trash2, Calendar, BookOpen, Tag, TrendingUp } from 'lucide-react';
export default function ExamHistory({ exams, onDelete, onRefresh }) {
const [filterTopic, setFilterTopic] = useState('');
const [expandedId, setExpandedId] = useState(null);
useEffect(() => {
onRefresh();
}, [onRefresh]);
const allTopics = useMemo(() => {
const set = new Set();
for (const e of exams) {
if (Array.isArray(e.topics)) {
e.topics.forEach((t) => set.add(t));
}
}
return Array.from(set).sort();
}, [exams]);
const filtered = useMemo(() => {
if (!filterTopic) return exams;
return exams.filter((e) => e.topics && e.topics.includes(filterTopic));
}, [exams, filterTopic]);
const formatDate = (iso) => {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString();
};
return (
<div style={{ padding: '24px', maxWidth: 900, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
<BookOpen size={22} /> Historial de Exámenes
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select
value={filterTopic}
onChange={(e) => setFilterTopic(e.target.value)}
style={{
padding: '8px 12px',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
color: 'var(--text-primary)',
fontSize: 13,
}}
>
<option value="">Todos los temas</option>
{allTopics.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
{filterTopic && (
<button className="btn btn-sm btn-secondary" onClick={() => setFilterTopic('')}>
Limpiar
</button>
)}
</div>
</div>
{filtered.length === 0 ? (
<div className="chat-empty-state" style={{ padding: 48 }}>
<span style={{ fontSize: 32 }}>📝</span>
<p>{exams.length === 0 ? 'Aún no has realizado exámenes.' : 'Ningún examen coincide con el filtro.'}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((exam) => {
const isExpanded = expandedId === exam.id;
return (
<div
key={exam.id}
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
padding: '14px 18px',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
onClick={() => setExpandedId(isExpanded ? null : exam.id)}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: '50%',
background: exam.score >= 80 ? 'rgba(52,211,153,0.12)' : exam.score >= 50 ? 'rgba(251,191,36,0.12)' : 'rgba(248,113,113,0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: exam.score >= 80 ? 'var(--accent-green)' : exam.score >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)',
fontWeight: 700,
fontSize: 13,
}}
>
<TrendingUp size={18} />
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{exam.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
<Calendar size={10} /> {formatDate(exam.taken_at)}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ fontWeight: 700, fontSize: 16, color: exam.score >= 80 ? 'var(--accent-green)' : exam.score >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)' }}>
{exam.score}%
</div>
<button
className="icon-btn"
onClick={(e) => { e.stopPropagation(); onDelete(exam.id); }}
title="Eliminar"
>
<Trash2 size={14} />
</button>
</div>
</div>
{isExpanded && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 8 }}>Temas:</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{Array.isArray(exam.topics) && exam.topics.map((t) => (
<span key={t} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: 'var(--bg-surface)', padding: '4px 10px', borderRadius: 'var(--radius-pill)', fontSize: 12, border: '1px solid var(--border)' }}>
<Tag size={10} /> {t}
</span>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,154 @@
.exam-panel {
max-width: 720px;
margin: 0 auto;
padding: 16px;
}
.exam-panel-empty {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.exam-header {
margin-bottom: 16px;
}
.exam-timer {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.timer-warning {
color: #e74c3c;
}
.exam-progress-bar {
width: 100%;
height: 8px;
background: var(--text-tertiary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.exam-progress-fill {
height: 100%;
transition: width 1s linear;
}
.exam-nav-info {
font-size: 13px;
color: var(--text-secondary);
}
.exam-question h3 {
font-size: 16px;
margin-bottom: 12px;
color: var(--text-primary);
}
.exam-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.exam-option {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
color: var(--text-primary);
}
.exam-option:hover {
background: var(--bg-elevated);
}
.exam-option.selected {
border-color: var(--accent-info);
background: rgba(74, 144, 217, 0.15);
}
.exam-free-text {
width: 100%;
min-height: 120px;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
font-size: 14px;
resize: vertical;
}
.exam-controls {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.exam-btn {
border: none;
border-radius: 8px;
padding: 10px 16px;
font-size: 14px;
cursor: pointer;
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
transition: opacity 0.2s;
}
.exam-btn:hover:not(:disabled) {
opacity: 0.85;
}
.exam-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.exam-btn.submit {
background: var(--accent-info);
color: #fff;
border-color: var(--accent-info);
}
.exam-panel-results {
text-align: center;
padding: 40px;
}
.exam-panel-results h2 {
margin-bottom: 16px;
color: var(--text-primary);
}
.exam-result-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
max-width: 320px;
margin: 0 auto;
}
.exam-score {
font-size: 48px;
font-weight: 700;
color: var(--accent-info);
}
.exam-detail {
font-size: 14px;
color: var(--text-secondary);
margin-top: 8px;
}

View File

@@ -0,0 +1,107 @@
import React from 'react';
import './ExamPanel.css';
export default function ExamPanel({ exam, useExamHook }) {
const {
currentQuestion,
answers,
remainingSeconds,
status,
result,
setAnswer,
goNext,
goPrev,
handleSubmit,
percentTimeRemaining,
} = useExamHook;
if (!exam || !exam.questions) {
return <div className="exam-panel-empty">No hay examen activo</div>;
}
const q = exam.questions[currentQuestion];
const total = exam.questions.length;
const answeredCount = answers.filter((a) => a !== null).length;
const formatTime = (s) => {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, '0')}`;
};
if (status === 'submitted' || status === 'expired') {
return (
<div className="exam-panel-results">
<h2>{status === 'submitted' ? 'Examen enviado' : 'Tiempo expirado'}</h2>
{result && (
<div className="exam-result-card">
<div className="exam-score">{result.score}%</div>
<div className="exam-detail">
Correctas: {result.correct} / {result.total}
</div>
</div>
)}
</div>
);
}
return (
<div className="exam-panel">
<div className="exam-header">
<div className="exam-timer">
<span className={remainingSeconds < 60 ? 'timer-warning' : ''}>
{formatTime(remainingSeconds)}
</span>
</div>
<div className="exam-progress-bar">
<div
className="exam-progress-fill"
style={{ width: `${percentTimeRemaining}%`, background: remainingSeconds < 60 ? '#e74c3c' : '#4a90d9' }}
/>
</div>
<div className="exam-nav-info">
Pregunta {currentQuestion + 1} de {total} · Respondidas: {answeredCount}
</div>
</div>
<div className="exam-question">
<h3>{q.q}</h3>
{q.options && (
<div className="exam-options">
{q.options.map((opt, idx) => (
<label key={idx} className={`exam-option ${answers[currentQuestion] === idx ? 'selected' : ''}`}>
<input
type="radio"
name={`q-${currentQuestion}`}
checked={answers[currentQuestion] === idx}
onChange={() => setAnswer(currentQuestion, idx)}
/>
<span>{opt}</span>
</label>
))}
</div>
)}
{!q.options && (
<textarea
className="exam-free-text"
placeholder="Escribí tu respuesta..."
value={answers[currentQuestion] || ''}
onChange={(e) => setAnswer(currentQuestion, e.target.value)}
/>
)}
</div>
<div className="exam-controls">
<button className="exam-btn" onClick={goPrev} disabled={currentQuestion === 0}>
Anterior
</button>
<button className="exam-btn" onClick={goNext} disabled={currentQuestion === total - 1}>
Siguiente
</button>
<button className="exam-btn submit" onClick={handleSubmit} disabled={status !== 'running'}>
Enviar examen
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import React, { useState, useEffect, useCallback } from 'react';
import { RotateCcw, Eye, Check, Trash2, Edit3, Save, X } from 'lucide-react';
export default function FlashcardReview({ cards, queue, onRefresh, onMarkSeen, onUpdate, onDelete }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [revealed, setRevealed] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editQ, setEditQ] = useState('');
const [editA, setEditA] = useState('');
useEffect(() => {
onRefresh({ seen: 0 });
}, [onRefresh]);
const current = queue[currentIndex];
const total = queue.length;
const handleReveal = () => setRevealed(true);
const handleNext = useCallback(() => {
setRevealed(false);
setEditingId(null);
setCurrentIndex((i) => (i + 1 < queue.length ? i + 1 : i));
}, [queue.length]);
const handleMarkSeen = useCallback(async () => {
if (!current) return;
await onMarkSeen(current.id);
setRevealed(false);
setEditingId(null);
setCurrentIndex((i) => (i < queue.length - 1 ? i : Math.max(0, queue.length - 2)));
}, [current, onMarkSeen, queue.length]);
const handleEditStart = (card) => {
setEditingId(card.id);
setEditQ(card.question);
setEditA(card.answer);
};
const handleEditSave = async () => {
if (!editingId) return;
await onUpdate(editingId, { question: editQ, answer: editA });
setEditingId(null);
};
const progress = total > 0 ? Math.round(((currentIndex + (revealed ? 1 : 0)) / total) * 100) : 0;
return (
<div style={{ padding: '24px', maxWidth: 640, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Flashcards</h2>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{cards.filter((c) => c.seen).length} / {cards.length} vistas
</div>
</div>
{/* Progress bar */}
<div className="progress-bar-bg" style={{ height: 6 }}>
<div className="progress-bar-fill" style={{ width: `${progress}%`, background: 'var(--accent-green)' }} />
</div>
{total === 0 ? (
<div className="chat-empty-state" style={{ padding: 48 }}>
<span style={{ fontSize: 32 }}>🎉</span>
<p>¡No hay flashcards pendientes!</p>
</div>
) : current ? (
<div
style={{
perspective: 1000,
minHeight: 220,
}}
>
<div
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
padding: '24px',
minHeight: 200,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
transition: 'transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease',
transform: revealed ? 'rotateY(180deg)' : 'rotateY(0deg)',
transformStyle: 'preserve-3d',
position: 'relative',
}}
>
{/* Front */}
<div style={{ backfaceVisibility: 'hidden', position: 'absolute', inset: 0, padding: '24px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
{editingId === current.id ? (
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
<textarea value={editQ} onChange={(e) => setEditQ(e.target.value)} rows={3} style={{ width: '100%', background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', padding: 10, fontSize: 14, fontFamily: 'var(--font-ui)' }} />
<textarea value={editA} onChange={(e) => setEditA(e.target.value)} rows={3} style={{ width: '100%', background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', padding: 10, fontSize: 14, fontFamily: 'var(--font-ui)' }} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button className="btn btn-sm btn-primary" onClick={handleEditSave}><Save size={12} /> Guardar</button>
<button className="btn btn-sm btn-secondary" onClick={() => setEditingId(null)}><X size={12} /> Cancelar</button>
</div>
</div>
) : (
<>
<div style={{ fontSize: 18, fontWeight: 600, marginBottom: 16 }}>{current.question}</div>
<button className="btn btn-primary" onClick={handleReveal}><Eye size={14} /> Revelar respuesta</button>
</>
)}
</div>
{/* Back */}
<div style={{ backfaceVisibility: 'hidden', position: 'absolute', inset: 0, padding: '24px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', transform: 'rotateY(180deg)' }}>
<div style={{ fontSize: 16, lineHeight: 1.6, marginBottom: 20 }}>{current.answer}</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', justifyContent: 'center' }}>
<button className="btn btn-sm btn-secondary" onClick={handleNext}><RotateCcw size={12} /> De nuevo</button>
<button className="btn btn-sm btn-primary" onClick={handleMarkSeen}><Check size={12} /> Vista</button>
</div>
</div>
</div>
</div>
) : null}
{/* Controls */}
{current && editingId !== current.id && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 10 }}>
<button className="icon-btn" onClick={() => handleEditStart(current)} title="Editar"><Edit3 size={16} /></button>
<button className="icon-btn" onClick={() => onDelete(current.id)} title="Eliminar"><Trash2 size={16} /></button>
</div>
)}
{/* All cards list */}
{cards.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>Todas las flashcards</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{cards.map((card) => (
<div key={card.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg-surface)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)', fontSize: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{card.question}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: card.seen ? 'var(--accent-green)' : 'var(--text-tertiary)' }}>{card.seen ? 'Vista' : 'Nueva'}</span>
<button className="icon-btn" style={{ padding: 4 }} onClick={() => handleEditStart(card)} title="Editar"><Edit3 size={12} /></button>
<button className="icon-btn" style={{ padding: 4 }} onClick={() => onDelete(card.id)} title="Eliminar"><Trash2 size={12} /></button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -2,37 +2,13 @@ import React, { useState, useEffect, useRef } from 'react';
import { GitBranch, X, GitMerge, Loader2, Send } from 'lucide-react';
import useChat from '../hooks/useChat';
import MessageBubble from './MessageBubble';
import { TypingDots } from './TypingDots';
function StreamingIndicator() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 8px',
color: 'var(--text-tertiary)',
fontSize: 11,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: 'var(--accent-green)',
animation: 'pulse-dot 1.5s ease-in-out infinite',
}}
/>
<span>Pensando...</span>
</div>
);
}
export default function ForkPanel({ forkId, forkTitle, onClose, onMerge }) {
export default function ForkPanel({ forkId, forkTitle, onClose, onMerge, width = 320, onResize }) {
const [merging, setMerging] = useState(false);
const [inputText, setInputText] = useState('');
const messagesEndRef = useRef(null);
const panelRef = useRef(null);
const chatHook = useChat({
conversationId: forkId ?? null,
@@ -43,7 +19,7 @@ export default function ForkPanel({ forkId, forkTitle, onClose, onMerge }) {
chatHook.setActiveId(forkId);
setInputText('');
}
}, [forkId]);
}, [forkId, chatHook.setActiveId]);
useEffect(() => {
if (messagesEndRef.current) {
@@ -75,136 +51,165 @@ export default function ForkPanel({ forkId, forkTitle, onClose, onMerge }) {
}
};
const handlePointerDown = (e) => {
if (!onResize) return;
const startX = e.clientX;
const startWidth = width;
const handlePointerMove = (moveEvent) => {
const deltaX = startX - moveEvent.clientX;
const newWidth = Math.min(480, Math.max(240, startWidth + deltaX));
onResize(newWidth);
};
const handlePointerUp = (upEvent) => {
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
(upEvent.target).releasePointerCapture?.(upEvent.pointerId);
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
e.target.setPointerCapture?.(e.pointerId);
};
const isOpen = forkId !== null;
return (
<aside className={`fork-panel ${isOpen ? 'open' : ''}`}>
<aside className={`fork-panel ${isOpen ? 'open' : ''}`} ref={panelRef} style={{ width: isOpen ? width : undefined }}>
{isOpen && (
<div
style={{
display: 'flex',
flexDirection: 'column',
flexDirection: 'row',
height: '100%',
width: 280,
width,
}}
>
{/* Header */}
<div className="fork-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
<GitBranch size={14} style={{ color: 'var(--accent-amber)', flexShrink: 0 }} />
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={forkTitle}
>
{forkTitle || 'Fork'}
</span>
<div
className="fork-resize-handle"
onPointerDown={handlePointerDown}
title="Arrastrar para redimensionar"
/>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100%' }}>
{/* Header */}
<div className="fork-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
<GitBranch size={14} style={{ color: 'var(--accent-amber)', flexShrink: 0 }} />
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={forkTitle}
>
{forkTitle || 'Fork'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<button
onClick={handleMerge}
disabled={merging}
className="fork-merge-btn"
>
{merging ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<GitMerge size={12} />
)}
{merging ? 'Merging...' : 'Merge & Close'}
</button>
<button
onClick={onClose}
className="fork-close-btn"
title="Close without merge"
>
<X size={14} />
<span style={{ marginLeft: 2 }}>Close</span>
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<button
onClick={handleMerge}
disabled={merging}
className="fork-merge-btn"
>
{merging ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<GitMerge size={12} />
)}
{merging ? 'Merging...' : 'Merge & Close'}
</button>
<button
onClick={onClose}
className="fork-close-btn"
title="Close without merge"
>
<X size={14} />
<span style={{ marginLeft: 2 }}>Close</span>
</button>
</div>
</div>
{/* Compact chat area */}
<div className="fork-chat">
{chatHook.messages.length === 0 && (
<div
{/* Compact chat area */}
<div className="fork-chat">
{chatHook.messages.length === 0 && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-tertiary)',
fontSize: 11,
padding: 16,
}}
>
Escribe un mensaje para comenzar
</div>
)}
{chatHook.messages.map((msg) => (
<MessageBubble key={msg.id ?? msg.created_at} message={msg} />
))}
{chatHook.isStreaming && <TypingDots />}
<div ref={messagesEndRef} />
</div>
{/* Compact chat input */}
<div style={{
borderTop: '1px solid var(--border)',
padding: '6px 8px',
display: 'flex',
gap: 6,
alignItems: 'flex-end',
background: 'var(--bg-surface)',
flexShrink: 0,
}}>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe..."
rows={1}
disabled={chatHook.isStreaming}
style={{
flex: 1,
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
color: 'var(--text-primary)',
fontFamily: 'var(--font-ui)',
fontSize: 12,
padding: '4px 8px',
resize: 'none',
outline: 'none',
maxHeight: 60,
}}
onInput={(e) => {
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 60) + 'px';
}}
/>
<button
onClick={handleSend}
disabled={!inputText.trim() || chatHook.isStreaming}
style={{
background: inputText.trim() && !chatHook.isStreaming ? 'var(--accent-green)' : 'var(--bg-elevated)',
color: inputText.trim() && !chatHook.isStreaming ? '#fff' : 'var(--text-tertiary)',
border: 'none',
borderRadius: 'var(--radius-sm)',
padding: '6px',
cursor: inputText.trim() && !chatHook.isStreaming ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-tertiary)',
fontSize: 11,
padding: 16,
opacity: inputText.trim() && !chatHook.isStreaming ? 1 : 0.5,
}}
>
Escribe un mensaje para comenzar
</div>
)}
{chatHook.messages.map((msg) => (
<MessageBubble key={msg.id ?? msg.created_at} message={msg} />
))}
{chatHook.isStreaming && <StreamingIndicator />}
<div ref={messagesEndRef} />
</div>
{/* Compact chat input */}
<div style={{
borderTop: '1px solid var(--border)',
padding: '6px 8px',
display: 'flex',
gap: 6,
alignItems: 'flex-end',
background: 'var(--bg-surface)',
flexShrink: 0,
}}>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe..."
rows={1}
disabled={chatHook.isStreaming}
style={{
flex: 1,
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
color: 'var(--text-primary)',
fontFamily: 'var(--font-ui)',
fontSize: 12,
padding: '4px 8px',
resize: 'none',
outline: 'none',
maxHeight: 60,
}}
onInput={(e) => {
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 60) + 'px';
}}
/>
<button
onClick={handleSend}
disabled={!inputText.trim() || chatHook.isStreaming}
style={{
background: inputText.trim() && !chatHook.isStreaming ? 'var(--accent-green)' : 'var(--bg-elevated)',
color: inputText.trim() && !chatHook.isStreaming ? '#fff' : 'var(--text-tertiary)',
border: 'none',
borderRadius: 'var(--radius-sm)',
padding: '6px',
cursor: inputText.trim() && !chatHook.isStreaming ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
opacity: inputText.trim() && !chatHook.isStreaming ? 1 : 0.5,
}}
>
<Send size={14} />
</button>
<Send size={14} />
</button>
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import { Sigma, X } from 'lucide-react';
export default function LatexEditor({ text, onTextChange, onSend, disabled }) {
const [active, setActive] = useState(false);
const [previewHtml, setPreviewHtml] = useState('');
const [previewError, setPreviewError] = useState(false);
const debounceRef = useRef(null);
const renderPreview = useCallback((value) => {
if (!value.trim()) {
setPreviewHtml('');
setPreviewError(false);
return;
}
try {
const html = katex.renderToString(value, { throwOnError: false, displayMode: true });
setPreviewHtml(html);
setPreviewError(false);
} catch {
setPreviewError(true);
}
}, []);
useEffect(() => {
if (!active) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
renderPreview(text);
}, 200);
return () => clearTimeout(debounceRef.current);
}, [text, active, renderPreview]);
const handleToggle = () => {
setActive((a) => !a);
if (!active) {
renderPreview(text);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
<textarea
value={text}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
}}
placeholder={active ? 'Escribe LaTeX...' : 'Escribe un mensaje...'}
rows={1}
disabled={disabled}
className="chat-input-textarea"
style={{ flex: 1 }}
/>
<button
className="icon-btn"
onClick={handleToggle}
title={active ? 'Cerrar editor LaTeX' : 'Editor LaTeX'}
style={{
color: active ? 'var(--accent-purple)' : undefined,
background: active ? 'rgba(192,132,252,0.08)' : undefined,
}}
>
{active ? <X size={18} /> : <Sigma size={18} />}
</button>
</div>
{active && (
<div
style={{
background: 'var(--bg-elevated)',
border: `1px solid ${previewError ? 'var(--accent-coral)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
padding: '12px 16px',
minHeight: 60,
fontSize: 14,
overflowX: 'auto',
}}
>
{previewHtml ? (
<div dangerouslySetInnerHTML={{ __html: previewHtml }} />
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>Vista previa...</span>
)}
</div>
)}
</div>
);
}

View File

@@ -2,6 +2,8 @@ import React, { useRef, useEffect, useState, useMemo } from 'react';
import { MessageSquare, GitBranch, X, Maximize2 } from 'lucide-react';
import MessageBubble from './MessageBubble';
import ModelSelector from './ModelSelector';
import { TypingDots } from './TypingDots';
import ScrollFab from './ScrollFab';
function CoordinatePlane({ data }) {
try {
@@ -34,15 +36,6 @@ function CoordinatePlane({ data }) {
} catch { return <div style={{ color: 'var(--accent-coral)', fontSize: 11 }}>Error al renderizar</div>; }
}
function StreamingIndicator() {
return (
<div className="streaming-indicator">
<span className="streaming-indicator-dot" />
<span>Pensando...</span>
</div>
);
}
function EmptyState({ hasConversation }) {
return (
<div className="chat-empty-state">
@@ -89,30 +82,16 @@ function GraphPanel({ messages, onClose }) {
);
}
function extractGraphs(messages) {
const graphs = [];
for (const msg of messages) {
if (msg.role !== 'assistant') continue;
const content = msg.content || '';
const regex = /```graph\n([\s\S]*?)```/g;
const matches = [...content.matchAll(regex)];
for (const match of matches) {
try {
const data = JSON.parse(match[1]);
graphs.push({ data, msgId: msg.id, element: null });
} catch {}
}
}
return graphs;
}
export default function MainChat({
conversation, modelo, modelos, onModelChange, onFork,
messages, isStreaming, MessageBubbleComponent = MessageBubble,
onRenameConversation,
}) {
const messagesEndRef = useRef(null);
const containerRef = useRef(null);
const [graphPanelOpen, setGraphPanelOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const titleInputRef = useRef(null);
const handleForkFromMessage = (topic) => { if (onFork) onFork(topic); };
@@ -126,15 +105,60 @@ export default function MainChat({
}
}, [messages, isStreaming]);
const currentTitle = conversation?.title || 'Selecciona una conversación';
const handleTitleClick = () => {
if (!conversation) return;
setEditingTitle(true);
};
const commitRename = () => {
const input = titleInputRef.current;
if (!input || !conversation) {
setEditingTitle(false);
return;
}
const newTitle = input.value.trim();
if (newTitle && newTitle !== currentTitle) {
onRenameConversation?.(conversation.id, newTitle);
}
setEditingTitle(false);
};
const handleTitleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitRename();
} else if (e.key === 'Escape') {
setEditingTitle(false);
}
};
return (
<div style={{ display: 'flex', flex: 1, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100%', overflow: 'hidden', background: 'var(--bg-base)' }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100%', overflow: 'hidden', background: 'var(--bg-base)', position: 'relative' }}>
{/* Topbar */}
<div className="mainchat-topbar">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<h2 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={conversation?.title}>
{conversation?.title || 'Selecciona una conversación'}
</h2>
{editingTitle ? (
<input
ref={titleInputRef}
className="input-rename"
defaultValue={currentTitle}
autoFocus
onFocus={(e) => e.target.select()}
onBlur={commitRename}
onKeyDown={handleTitleKeyDown}
/>
) : (
<h2
onClick={handleTitleClick}
style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: conversation ? 'pointer' : 'default' }}
title={conversation ? 'Click para renombrar' : currentTitle}
>
{currentTitle}
</h2>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<ModelSelector selectedModelId={modelo?.id ?? null} onSelect={(id) => { const s = modelos.find(m => m.id === id); onModelChange(s || null); }} models={modelos} />
@@ -153,9 +177,11 @@ export default function MainChat({
{messages.map((msg) => (
<MessageBubbleComponent key={msg.id ?? msg.created_at} message={msg} onFork={msg.role === 'assistant' ? handleForkFromMessage : undefined} />
))}
{isStreaming && <StreamingIndicator />}
{isStreaming && <TypingDots />}
<div ref={messagesEndRef} />
</div>
<ScrollFab containerRef={containerRef} threshold={200} />
</div>
{graphPanelOpen && hasGraphs && <GraphPanel messages={messages} onClose={() => setGraphPanelOpen(false)} />}
</div>

View File

@@ -1,7 +1,10 @@
import React, { useMemo } from 'react';
import React, { useMemo, useEffect, useState, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import { GitMerge, GitBranch } from 'lucide-react';
import { GitMerge, GitBranch, Copy, Check } from 'lucide-react';
import katex from 'katex';
import Prism from 'prismjs';
import 'prismjs/themes/prism-tomorrow.css';
import { useReactions } from '../context/ReactionsContext';
function LatexRenderer({ text }) {
const parts = useMemo(() => {
@@ -26,9 +29,9 @@ function LatexRenderer({ text }) {
return parts.map((part, i) =>
part.type === 'latex' ? (
<span key={i} dangerouslySetInnerHTML={{ __html: part.html }}
<span key={`${i}-${part.type}`} dangerouslySetInnerHTML={{ __html: part.html }}
style={part.isBlock ? { display: 'block', margin: '12px 0', textAlign: 'center' } : {}} />
) : <React.Fragment key={i}>{part.content}</React.Fragment>
) : <React.Fragment key={`${i}-${part.type}`}>{part.content}</React.Fragment>
);
}
@@ -70,6 +73,36 @@ function CoordinatePlane({ data }) {
} catch { return <div style={{ color: 'var(--accent-coral)', fontSize: 12 }}>Error al renderizar gráfico</div>; }
}
function CodeBlock({ className, children }) {
const codeRef = useRef(null);
const code = String(children).trim();
useEffect(() => {
if (!codeRef.current || !className) return;
const langMatch = className.match(/language-(\w+)/);
const lang = langMatch ? langMatch[1] : null;
if (lang && Prism.languages[lang]) {
codeRef.current.innerHTML = Prism.highlight(code, Prism.languages[lang], lang);
}
}, [code, className]);
if (!className || !className.startsWith('language-')) {
return <code className={className}>{children}</code>;
}
const langMatch = className.match(/language-(\w+)/);
const lang = langMatch ? langMatch[1] : null;
const canHighlight = lang && Prism.languages[lang];
return (
<pre>
<code className={className} ref={canHighlight ? codeRef : null}>
{canHighlight ? '' : children}
</code>
</pre>
);
}
function MarkdownContent({ content }) {
return (
<div className="markdown-body">
@@ -80,11 +113,16 @@ function MarkdownContent({ content }) {
if (className === 'language-graph') return <CoordinatePlane data={code} />;
const isInline = !className || !className.startsWith('language-');
if (isInline) return <code {...props}>{children}</code>;
return <pre><code className={className} {...props}>{children}</code></pre>;
return <CodeBlock className={className}>{children}</CodeBlock>;
},
p({ children }) {
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.map(c => typeof c === 'string' ? c : '').join('') : '';
return <p style={{ marginBottom: 8 }}><LatexRenderer text={text} /></p>;
const arr = React.Children.toArray(children);
const hasRichContent = arr.some(c => typeof c !== 'string');
if (!hasRichContent) {
const text = arr.join('');
return <p style={{ marginBottom: 8 }}><LatexRenderer text={text} /></p>;
}
return <p style={{ marginBottom: 8 }}>{children}</p>;
},
h1({ children }) { return <h1 style={{ fontSize: 18, fontWeight: 700, marginBottom: 10, marginTop: 6, background: 'linear-gradient(135deg, var(--accent-green), var(--accent-info))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>{children}</h1>; },
h2({ children }) { return <h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 8, marginTop: 6 }}>{children}</h2>; },
@@ -103,7 +141,11 @@ function MarkdownContent({ content }) {
}
export default function MessageBubble({ message, onFork }) {
const { role, content, created_at } = message;
const { role, content, created_at, id } = message;
const reactions = useReactions();
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef(null);
if (role === 'system') return null;
if (!content) return null;
@@ -117,8 +159,22 @@ export default function MessageBubble({ message, onFork }) {
}
const isUser = role === 'user';
const msgId = id ?? created_at;
const userReaction = !isUser ? reactions.get(msgId) : null;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
} catch {
// ignore
}
};
return (
<div className="message-bubble" style={{ display: 'flex', justifyContent: isUser ? 'flex-end' : 'flex-start', width: '100%' }}>
<div className="message-bubble" style={{ display: 'flex', justifyContent: isUser ? 'flex-end' : 'flex-start', width: '100%', position: 'relative' }}>
<div style={{ maxWidth: '80%', padding: isUser ? '10px 14px' : '0', background: isUser ? 'var(--bg-elevated)' : 'transparent', borderRadius: isUser ? '12px 12px 4px 12px' : '0', color: 'var(--text-primary)', fontSize: 14, lineHeight: 1.5, wordBreak: 'break-word' }}>
{isUser ? <div>{content}</div> : <MarkdownContent content={content} />}
{!isUser && onFork && (
@@ -139,8 +195,32 @@ export default function MessageBubble({ message, onFork }) {
<GitBranch size={11} /> Fork
</button>
)}
{!isUser && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 }}>
<button
className={`reaction-pill ${userReaction === '👍' ? 'reaction-pill--active' : ''}`}
onClick={() => reactions.toggle(msgId, '👍')}
title="Me gusta"
>
👍
</button>
<button
className={`reaction-pill ${userReaction === '👎' ? 'reaction-pill--active' : ''}`}
onClick={() => reactions.toggle(msgId, '👎')}
title="No me gusta"
>
👎
</button>
</div>
)}
{created_at && <div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginTop: 4, textAlign: isUser ? 'right' : 'left' }}>{new Date(created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>}
</div>
{!isUser && (
<button className="copy-btn" onClick={handleCopy} title="Copiar mensaje">
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copiado' : 'Copiar'}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,149 @@
import React, { useState, useRef, useCallback } from 'react';
import { Upload, X } from 'lucide-react';
function ComparePanel({ url, side, onReplace, onClear, onFileSelected, inputRef }) {
return (
<div
style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-surface)',
}}
>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>
{side === 'left' ? 'PDF A' : 'PDF B'}
</span>
<div style={{ display: 'flex', gap: 6 }}>
<button className="icon-btn" style={{ padding: 4 }} onClick={onReplace} title="Reemplazar">
<Upload size={14} />
</button>
{url && (
<button
className="icon-btn"
style={{ padding: 4 }}
onClick={onClear}
title="Quitar"
>
<X size={14} />
</button>
)}
</div>
</div>
<div style={{ flex: 1, position: 'relative' }}>
{url ? (
<iframe
src={url}
style={{ width: '100%', height: '100%', border: 'none', background: '#fff' }}
title={`PDF ${side}`}
/>
) : (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-tertiary)',
gap: 8,
padding: 40,
}}
>
<Upload size={28} opacity={0.4} />
<span style={{ fontSize: 13 }}>Arrastra o sube un PDF</span>
<button className="btn btn-sm btn-secondary" onClick={() => inputRef.current?.click()}>
Seleccionar archivo
</button>
</div>
)}
</div>
<input
ref={inputRef}
type="file"
accept=".pdf"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onFileSelected(file);
e.target.value = '';
}}
/>
</div>
);
}
export default function MultiPdfCompare() {
const [leftUrl, setLeftUrl] = useState(null);
const [rightUrl, setRightUrl] = useState(null);
const leftInputRef = useRef(null);
const rightInputRef = useRef(null);
const handleReplace = (side) => {
if (side === 'left') {
leftInputRef.current?.click();
} else {
rightInputRef.current?.click();
}
};
const handleClear = (side) => {
if (side === 'left') {
if (leftUrl) URL.revokeObjectURL(leftUrl);
setLeftUrl(null);
} else {
if (rightUrl) URL.revokeObjectURL(rightUrl);
setRightUrl(null);
}
};
const handleFile = useCallback((file, side) => {
if (!file) return;
const url = URL.createObjectURL(file);
if (side === 'left') {
if (leftUrl) URL.revokeObjectURL(leftUrl);
setLeftUrl(url);
} else {
if (rightUrl) URL.revokeObjectURL(rightUrl);
setRightUrl(url);
}
}, [leftUrl, rightUrl]);
return (
<div style={{ padding: '24px', height: 'calc(100vh - 48px)', display: 'flex', flexDirection: 'column' }}>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>Comparar PDFs</h2>
<div style={{ flex: 1, display: 'flex', gap: 16, minHeight: 0 }}>
<ComparePanel
url={leftUrl}
side="left"
onReplace={() => handleReplace('left')}
onClear={() => handleClear('left')}
onFileSelected={(file) => handleFile(file, 'left')}
inputRef={leftInputRef}
/>
<ComparePanel
url={rightUrl}
side="right"
onReplace={() => handleReplace('right')}
onClear={() => handleClear('right')}
onFileSelected={(file) => handleFile(file, 'right')}
inputRef={rightInputRef}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Play, Pause, RotateCcw, Clock } from 'lucide-react';
const STUDY_MINUTES = 25;
const BREAK_MINUTES = 5;
export default function PomodoroTimer() {
const [phase, setPhase] = useState('study'); // 'study' | 'break'
const [state, setState] = useState('idle'); // 'idle' | 'running' | 'paused'
const [remaining, setRemaining] = useState(STUDY_MINUTES * 60);
const intervalRef = useRef(null);
const phaseRef = useRef(phase);
useEffect(() => {
phaseRef.current = phase;
}, [phase]);
const notify = useCallback((title, body) => {
if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
try {
new Notification(title, { body });
} catch {
// ignore
}
}
}, []);
const requestPermission = useCallback(() => {
if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().catch(() => {});
}
}, []);
const clearTimer = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
const start = useCallback(() => {
requestPermission();
setState('running');
clearTimer();
intervalRef.current = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) {
// Phase transition using ref to avoid stale closure
const nextPhase = phaseRef.current === 'study' ? 'break' : 'study';
const nextDuration = nextPhase === 'study' ? STUDY_MINUTES * 60 : BREAK_MINUTES * 60;
setPhase(nextPhase);
if (nextPhase === 'break') {
notify('Pomodoro', '¡Tiempo de descanso! 5 minutos.');
} else {
notify('Pomodoro', '¡De vuelta al estudio! 25 minutos.');
}
return nextDuration;
}
return prev - 1;
});
}, 1000);
}, [clearTimer, notify, requestPermission]);
const pause = useCallback(() => {
clearTimer();
setState('paused');
}, [clearTimer]);
const reset = useCallback(() => {
clearTimer();
setState('idle');
setPhase('study');
setRemaining(STUDY_MINUTES * 60);
}, [clearTimer]);
useEffect(() => {
return () => clearTimer();
}, [clearTimer]);
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
const display = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
const progress = phase === 'study'
? ((STUDY_MINUTES * 60 - remaining) / (STUDY_MINUTES * 60)) * 100
: ((BREAK_MINUTES * 60 - remaining) / (BREAK_MINUTES * 60)) * 100;
return (
<div style={{ padding: '24px', maxWidth: 480, margin: '0 auto', textAlign: 'center' }}>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<Clock size={22} /> Pomodoro
</h2>
<div
style={{
width: 220,
height: 220,
borderRadius: '50%',
margin: '0 auto 24px',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: `conic-gradient(var(--accent-green) ${progress}%, var(--bg-elevated) ${progress}%)`,
boxShadow: '0 0 40px rgba(52,211,153,0.1)',
}}
>
<div
style={{
width: 190,
height: 190,
borderRadius: '50%',
background: 'var(--bg-surface)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div style={{ fontSize: 42, fontWeight: 700, fontFamily: 'var(--font-mono)', letterSpacing: '0.05em' }}>{display}</div>
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
{phase === 'study' ? 'Estudio' : 'Descanso'}
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 12 }}>
{state === 'running' ? (
<button className="btn btn-secondary" onClick={pause}><Pause size={16} /> Pausar</button>
) : (
<button className="btn btn-primary" onClick={start}><Play size={16} /> {state === 'paused' ? 'Continuar' : 'Iniciar'}</button>
)}
<button className="btn btn-secondary" onClick={reset}><RotateCcw size={16} /> Reiniciar</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import React, { useMemo, useState, useCallback } from 'react';
import roadmapData from '../data/roadmap.json';
export default function RoadmapVisual({ onForkTopic }) {
const [selectedDomain, setSelectedDomain] = useState(null);
const { nodes, edges, domains } = useMemo(() => {
const allTopics = [];
const domainMap = new Map();
roadmapData.domains.forEach((d) => {
domainMap.set(d.name, d.color);
d.topics.forEach((t) => {
allTopics.push({ ...t, domain: d.name, color: d.color });
});
});
// Deterministic layout: concentric rings by domain
const centerX = 400;
const centerY = 300;
const domainRadius = { Matemáticas: 100, Ciencias: 180, Informática: 260, Humanidades: 340 };
const nodes = allTopics.map((t, i) => {
const domainTopics = allTopics.filter((x) => x.domain === t.domain);
const idxInDomain = domainTopics.findIndex((x) => x.id === t.id);
const count = domainTopics.length;
const radius = domainRadius[t.domain] || 200;
const angle = (idxInDomain / count) * Math.PI * 2 - Math.PI / 2;
return {
...t,
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
};
});
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const edges = roadmapData.edges
.filter((e) => nodeMap.has(e.from) && nodeMap.has(e.to))
.map((e) => ({ from: nodeMap.get(e.from), to: nodeMap.get(e.to) }));
return { nodes, edges, domains: roadmapData.domains };
}, []);
const handleNodeClick = useCallback(
(node) => {
if (onForkTopic) onForkTopic(node.name);
},
[onForkTopic]
);
const filteredNodes = selectedDomain ? nodes.filter((n) => n.domain === selectedDomain) : nodes;
const filteredEdges = selectedDomain
? edges.filter((e) => e.from.domain === selectedDomain && e.to.domain === selectedDomain)
: edges;
return (
<div style={{ padding: '24px', maxWidth: 900, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Mapa de Temas</h2>
<div style={{ display: 'flex', gap: 6 }}>
<button
className={`btn btn-sm ${selectedDomain === null ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setSelectedDomain(null)}
>
Todos
</button>
{domains.map((d) => (
<button
key={d.name}
className={`btn btn-sm ${selectedDomain === d.name ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setSelectedDomain(d.name)}
style={selectedDomain === d.name ? { background: d.color, color: '#0f1117' } : {}}
>
{d.name}
</button>
))}
</div>
</div>
<div style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
<svg viewBox="0 0 800 600" style={{ width: '100%', height: 'auto', display: 'block' }}>
{/* Edges */}
{filteredEdges.map((e, i) => (
<line
key={i}
x1={e.from.x}
y1={e.from.y}
x2={e.to.x}
y2={e.to.y}
stroke="var(--border-glow)"
strokeWidth={1.5}
opacity={0.6}
/>
))}
{/* Nodes */}
{filteredNodes.map((node) => (
<g key={node.id} onClick={() => handleNodeClick(node)} style={{ cursor: 'pointer' }}>
<circle
cx={node.x}
cy={node.y}
r={28}
fill={node.color + '20'}
stroke={node.color}
strokeWidth={2}
/>
<text
x={node.x}
y={node.y + 4}
textAnchor="middle"
fill="var(--text-primary)"
fontSize={11}
fontFamily="var(--font-ui)"
fontWeight={600}
>
{node.name}
</text>
</g>
))}
</svg>
</div>
{/* Legend */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 14, marginTop: 16 }}>
{domains.map((d) => (
<div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: d.color }} />
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{d.name}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React, { useState, useEffect } from 'react';
import { ArrowDown } from 'lucide-react';
export default function ScrollFab({ containerRef, threshold = 200 }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const container = containerRef?.current;
if (!container) return;
const handleScroll = () => {
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
setVisible(distanceFromBottom > threshold);
};
container.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => container.removeEventListener('scroll', handleScroll);
}, [containerRef, threshold]);
const handleClick = () => {
const container = containerRef?.current;
if (!container) return;
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
};
return (
<button
className={`scroll-fab ${visible ? 'scroll-fab--visible' : ''}`}
onClick={handleClick}
aria-label="Scroll to new messages"
>
<ArrowDown size={14} />
<span>Nuevos mensajes</span>
</button>
);
}

View File

@@ -0,0 +1,281 @@
import React, { useRef, useEffect } from 'react';
import { Search, X, MessageSquare, FileText } from 'lucide-react';
import useSearch from '../hooks/useSearch';
export default function SearchBar({ inputRef, onNavigate }) {
const { query, setQuery, results, loading, clear } = useSearch();
const containerRef = useRef(null);
const timeoutRef = useRef(null);
const hasResults = results.length > 0;
const isOpen = query.trim().length > 0;
// Cleanup timeout on unmount
useEffect(() => () => clearTimeout(timeoutRef.current), []);
// Click outside to close
useEffect(() => {
function handleClick(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
clear();
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}
}, [isOpen, clear]);
const messageResults = results.filter((r) => r.type === 'message');
const pdfResults = results.filter((r) => r.type === 'pdf');
const handleClickResult = (result) => {
if (result.type === 'message' && result.conversation_id) {
onNavigate?.('/');
// Small delay to let route change before attempting to select conversation
timeoutRef.current = setTimeout(() => {
window.dispatchEvent(
new CustomEvent('studyos:select-conversation', {
detail: { id: result.conversation_id },
})
);
}, 50);
} else if (result.type === 'pdf') {
onNavigate?.('/compare');
}
clear();
};
const stripHtml = (html) => {
if (!html) return '';
return html.replace(/<[^>]+>/g, '');
};
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<div style={{ position: 'relative', marginBottom: 8 }}>
<Search
size={12}
style={{ position: 'absolute', left: 8, top: 7, color: 'var(--text-tertiary)' }}
/>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
style={{
width: '100%',
padding: '5px 24px 5px 24px',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
color: 'var(--text-primary)',
fontSize: 11,
outline: 'none',
fontFamily: 'var(--font-ui)',
}}
/>
{query && (
<button
onClick={clear}
style={{
position: 'absolute',
right: 6,
top: 5,
background: 'none',
border: 'none',
color: 'var(--text-tertiary)',
cursor: 'pointer',
padding: 2,
display: 'flex',
alignItems: 'center',
}}
title="Limpiar"
>
<X size={12} />
</button>
)}
</div>
{isOpen && (
<div
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-lg)',
zIndex: 20,
maxHeight: 320,
overflowY: 'auto',
backdropFilter: 'blur(24px)',
}}
>
{loading && (
<div
style={{
padding: '10px 12px',
fontSize: 12,
color: 'var(--text-tertiary)',
textAlign: 'center',
}}
>
Buscando...
</div>
)}
{!loading && !hasResults && (
<div
style={{
padding: '10px 12px',
fontSize: 12,
color: 'var(--text-tertiary)',
textAlign: 'center',
}}
>
Sin resultados
</div>
)}
{messageResults.length > 0 && (
<div>
<div
style={{
padding: '6px 12px',
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--accent-info)',
borderBottom: '1px solid var(--border)',
}}
>
Mensajes
</div>
{messageResults.slice(0, 10).map((r) => (
<button
key={`msg-${r.id}`}
onClick={() => handleClickResult(r)}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
width: '100%',
padding: '8px 12px',
border: 'none',
background: 'transparent',
color: 'var(--text-secondary)',
fontSize: 12,
textAlign: 'left',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
fontFamily: 'var(--font-ui)',
}}
>
<MessageSquare size={12} style={{ flexShrink: 0, marginTop: 2, color: 'var(--accent-info)' }} />
<div style={{ minWidth: 0 }}>
<div
style={{
fontWeight: 500,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={r.title}
>
{r.title}
</div>
<div
style={{
fontSize: 11,
color: 'var(--text-tertiary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginTop: 2,
}}
title={stripHtml(r.snippet)}
>
{stripHtml(r.snippet)}
</div>
</div>
</button>
))}
</div>
)}
{pdfResults.length > 0 && (
<div>
<div
style={{
padding: '6px 12px',
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--accent-info)',
borderBottom: '1px solid var(--border)',
}}
>
PDFs
</div>
{pdfResults.slice(0, 10).map((r) => (
<button
key={`pdf-${r.id}`}
onClick={() => handleClickResult(r)}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
width: '100%',
padding: '8px 12px',
border: 'none',
background: 'transparent',
color: 'var(--text-secondary)',
fontSize: 12,
textAlign: 'left',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
fontFamily: 'var(--font-ui)',
}}
>
<FileText size={12} style={{ flexShrink: 0, marginTop: 2, color: 'var(--accent-purple)' }} />
<div style={{ minWidth: 0 }}>
<div
style={{
fontWeight: 500,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={r.title}
>
{r.title}
</div>
<div
style={{
fontSize: 11,
color: 'var(--text-tertiary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginTop: 2,
}}
title={stripHtml(r.snippet)}
>
{stripHtml(r.snippet)}
</div>
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -8,7 +8,11 @@ import {
import { CSS } from '@dnd-kit/utilities';
import {
Settings, GripVertical, Trash2, FileText, Plus, StickyNote, Search, ChevronDown, ChevronRight,
Clock, Map, BookOpen, Layers, BarChart3, FileSpreadsheet,
} from 'lucide-react';
import { Skeleton } from './Skeleton';
import SearchBar from './SearchBar';
import ThemeToggle from './ThemeToggle';
function DragOverlayItem({ pdf }) {
return (
@@ -34,7 +38,10 @@ function SortablePdfItem({ pdf, onDelete }) {
export default function Sidebar({
collapsed, onToggle, pdfs, progress, conversations, activeConversation, notes,
onSelectConversation, onNewConversation, onUploadPdf, onReorderPdf, onDeletePdf,
onResetTopic, onNavigateSettings,
onResetTopic, onNavigateSettings, onNavigate,
hasLoadedConversations = false, hasLoadedPdfs = false,
isMobileOpen = false, onClose,
searchInputRef, dueCount = 0,
}) {
const fileInputRef = useRef(null);
const [pdfItems, setPdfItems] = useState(() => pdfs);
@@ -79,99 +86,153 @@ export default function Sidebar({
}
return (
<aside className="app-sidebar" style={{ width: 260 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 14px', borderBottom: '1px solid var(--border)' }}>
<h1 style={{ fontSize: 18, fontWeight: 800, letterSpacing: '-0.03em', background: 'linear-gradient(135deg, var(--accent-info), var(--accent-purple))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>StudyOS</h1>
<div className="avatar-dot">U</div>
</div>
<>
{isMobileOpen && (
<div className="sidebar-backdrop" onClick={onClose} aria-hidden="true" />
)}
<aside className={`app-sidebar ${isMobileOpen ? 'sidebar--open' : ''}`} style={{ width: 260 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 14px', borderBottom: '1px solid var(--border)' }}>
<h1 style={{ fontSize: 18, fontWeight: 800, letterSpacing: '-0.03em', background: 'linear-gradient(135deg, var(--accent-info), var(--accent-purple))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>StudyOS</h1>
<div className="avatar-dot">U</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '12px', display: 'flex', flexDirection: 'column', gap: 18 }}>
{/* PDFs */}
<section>
<SectionHeader icon="📄" label="PDFs" expanded={pdfsExpanded} onToggle={() => setPdfsExpanded(!pdfsExpanded)} />
{pdfsExpanded && (
<>
{pdfItems.length > 3 && (
<div style={{ position: 'relative', marginBottom: 8 }}>
<Search size={12} style={{ position: 'absolute', left: 8, top: 7, color: 'var(--text-tertiary)' }} />
<input value={pdfSearch} onChange={e => setPdfSearch(e.target.value)} placeholder="Buscar..."
style={{ width: '100%', padding: '5px 8px 5px 24px', background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: 11, outline: 'none', fontFamily: 'var(--font-ui)' }} />
</div>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={filteredPdfs.map(p => p.id)} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, maxHeight: pdfSearch ? 200 : 160, overflowY: 'auto' }}>
{filteredPdfs.map(pdf => <SortablePdfItem key={pdf.id} pdf={pdf} onDelete={onDeletePdf} />)}
{filteredPdfs.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>{pdfSearch ? 'Sin resultados' : 'Sin PDFs'}</span>}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px', display: 'flex', flexDirection: 'column', gap: 18 }}>
{/* Search */}
<section>
<SearchBar inputRef={searchInputRef} onNavigate={onNavigate} />
</section>
{/* PDFs */}
<section>
<SectionHeader icon="📄" label="PDFs" expanded={pdfsExpanded} onToggle={() => setPdfsExpanded(!pdfsExpanded)} />
{pdfsExpanded && (
<>
{pdfItems.length > 3 && (
<div style={{ position: 'relative', marginBottom: 8 }}>
<Search size={12} style={{ position: 'absolute', left: 8, top: 7, color: 'var(--text-tertiary)' }} />
<input value={pdfSearch} onChange={e => setPdfSearch(e.target.value)} placeholder="Buscar..."
style={{ width: '100%', padding: '5px 8px 5px 24px', background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: 11, outline: 'none', fontFamily: 'var(--font-ui)' }} />
</div>
</SortableContext>
</DndContext>
<button className="sidebar-upload-btn" onClick={() => fileInputRef.current?.click()}><Plus size={14} /> Subir PDF</button>
<input ref={fileInputRef} type="file" accept=".pdf" style={{ display: 'none' }} onChange={handleFileChange} />
</>
)}
</section>
{/* Progress */}
<section>
<SectionHeader icon="📊" label="Progreso" expanded={progressExpanded} onToggle={() => setProgressExpanded(!progressExpanded)} />
{progressExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{progress.map(p => {
const color = p.percentage >= 80 ? 'var(--accent-green)' : p.percentage >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)';
return (
<div key={p.topic}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4, color: 'var(--text-secondary)' }}>
<span style={{ fontWeight: 500 }}>{p.topic}</span>
<span style={{ color: 'var(--text-tertiary)' }}>{p.exercises_correct}/{p.exercises_done}</span>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={filteredPdfs.map(p => p.id)} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, maxHeight: pdfSearch ? 200 : 160, overflowY: 'auto' }}>
{filteredPdfs.map(pdf => <SortablePdfItem key={pdf.id} pdf={pdf} onDelete={onDeletePdf} />)}
{filteredPdfs.length === 0 && (
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>
{pdfSearch ? 'Sin resultados' : 'Sin PDFs'}
</span>
)}
</div>
<div className="sidebar-progress-bar"><div style={{ width: `${p.percentage}%`, background: color, boxShadow: `0 0 8px ${color}40` }} /></div>
</div>
);
})}
</div>
)}
</section>
</SortableContext>
</DndContext>
{pdfs.length === 0 && !hasLoadedPdfs && (
<>
<Skeleton variant="pdf" />
<Skeleton variant="pdf" />
</>
)}
<button className="sidebar-upload-btn" onClick={() => fileInputRef.current?.click()}><Plus size={14} /> Subir PDF</button>
<input ref={fileInputRef} type="file" accept=".pdf" style={{ display: 'none' }} onChange={handleFileChange} />
</>
)}
</section>
{/* Chats */}
<section>
<SectionHeader icon="💬" label="Chats" expanded={chatsExpanded} onToggle={() => setChatsExpanded(!chatsExpanded)}
extra={<button onClick={onNewConversation} className="icon-btn" style={{ padding: 2 }} title="Nueva"><Plus size={14} /></button>} />
{chatsExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{conversations.map(conv => {
const isActive = activeConversation?.id === conv.id;
return (
<button key={conv.id} onClick={() => onSelectConversation(conv)} className={`sidebar-conv-item ${isActive ? 'active' : ''}`}>
<FileText size={14} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{conv.title}</span>
{/* Progress */}
<section>
<SectionHeader icon="📊" label="Progreso" expanded={progressExpanded} onToggle={() => setProgressExpanded(!progressExpanded)} />
{progressExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{progress.map(p => {
const color = p.percentage >= 80 ? 'var(--accent-green)' : p.percentage >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)';
return (
<div key={p.topic}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4, color: 'var(--text-secondary)' }}>
<span style={{ fontWeight: 500 }}>{p.topic}</span>
<span style={{ color: 'var(--text-tertiary)' }}>{p.exercises_correct}/{p.exercises_done}</span>
</div>
<div className="sidebar-progress-bar"><div style={{ width: `${p.percentage}%`, background: color, boxShadow: `0 0 8px ${color}40` }} /></div>
</div>
);
})}
</div>
)}
</section>
{/* Chats */}
<section>
<SectionHeader icon="💬" label="Chats" expanded={chatsExpanded} onToggle={() => setChatsExpanded(!chatsExpanded)}
extra={<button onClick={onNewConversation} className="icon-btn" style={{ padding: 2 }} title="Nueva"><Plus size={14} /></button>} />
{chatsExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{conversations.length === 0 && !hasLoadedConversations && (
<>
<Skeleton variant="conv" />
<Skeleton variant="conv" />
<Skeleton variant="conv" />
</>
)}
{conversations.map(conv => {
const isActive = activeConversation?.id === conv.id;
return (
<button key={conv.id} onClick={() => onSelectConversation(conv)} className={`sidebar-conv-item ${isActive ? 'active' : ''}`}>
<FileText size={14} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{conv.title}</span>
</button>
);
})}
</div>
)}
</section>
{/* Notes */}
<section>
<SectionHeader icon="📝" label="Notas" expanded={notesExpanded} onToggle={() => setNotesExpanded(!notesExpanded)}
extra={<button className="icon-btn" style={{ padding: 2 }} title="Nueva" onClick={onNavigateSettings}><Plus size={14} /></button>} />
{notesExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{notes.slice(0, 8).map(note => (
<button key={note.id} className="sidebar-note-item">
<StickyNote size={12} /> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{note.title}</span>
</button>
);
})}
</div>
)}
</section>
))}
{notes.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>Sin notas</span>}
</div>
)}
</section>
</div>
{/* Notes */}
<section>
<SectionHeader icon="📝" label="Notas" expanded={notesExpanded} onToggle={() => setNotesExpanded(!notesExpanded)}
extra={<button className="icon-btn" style={{ padding: 2 }} title="Nueva" onClick={onNavigateSettings}><Plus size={14} /></button>} />
{notesExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{notes.slice(0, 8).map(note => (
<button key={note.id} className="sidebar-note-item">
<StickyNote size={12} /> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{note.title}</span>
</button>
))}
{notes.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>Sin notas</span>}
</div>
)}
</section>
</div>
<div style={{ borderTop: '1px solid var(--border)', padding: '10px 14px' }}>
<button onClick={onNavigateSettings} className="sidebar-nav-btn"><Settings size={16} /><span>Settings</span></button>
</div>
</aside>
<div style={{ borderTop: '1px solid var(--border)', padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 2 }}>
<button onClick={() => onNavigate?.('/timer')} className="sidebar-nav-btn"><Clock size={16} /><span>Timer</span></button>
<button onClick={() => onNavigate?.('/roadmap')} className="sidebar-nav-btn"><Map size={16} /><span>Roadmap</span></button>
<button onClick={() => onNavigate?.('/exams')} className="sidebar-nav-btn"><BookOpen size={16} /><span>Exámenes</span></button>
<button onClick={() => onNavigate?.('/flashcards')} className="sidebar-nav-btn" style={{ position: 'relative' }}>
<Layers size={16} />
<span>Flashcards</span>
{dueCount > 0 && (
<span style={{
marginLeft: 'auto',
background: 'var(--accent-coral)',
color: '#fff',
fontSize: 10,
fontWeight: 700,
padding: '2px 6px',
borderRadius: 'var(--radius-pill)',
minWidth: 18,
textAlign: 'center',
}}>
{dueCount}
</span>
)}
</button>
<button onClick={() => onNavigate?.('/heatmap')} className="sidebar-nav-btn"><BarChart3 size={16} /><span>Heatmap</span></button>
<button onClick={() => onNavigate?.('/compare')} className="sidebar-nav-btn"><FileSpreadsheet size={16} /><span>Comparar PDFs</span></button>
<button onClick={onNavigateSettings} className="sidebar-nav-btn"><Settings size={16} /><span>Settings</span></button>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 4, paddingTop: 8, borderTop: '1px solid var(--border)' }}>
<ThemeToggle />
</div>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,21 @@
export function Skeleton({ variant = 'conv' }) {
if (variant === 'pdf') {
return (
<div className="skeleton-card skeleton-pdf">
<div className="skeleton-grip" />
<div className="skeleton-lines">
<div className="skeleton-line skeleton-line--short" />
<div className="skeleton-line skeleton-line--shorter" />
</div>
<div className="skeleton-shimmer" />
</div>
);
}
return (
<div className="skeleton-card skeleton-conv">
<div className="skeleton-line skeleton-line--text" />
<div className="skeleton-shimmer" />
</div>
);
}

View File

@@ -0,0 +1,152 @@
.buddy-panel {
max-width: 720px;
margin: 0 auto;
padding: 16px;
}
.buddy-panel.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.buddy-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.buddy-avatars {
display: flex;
gap: 8px;
}
.buddy-avatar {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.buddy-avatar-icon {
font-size: 28px;
}
.buddy-role-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--accent-info);
color: #fff;
}
.buddy-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.buddy-share-section {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.buddy-token {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
}
.buddy-token code {
background: var(--bg-surface);
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border);
font-size: 12px;
}
.buddy-join {
display: flex;
gap: 8px;
}
.buddy-join input {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
font-size: 13px;
}
.buddy-btn {
border: none;
border-radius: 6px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
background: var(--accent-info);
color: #fff;
transition: opacity 0.2s;
}
.buddy-btn:hover {
opacity: 0.85;
}
.buddy-feed {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 60vh;
overflow-y: auto;
padding: 8px;
border-radius: 8px;
background: var(--bg-surface);
border: 1px solid var(--border);
}
.buddy-msg {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
border-radius: 8px;
background: var(--bg-base);
}
.buddy-msg.user {
border-left: 3px solid var(--accent-info);
}
.buddy-msg.assistant {
border-left: 3px solid #27ae60;
}
.buddy-msg-role {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
}
.buddy-msg-content {
font-size: 14px;
color: var(--text-primary);
white-space: pre-wrap;
}
.buddy-empty {
text-align: center;
padding: 24px;
color: var(--text-secondary);
font-size: 14px;
}

View File

@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import './StudyBuddyPanel.css';
export default function StudyBuddyPanel({
conversation,
messages,
roleLabel,
counterpartLabel,
onShare,
shareToken,
onJoin,
loading,
}) {
const [joinToken, setJoinToken] = useState('');
if (loading) {
return <div className="buddy-panel loading">Cargando modo compañero...</div>;
}
return (
<div className="buddy-panel">
<div className="buddy-header">
<div className="buddy-avatars">
<div className="buddy-avatar self">
<span className="buddy-avatar-icon">🎓</span>
<span className="buddy-role-badge">{roleLabel}</span>
</div>
<div className="buddy-avatar other">
<span className="buddy-avatar-icon">🎓</span>
<span className="buddy-role-badge">{counterpartLabel}</span>
</div>
</div>
<div className="buddy-title">
{conversation?.title || 'Conversación compartida'}
</div>
</div>
<div className="buddy-share-section">
{!shareToken && (
<button className="buddy-btn" onClick={onShare}>
Compartir conversación
</button>
)}
{shareToken && (
<div className="buddy-token">
<label>Token de compartir:</label>
<code>{shareToken}</code>
</div>
)}
<div className="buddy-join">
<input
type="text"
placeholder="Pegar token para unirse..."
value={joinToken}
onChange={(e) => setJoinToken(e.target.value)}
/>
<button className="buddy-btn" onClick={() => onJoin(joinToken)}>
Unirse
</button>
</div>
</div>
<div className="buddy-feed">
{messages.map((msg) => (
<div key={msg.id} className={`buddy-msg ${msg.role}`}>
<span className="buddy-msg-role">
{msg.role === 'user' ? roleLabel : msg.role === 'assistant' ? 'Tutor' : 'Sistema'}
</span>
<span className="buddy-msg-content">{msg.content}</span>
</div>
))}
{messages.length === 0 && (
<div className="buddy-empty">No hay mensajes aún. ¡Empezá a estudiar con tu compañero!</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React, { useState, useEffect } from 'react';
import { Sun, Moon } from 'lucide-react';
export default function ThemeToggle() {
const [theme, setTheme] = useState('dark');
useEffect(() => {
const stored = localStorage.getItem('studyos-theme');
if (stored === 'light' || stored === 'dark') {
setTheme(stored);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
setTheme('light');
} else {
setTheme('dark');
}
}, []);
useEffect(() => {
document.documentElement.dataset.theme = theme;
try {
localStorage.setItem('studyos-theme', theme);
} catch (e) {
// ignore
}
}, [theme]);
const toggle = () => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
return (
<button
onClick={toggle}
title={theme === 'dark' ? 'Cambiar a claro' : 'Cambiar a oscuro'}
className="icon-btn"
style={{ padding: 6 }}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
);
}

View File

@@ -0,0 +1,75 @@
import React, { useState, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Check, X } from 'lucide-react';
const ToastContext = React.createContext(null);
let toastIdCounter = 0;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const removeToast = useCallback((id) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback((msg, type, opts = {}) => {
const id = ++toastIdCounter;
const duration = opts.duration ?? 4000;
setToasts((prev) => [...prev, { id, msg, type, duration }]);
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
return id;
}, [removeToast]);
const success = useCallback((msg, opts) => addToast(msg, 'success', opts), [addToast]);
const error = useCallback((msg, opts) => addToast(msg, 'error', opts), [addToast]);
const value = React.useMemo(() => ({ success, error }), [success, error]);
// Ensure toast root exists
const toastRootRef = useRef(null);
if (!toastRootRef.current && typeof document !== 'undefined') {
let root = document.getElementById('toast-root');
if (!root) {
root = document.createElement('div');
root.id = 'toast-root';
document.body.appendChild(root);
}
toastRootRef.current = root;
}
return (
<ToastContext.Provider value={value}>
{children}
{toastRootRef.current &&
createPortal(
<div className="toast-portal">
{toasts.map((toast) => (
<div
key={toast.id}
className={`toast-card toast-${toast.type}`}
style={{ animationDelay: `${toasts.indexOf(toast) * 50}ms` }}
>
<span className="toast-icon">
{toast.type === 'success' ? <Check size={16} /> : <X size={16} />}
</span>
<span className="toast-msg">{toast.msg}</span>
<button className="toast-close" onClick={() => removeToast(toast.id)} title="Dismiss">
<X size={14} />
</button>
</div>
))}
</div>,
toastRootRef.current
)}
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = React.useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
return ctx;
}

View File

@@ -0,0 +1,9 @@
export function TypingDots() {
return (
<span className="typing-dots">
<span className="typing-dot" />
<span className="typing-dot" />
<span className="typing-dot" />
</span>
);
}

View File

@@ -0,0 +1,125 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Mic, MicOff } from 'lucide-react';
export default function VoiceInput({ onTranscript, disabled }) {
const [isListening, setIsListening] = useState(false);
const [error, setError] = useState(null);
const [supported, setSupported] = useState(false);
const recognitionRef = useRef(null);
useEffect(() => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
setSupported(true);
}
}, []);
const startListening = useCallback(() => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return;
setError(null);
const recognition = new SR();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'es-ES';
recognition.onresult = (event) => {
const results = event.results;
if (!results || results.length === 0) return;
const last = results[results.length - 1];
if (last.isFinal) {
const transcript = last[0].transcript;
if (onTranscript) onTranscript(transcript);
}
};
recognition.onerror = (event) => {
if (event.error === 'not-allowed') {
setError('Micrófono bloqueado');
} else if (event.error === 'no-speech') {
// ignore
} else {
setError('Error de reconocimiento');
}
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
try {
recognition.start();
recognitionRef.current = recognition;
setIsListening(true);
} catch (err) {
setError('No se pudo iniciar');
}
}, [onTranscript]);
const stopListening = useCallback(() => {
if (recognitionRef.current) {
try {
recognitionRef.current.stop();
} catch {
// ignore
}
recognitionRef.current = null;
}
setIsListening(false);
}, []);
const toggle = useCallback(() => {
if (isListening) {
stopListening();
} else {
startListening();
}
}, [isListening, startListening, stopListening]);
useEffect(() => {
return () => {
if (recognitionRef.current) {
try { recognitionRef.current.stop(); } catch {}
}
};
}, []);
if (!supported) return null;
return (
<div style={{ position: 'relative' }}>
<button
className="icon-btn"
onClick={toggle}
disabled={disabled}
title={isListening ? 'Detener dictado' : 'Dictar mensaje'}
style={{
color: isListening ? 'var(--accent-coral)' : undefined,
animation: isListening ? 'pulse 1.5s ease-in-out infinite' : undefined,
}}
>
{isListening ? <MicOff size={18} /> : <Mic size={18} />}
</button>
{error && (
<div
style={{
position: 'absolute',
bottom: 'calc(100% + 6px)',
left: '50%',
transform: 'translateX(-50%)',
background: 'var(--accent-coral)',
color: '#fff',
fontSize: 11,
padding: '4px 8px',
borderRadius: 'var(--radius-sm)',
whiteSpace: 'nowrap',
zIndex: 10,
}}
>
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import React, { useState, useCallback } from 'react';
const ReactionsContext = React.createContext(null);
export function ReactionsProvider({ children }) {
const [reactions, setReactions] = useState({});
const get = useCallback((msgId) => {
return reactions[msgId] ?? null;
}, [reactions]);
const toggle = useCallback((msgId, emoji) => {
setReactions((prev) => {
const current = prev[msgId];
if (current === emoji) {
const next = { ...prev };
delete next[msgId];
return next;
}
return { ...prev, [msgId]: emoji };
});
}, []);
const value = React.useMemo(() => ({ get, toggle }), [get, toggle]);
return (
<ReactionsContext.Provider value={value}>
{children}
</ReactionsContext.Provider>
);
}
export function useReactions() {
const ctx = React.useContext(ReactionsContext);
if (!ctx) throw new Error('useReactions must be used within a ReactionsProvider');
return ctx;
}

View File

@@ -0,0 +1,63 @@
{
"domains": [
{
"name": "Matemáticas",
"color": "#818cf8",
"topics": [
{ "id": "algebra", "name": "Álgebra" },
{ "id": "calculus", "name": "Cálculo" },
{ "id": "linear-algebra", "name": "Álgebra Lineal" },
{ "id": "statistics", "name": "Estadística" },
{ "id": "geometry", "name": "Geometría" }
]
},
{
"name": "Ciencias",
"color": "#34d399",
"topics": [
{ "id": "physics", "name": "Física" },
{ "id": "chemistry", "name": "Química" },
{ "id": "biology", "name": "Biología" },
{ "id": "astronomy", "name": "Astronomía" }
]
},
{
"name": "Informática",
"color": "#f472b6",
"topics": [
{ "id": "programming", "name": "Programación" },
{ "id": "algorithms", "name": "Algoritmos" },
{ "id": "databases", "name": "Bases de Datos" },
{ "id": "networks", "name": "Redes" },
{ "id": "ai", "name": "Inteligencia Artificial" },
{ "id": "os", "name": "Sistemas Operativos" }
]
},
{
"name": "Humanidades",
"color": "#fbbf24",
"topics": [
{ "id": "history", "name": "Historia" },
{ "id": "philosophy", "name": "Filosofía" },
{ "id": "literature", "name": "Literatura" }
]
}
],
"edges": [
{ "from": "algebra", "to": "calculus" },
{ "from": "calculus", "to": "linear-algebra" },
{ "from": "calculus", "to": "statistics" },
{ "from": "algebra", "to": "geometry" },
{ "from": "physics", "to": "calculus" },
{ "from": "chemistry", "to": "physics" },
{ "from": "biology", "to": "chemistry" },
{ "from": "astronomy", "to": "physics" },
{ "from": "programming", "to": "algorithms" },
{ "from": "algorithms", "to": "ai" },
{ "from": "databases", "to": "networks" },
{ "from": "os", "to": "networks" },
{ "from": "programming", "to": "os" },
{ "from": "history", "to": "philosophy" },
{ "from": "philosophy", "to": "literature" }
]
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import useChat from '../useChat';
import * as api from '../../lib/api';
vi.mock('../../lib/api');
const mockToastError = vi.fn();
vi.mock('../../components/Toast', () => ({
ToastProvider: ({ children }) => children,
useToast: () => ({ error: mockToastError }),
}));
describe('useChat', () => {
beforeEach(() => {
vi.clearAllMocks();
api.getMessages.mockResolvedValue({ messages: [] });
api.postChatStream.mockResolvedValue({
body: {
getReader() {
return {
async read() {
return { done: true, value: undefined };
},
releaseLock() {},
};
},
},
});
api.streamSSE = async function* mockSSE() {
yield { type: 'done' };
};
});
it('blocks sendMessage when isStreaming and shows toast', async () => {
const { result } = renderHook(() => useChat({ conversationId: '1' }));
await act(async () => {
await result.current.setActiveId('1');
});
// Trigger first message to set isStreaming
act(() => {
result.current.sendMessage('hello');
});
await waitFor(() => expect(result.current.isStreaming).toBe(true));
// Try sending again while streaming
await act(async () => {
await result.current.sendMessage('second');
});
// postChatStream called only once
expect(api.postChatStream).toHaveBeenCalledTimes(1);
// Toast shown
expect(mockToastError).toHaveBeenCalledWith('Message already in progress');
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import usePdfs from '../usePdfs';
import * as api from '../../lib/api';
vi.mock('../../lib/api');
describe('usePdfs', () => {
beforeEach(() => {
vi.clearAllMocks();
api.getPdfs.mockResolvedValue([
{ id: 1, filename: 'a.pdf', sort_order: 0 },
]);
});
it('refresh loads PDFs', async () => {
const { result } = renderHook(() => usePdfs());
await act(async () => {
await result.current.refresh();
});
expect(result.current.pdfs).toHaveLength(1);
expect(api.getPdfs).toHaveBeenCalledTimes(1);
});
it('uploadPdf refreshes list', async () => {
const { result } = renderHook(() => usePdfs());
const file = new File(['content'], 'test.pdf');
api.uploadPdf.mockResolvedValue({});
await act(async () => {
await result.current.uploadPdf(file);
});
expect(api.uploadPdf).toHaveBeenCalledWith(file);
expect(api.getPdfs).toHaveBeenCalled();
});
it('reorderPdf refreshes list', async () => {
const { result } = renderHook(() => usePdfs());
api.reorderPdf.mockResolvedValue({});
await act(async () => {
await result.current.reorderPdf(1, 2);
});
expect(api.reorderPdf).toHaveBeenCalledWith(1, 2);
});
it('deletePdf refreshes list', async () => {
const { result } = renderHook(() => usePdfs());
api.deletePdf.mockResolvedValue({});
await act(async () => {
await result.current.deletePdf(1);
});
expect(api.deletePdf).toHaveBeenCalledWith(1);
});
});

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import useProgress from '../useProgress';
import * as api from '../../lib/api';
vi.mock('../../lib/api');
describe('useProgress', () => {
let wsInstances = [];
beforeEach(() => {
vi.clearAllMocks();
api.getProgress.mockResolvedValue([]);
global.WebSocket = vi.fn(function MockWebSocket(url) {
this.url = url;
this.readyState = 0;
this.onopen = null;
this.onmessage = null;
this.onclose = null;
this.onerror = null;
this.close = vi.fn(() => {
this.readyState = 3;
if (this.onclose) this.onclose();
});
this.send = vi.fn();
wsInstances.push(this);
});
});
afterEach(() => {
wsInstances = [];
vi.restoreAllMocks();
});
it('updates state on progress_update message', async () => {
api.getProgress.mockResolvedValue([
{ topic: 'math', exercises_done: 2, exercises_correct: 1 },
]);
const { result } = renderHook(() => useProgress());
await waitFor(() => expect(result.current.progress.length).toBe(1));
const ws = wsInstances[0];
act(() => {
ws.onmessage({
data: JSON.stringify({
type: 'progress_update',
data: { topic: 'math', exercises_done: 3, exercises_correct: 2 },
}),
});
});
await waitFor(() =>
expect(result.current.progress[0].percentage).toBe(67)
);
});
it('closes socket on unmount', async () => {
api.getProgress.mockResolvedValue([]);
const { unmount } = renderHook(() => useProgress());
const ws = wsInstances[0];
unmount();
expect(ws.close).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { getMessages, postChatStream, streamSSE, updateProgress } from '../lib/api';
import { useToast } from '../components/Toast';
function extractExerciseJson(text) {
const fenceRegex = /```json\s*([\s\S]*?)\s*```/;
@@ -29,12 +30,15 @@ function extractExerciseJson(text) {
return null;
}
export default function useChat({ conversationId, onProgressUpdate }) {
export default function useChat({ conversationId, onProgressUpdate, onStudySession, onAutoFork, onDifficultyChanged }) {
const [messages, setMessages] = useState([]);
const [isStreaming, setIsStreaming] = useState(false);
const [activeId, setActiveIdState] = useState(conversationId);
const [autoForkPrompt, setAutoForkPrompt] = useState(null);
const [difficultyChanged, setDifficultyChanged] = useState(null);
const abortRef = useRef(null);
const activeIdRef = useRef(activeId);
const toast = useToast();
// Sync activeIdRef with current activeId
useEffect(() => {
@@ -72,7 +76,10 @@ export default function useChat({ conversationId, onProgressUpdate }) {
const sendMessage = useCallback(
async (text, pdfIds = [], attachments = []) => {
if (isStreaming) return;
if (isStreaming) {
toast.error('Message already in progress');
return;
}
if (!activeId || !text.trim()) return;
const controller = new AbortController();
@@ -87,6 +94,8 @@ export default function useChat({ conversationId, onProgressUpdate }) {
setMessages((prev) => [...prev, userMsg]);
setIsStreaming(true);
setAutoForkPrompt(null);
setDifficultyChanged(null);
const assistantMsg = {
id: `temp-assist-${Date.now()}`,
@@ -96,6 +105,7 @@ export default function useChat({ conversationId, onProgressUpdate }) {
};
setMessages((prev) => [...prev, assistantMsg]);
let streamSuccess = false;
try {
const response = await postChatStream({
conversation_id: activeId,
@@ -107,21 +117,40 @@ export default function useChat({ conversationId, onProgressUpdate }) {
let fullText = '';
for await (const event of streamSSE(response)) {
if (controller.signal.aborted) break;
if (event.type === 'token') {
if (event.token !== undefined) {
fullText += event.token;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsg.id ? { ...m, content: fullText } : m
)
);
} else if (event.type === 'done') {
} else if (event.done) {
fullText = event.full_text || fullText;
} else if (event.auto_fork_suggest) {
setAutoForkPrompt(event.auto_fork_suggest);
if (onAutoFork) {
onAutoFork({
topic: event.auto_fork_suggest.topic,
parentId: event.auto_fork_suggest.parent_id,
wrongStreak: event.auto_fork_suggest.wrong_streak,
});
}
} else if (event.difficulty_changed) {
setDifficultyChanged(event.difficulty_changed);
if (onDifficultyChanged) {
onDifficultyChanged({
level: event.difficulty_changed.level,
globalWrongStreak: event.difficulty_changed.global_wrong_streak,
});
}
} else if (event.error) {
console.error('[useChat] stream error:', event.error);
break;
}
}
streamSuccess = true;
// Exercise JSON parsing after streaming
const extracted = extractExerciseJson(fullText);
if (extracted && extracted.exercise) {
@@ -147,6 +176,16 @@ export default function useChat({ conversationId, onProgressUpdate }) {
m.id === assistantMsg.id ? { ...m, content: fullText } : m
)
);
// Record study session after successful reply
if (onStudySession) {
const today = new Date().toISOString().split('T')[0];
try {
await onStudySession(today, 1);
} catch (err) {
console.error('[useChat] study session error:', err.message);
}
}
} catch (err) {
if (err.name === 'AbortError') return;
console.error('[useChat] send error:', err.message);
@@ -163,8 +202,8 @@ export default function useChat({ conversationId, onProgressUpdate }) {
}
setIsStreaming(false);
if (controller.signal.aborted) return;
// Refresh messages from server to get persisted IDs only for current conversation
if (activeIdRef.current === activeId) {
// Only refresh messages from server on success, to avoid overwriting error state
if (streamSuccess && activeIdRef.current === activeId) {
try {
const data = await getMessages(activeId);
setMessages(data.messages || []);
@@ -174,7 +213,7 @@ export default function useChat({ conversationId, onProgressUpdate }) {
}
}
},
[activeId, onProgressUpdate, isStreaming]
[activeId, onProgressUpdate, onAutoFork, onDifficultyChanged, onStudySession, isStreaming, toast]
);
return {
@@ -183,5 +222,9 @@ export default function useChat({ conversationId, onProgressUpdate }) {
activeId,
setActiveId,
sendMessage,
autoForkPrompt,
setAutoForkPrompt,
difficultyChanged,
setDifficultyChanged,
};
}

119
client/src/hooks/useExam.js Normal file
View File

@@ -0,0 +1,119 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { submitExam } from '../lib/api';
export default function useExam(exam) {
const [currentQuestion, setCurrentQuestion] = useState(0);
const [answers, setAnswers] = useState([]);
const [remainingSeconds, setRemainingSeconds] = useState(0);
const [status, setStatus] = useState('idle'); // idle, running, submitted, expired
const [result, setResult] = useState(null);
const timerRef = useRef(null);
const answersRef = useRef(answers);
const examRef = useRef(exam);
useEffect(() => { answersRef.current = answers; }, [answers]);
useEffect(() => { examRef.current = exam; }, [exam]);
// Initialize when exam data arrives
useEffect(() => {
if (!exam || !exam.questions) {
setStatus('idle');
setRemainingSeconds(0);
return;
}
setAnswers(new Array(exam.questions.length).fill(null));
setCurrentQuestion(0);
setResult(null);
const startedAt = new Date(exam.started_at).getTime();
const durationMs = (exam.duration_seconds || 0) * 1000;
const now = Date.now();
const elapsed = now - startedAt;
const remaining = Math.max(0, Math.ceil((durationMs - elapsed) / 1000));
if (remaining <= 0) {
setStatus('expired');
setRemainingSeconds(0);
} else {
setStatus('running');
setRemainingSeconds(remaining);
}
}, [exam]);
const handleAutoSubmit = useCallback(async () => {
const currentExam = examRef.current;
const currentAnswers = answersRef.current;
if (!currentExam?.id) return;
try {
const res = await submitExam(currentExam.id, currentAnswers);
setResult(res);
setStatus('submitted');
} catch (err) {
console.error('[useExam] auto-submit error:', err.message);
setStatus('expired');
}
}, []);
// Countdown timer
useEffect(() => {
if (status !== 'running') return;
timerRef.current = setInterval(() => {
setRemainingSeconds((prev) => {
if (prev <= 1) {
clearInterval(timerRef.current);
// Auto-submit at 0
handleAutoSubmit();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timerRef.current);
}, [status, handleAutoSubmit]);
const handleSubmit = useCallback(async () => {
if (!exam || !exam.id || status !== 'running') return;
clearInterval(timerRef.current);
try {
const res = await submitExam(exam.id, answers);
setResult(res);
setStatus('submitted');
} catch (err) {
console.error('[useExam] submit error:', err.message);
setStatus('expired');
}
}, [exam, answers, status]);
const setAnswer = useCallback((questionIndex, answer) => {
setAnswers((prev) => {
const next = [...prev];
next[questionIndex] = answer;
return next;
});
}, []);
const goNext = useCallback(() => {
setCurrentQuestion((q) => Math.min(q + 1, (exam?.questions?.length || 1) - 1));
}, [exam]);
const goPrev = useCallback(() => {
setCurrentQuestion((q) => Math.max(q - 1, 0));
}, []);
const percentTimeRemaining = exam && exam.duration_seconds
? Math.min(100, Math.max(0, (remainingSeconds / exam.duration_seconds) * 100))
: 100;
return {
currentQuestion,
answers,
remainingSeconds,
status,
result,
setAnswer,
goNext,
goPrev,
handleSubmit,
percentTimeRemaining,
};
}

View File

@@ -0,0 +1,79 @@
import { useState, useCallback } from 'react';
import {
getExams,
createExam as apiCreateExam,
updateExam as apiUpdateExam,
deleteExam as apiDeleteExam,
} from '../lib/api';
export default function useExams() {
const [exams, setExams] = useState([]);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
try {
const rows = await getExams();
setExams(rows);
setError(null);
} catch (err) {
console.error('[useExams] refresh error:', err.message);
setError(err.message);
}
}, []);
const create = useCallback(async (body) => {
try {
const row = await apiCreateExam(body);
setExams((prev) => [row, ...prev]);
setError(null);
return row;
} catch (err) {
console.error('[useExams] create error:', err.message);
setError(err.message);
throw err;
}
}, []);
const update = useCallback(async (id, body) => {
try {
const row = await apiUpdateExam(id, body);
setExams((prev) => prev.map((e) => (e.id === id ? row : e)));
setError(null);
return row;
} catch (err) {
console.error('[useExams] update error:', err.message);
setError(err.message);
throw err;
}
}, []);
const remove = useCallback(async (id) => {
try {
await apiDeleteExam(id);
setExams((prev) => prev.filter((e) => e.id !== id));
setError(null);
} catch (err) {
console.error('[useExams] delete error:', err.message);
setError(err.message);
throw err;
}
}, []);
const filterByTopic = useCallback(
(topic) => {
if (!topic) return exams;
return exams.filter((e) => e.topics && e.topics.includes(topic));
},
[exams]
);
return {
exams,
error,
refresh,
create,
update,
remove,
filterByTopic,
};
}

View File

@@ -0,0 +1,162 @@
import { useState, useCallback, useEffect } from 'react';
import {
generateFlashcards as apiGenerateFlashcards,
getFlashcards,
updateFlashcard as apiUpdateFlashcard,
deleteFlashcard as apiDeleteFlashcard,
getFlashcardReviews,
reviewFlashcard as apiReviewFlashcard,
streamSSE,
} from '../lib/api';
export default function useFlashcards() {
const [cards, setCards] = useState([]);
const [queue, setQueue] = useState([]);
const [loading, setLoading] = useState(false);
const [streamEvents, setStreamEvents] = useState([]);
const [dueCount, setDueCount] = useState(0);
const [dueCards, setDueCards] = useState([]);
const refresh = useCallback(async (params = {}) => {
try {
const rows = await getFlashcards(params);
setCards(rows);
} catch (err) {
console.error('[useFlashcards] refresh error:', err.message);
}
}, []);
const refreshDue = useCallback(async () => {
try {
const data = await getFlashcardReviews();
setDueCount(data?.count || 0);
setDueCards(data?.cards || []);
} catch (err) {
console.error('[useFlashcards] refreshDue error:', err.message);
setDueCount(0);
setDueCards([]);
}
}, []);
// Load due count on mount
useEffect(() => {
refreshDue();
}, [refreshDue]);
const generate = useCallback(async (body) => {
setLoading(true);
setStreamEvents([]);
try {
const response = await apiGenerateFlashcards(body);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
try {
const event = JSON.parse(data);
setStreamEvents((prev) => [...prev, event]);
if (event.type === 'card') {
setCards((prev) => [event.card, ...prev]);
setQueue((prev) => [...prev, event.card]);
}
} catch {
// ignore malformed SSE lines
}
}
}
}
} catch (err) {
console.error('[useFlashcards] generate error:', err.message);
} finally {
setLoading(false);
}
}, []);
const fetchNext = useCallback(async () => {
try {
const rows = await getFlashcards({ seen: 0 });
setQueue(rows);
} catch (err) {
console.error('[useFlashcards] fetchNext error:', err.message);
}
}, []);
const reveal = useCallback((id) => {
setQueue((prev) =>
prev.map((c) => (c.id === id ? { ...c, _revealed: true } : c))
);
}, []);
const markSeen = useCallback(async (id) => {
try {
const row = await apiUpdateFlashcard(id, { seen: true });
setQueue((prev) => prev.filter((c) => c.id !== id));
setCards((prev) => prev.map((c) => (c.id === id ? row : c)));
} catch (err) {
console.error('[useFlashcards] markSeen error:', err.message);
}
}, []);
const update = useCallback(async (id, body) => {
try {
const row = await apiUpdateFlashcard(id, body);
setCards((prev) => prev.map((c) => (c.id === id ? row : c)));
setQueue((prev) => prev.map((c) => (c.id === id ? row : c)));
return row;
} catch (err) {
console.error('[useFlashcards] update error:', err.message);
throw err;
}
}, []);
const remove = useCallback(async (id) => {
try {
await apiDeleteFlashcard(id);
setCards((prev) => prev.filter((c) => c.id !== id));
setQueue((prev) => prev.filter((c) => c.id !== id));
} catch (err) {
console.error('[useFlashcards] delete error:', err.message);
throw err;
}
}, []);
const review = useCallback(async (id, quality) => {
try {
// Server authoritative write
const result = await apiReviewFlashcard(id, quality);
// Refresh due list
await refreshDue();
return result;
} catch (err) {
console.error('[useFlashcards] review error:', err.message);
throw err;
}
}, [dueCards, refreshDue]);
return {
cards,
queue,
loading,
generate,
fetchNext,
reveal,
markSeen,
update,
remove,
streamEvents,
dueCount,
dueCards,
review,
refreshDue,
};
}

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
export function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mql.addEventListener('change', handler);
setMatches(mql.matches);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { getProgress, updateProgress, resetProgressTopic } from '../lib/api';
function calcPercentage(row) {
@@ -9,6 +9,9 @@ function calcPercentage(row) {
export default function useProgress() {
const [progress, setProgress] = useState([]);
const [error, setError] = useState(null);
const wsRef = useRef(null);
const reconnectDelayRef = useRef(1000);
const reconnectTimerRef = useRef(null);
const refresh = useCallback(async () => {
try {
@@ -28,6 +31,62 @@ export default function useProgress() {
useEffect(() => {
refresh();
const connect = () => {
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
reconnectDelayRef.current = 1000;
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'progress_update' && msg.data) {
setProgress((prev) => {
const row = msg.data;
const enriched = { ...row, percentage: calcPercentage(row) };
const idx = prev.findIndex((p) => p.topic === row.topic);
if (idx >= 0) {
const next = [...prev];
next[idx] = enriched;
return next;
}
return [...prev, enriched];
});
}
} catch (e) {
// ignore malformed messages
}
};
ws.onclose = () => {
wsRef.current = null;
const delay = Math.min(reconnectDelayRef.current, 30000);
reconnectDelayRef.current *= 2;
reconnectTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
ws.close();
};
};
connect();
return () => {
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
};
}, [refresh]);
const updateExercise = useCallback(

View File

@@ -0,0 +1,44 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { searchApi } from '../lib/api';
export default function useSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const debounceRef = useRef(null);
const clear = useCallback(() => {
setQuery('');
setResults([]);
setLoading(false);
}, []);
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
const trimmed = query.trim();
if (!trimmed) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
try {
const data = await searchApi(trimmed);
setResults(data || []);
} catch (err) {
console.error('[useSearch] error:', err.message);
setResults([]);
} finally {
setLoading(false);
}
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
return { query, setQuery, results, loading, clear };
}

View File

@@ -0,0 +1,95 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { getSharedConversation, getMessages } from '../lib/api';
export default function useStudyBuddy(token) {
const [conversation, setConversation] = useState(null);
const [messages, setMessages] = useState([]);
const [roleLabel, setRoleLabel] = useState('compañero');
const [counterpartLabel, setCounterpartLabel] = useState('compañero');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const wsRef = useRef(null);
const convIdRef = useRef(null);
// Load shared conversation from token
const loadShared = useCallback(async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const data = await getSharedConversation(token);
setConversation(data.conversation);
setMessages(data.messages || []);
setRoleLabel(data.role_label || 'compañero');
setCounterpartLabel(data.counterpart_label || 'compañero');
convIdRef.current = data.conversation?.id ?? null;
} catch (err) {
console.error('[useStudyBuddy] load error:', err.message);
setError(err.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
loadShared();
}, [loadShared]);
// WebSocket connection for real-time updates
useEffect(() => {
if (!conversation?.id) return;
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('[useStudyBuddy] WS connected');
};
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'buddy_msg' && payload.conv_id === conversation.id) {
// Refresh messages
refreshMessages();
}
} catch {
// ignore non-JSON messages
}
};
ws.onerror = (err) => {
console.error('[useStudyBuddy] WS error:', err);
};
ws.onclose = () => {
console.log('[useStudyBuddy] WS disconnected');
};
return () => {
ws.close();
};
}, [conversation?.id]);
const refreshMessages = useCallback(async () => {
const convId = convIdRef.current;
if (!convId) return;
try {
const data = await getMessages(convId);
setMessages(data.messages || []);
} catch (err) {
console.error('[useStudyBuddy] refresh error:', err.message);
}
}, []);
return {
conversation,
messages,
roleLabel,
counterpartLabel,
loading,
error,
refreshMessages,
};
}

View File

@@ -0,0 +1,31 @@
import { useState, useCallback } from 'react';
import { recordStudySession as apiRecordSession, getHeatmap } from '../lib/api';
export default function useStudySessions() {
const [heatmapData, setHeatmapData] = useState([]);
const recordSession = useCallback(async (date, minutes, topic) => {
try {
await apiRecordSession(date, minutes, topic);
} catch (err) {
console.error('[useStudySessions] record error:', err.message);
}
}, []);
const heatmap = useCallback(async (days = 365) => {
try {
const rows = await getHeatmap(days);
setHeatmapData(rows);
return rows;
} catch (err) {
console.error('[useStudySessions] heatmap error:', err.message);
return [];
}
}, []);
return {
recordSession,
heatmap,
heatmapData,
};
}

View File

@@ -37,6 +37,32 @@
html, body, #root { height: 100%; }
html { scroll-behavior: smooth; }
[data-theme="light"] {
--bg-base: #f8f9fb;
--bg-surface: #ffffff;
--bg-elevated: #f0f2f7;
--bg-glass: rgba(0,0,0,0.03);
--border: #e2e5ec;
--border-glow: #d0d4e0;
--text-primary: #1a1c23;
--text-secondary: #5b5f6e;
--text-tertiary: #8b8fa0;
--accent-green: #10b981;
--accent-amber: #f59e0b;
--accent-coral: #ef4444;
--accent-info: #6366f1;
--accent-purple: #8b5cf6;
--accent-pink: #ec4899;
--accent-cyan: #06b6d4;
--accent-orange: #f97316;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 14px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.10);
--shadow-glow-green: 0 0 20px rgba(16,185,129,0.12);
--shadow-glow-purple: 0 0 24px rgba(139,92,246,0.10);
--shadow-glow-blue: 0 0 20px rgba(99,102,241,0.10);
}
body {
background: linear-gradient(135deg, #0f1117 0%, #13152a 30%, #0f172a 60%, #0f1117 100%);
color: var(--text-primary);
@@ -66,3 +92,84 @@ code, pre { font-family: var(--font-mono); }
@keyframes scaleIn { from{opacity:0;transform:scale(0.96)} to{opacity:1;transform:scale(1)} }
@keyframes glowPulse { 0%,100%{box-shadow:0 0 8px rgba(129,140,248,0.2)} 50%{box-shadow:0 0 20px rgba(129,140,248,0.4)} }
@keyframes shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
@keyframes skeleton-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-dot {
0%, 100% { opacity: 0.4; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.1); }
}
/* Toast portal */
#toast-root { position: fixed; top: 16px; right: 16px; z-index: 200; pointer-events: none; }
.toast-portal { display: flex; flex-direction: column; gap: 8px; pointer-events: auto; }
.toast-card {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-radius: var(--radius-md);
background: var(--bg-elevated); border: 1px solid var(--border);
color: var(--text-primary); font-size: 13px; min-width: 220px; max-width: 360px;
box-shadow: var(--shadow-lg);
animation: fade-in 0.3s ease;
}
.toast-card.toast-success { background: linear-gradient(135deg, rgba(52,211,153,0.12), rgba(52,211,153,0.06)); border-color: rgba(52,211,153,0.35); }
.toast-card.toast-error { background: linear-gradient(135deg, rgba(248,113,113,0.12), rgba(248,113,113,0.06)); border-color: rgba(248,113,113,0.35); }
.toast-icon { display: flex; align-items: center; flex-shrink: 0; }
.toast-msg { flex: 1; line-height: 1.4; }
.toast-close { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: var(--radius-sm); transition: color var(--transition-fast); }
.toast-close:hover { color: var(--text-primary); }
/* Typing dots */
.typing-dots { display: inline-flex; align-items: center; gap: 4px; padding: 8px 12px; }
.typing-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--accent-info);
animation: typing-bounce 1.2s ease-in-out infinite;
}
.typing-dot:nth-child(1) { animation-delay: 0ms; }
.typing-dot:nth-child(2) { animation-delay: 150ms; }
.typing-dot:nth-child(3) { animation-delay: 300ms; }
/* Skeleton */
.skeleton-card {
position: relative; overflow: hidden;
background: var(--bg-elevated); border-radius: var(--radius-sm);
border: 1px solid var(--border); margin-bottom: 6px;
}
.skeleton-shimmer {
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent);
background-size: 200% 100%;
animation: skeleton-shimmer 1.6s infinite;
pointer-events: none;
}
.skeleton-conv { padding: 10px 14px; }
.skeleton-pdf { padding: 8px 10px; display: flex; align-items: center; gap: 8px; }
.skeleton-grip { width: 14px; height: 14px; border-radius: 2px; background: var(--border); flex-shrink: 0; }
.skeleton-lines { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.skeleton-line { height: 10px; border-radius: var(--radius-pill); background: var(--border); }
.skeleton-line--text { width: 75%; height: 14px; }
.skeleton-line--short { width: 60%; }
.skeleton-line--shorter { width: 40%; }
/* Mobile sidebar */
@media (max-width: 767px) {
.app-sidebar {
position: fixed !important; top: 0; left: 0; height: 100vh;
transform: translateX(-100%);
z-index: 30; transition: transform var(--transition-slow);
}
}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { backupDatabase, restoreDatabase } from '../api';
describe('api', () => {
beforeEach(() => {
vi.restoreAllMocks();
global.fetch = vi.fn();
});
describe('backupDatabase', () => {
it('fetches backup endpoint and triggers download', async () => {
const blob = new Blob(['db-content']);
global.fetch.mockResolvedValue({
ok: true,
blob: () => Promise.resolve(blob),
});
const createObjectURL = vi.fn(() => 'blob:test');
const revokeObjectURL = vi.fn();
URL.createObjectURL = createObjectURL;
URL.revokeObjectURL = revokeObjectURL;
const clickSpy = vi.fn();
const anchorSpy = vi.spyOn(document, 'createElement').mockReturnValue({
href: '',
download: '',
click: clickSpy,
});
await backupDatabase();
expect(global.fetch).toHaveBeenCalledWith('/api/config/backup');
expect(createObjectURL).toHaveBeenCalledWith(blob);
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test');
anchorSpy.mockRestore();
});
it('throws on non-ok response', async () => {
global.fetch.mockResolvedValue({ ok: false, status: 404 });
await expect(backupDatabase()).rejects.toThrow('Backup failed: 404');
});
});
describe('restoreDatabase', () => {
it('posts file as FormData', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ ok: true }),
});
const file = new File(['db'], 'studyos.db');
await restoreDatabase(file);
expect(global.fetch).toHaveBeenCalledWith(
'/api/config/restore',
expect.objectContaining({
method: 'POST',
body: expect.any(FormData),
})
);
});
it('throws on non-ok response', async () => {
global.fetch.mockResolvedValue({ ok: false, status: 400, text: () => Promise.resolve('bad') });
const file = new File(['db'], 'studyos.db');
await expect(restoreDatabase(file)).rejects.toThrow('Restore failed: 400 bad');
});
});
});

View File

@@ -83,6 +83,19 @@ export async function createConversation(body) {
}
}
export async function updateConversation(id, body) {
try {
const res = await fetch(`${API_BASE}/conversations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to update conversation: ${err.message}`);
}
}
export async function getMessages(convId) {
try {
const res = await fetch(`${API_BASE}/conversations/${convId}/messages`);
@@ -250,6 +263,128 @@ export async function testModel(id) {
}
}
// Exams
export async function getExams(topic) {
try {
const url = topic ? `${API_BASE}/exams?topic=${encodeURIComponent(topic)}` : `${API_BASE}/exams`;
const res = await fetch(url);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch exams: ${err.message}`);
}
}
export async function createExam(body) {
try {
const res = await fetch(`${API_BASE}/exams`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to create exam: ${err.message}`);
}
}
export async function updateExam(id, body) {
try {
const res = await fetch(`${API_BASE}/exams/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to update exam: ${err.message}`);
}
}
export async function deleteExam(id) {
try {
const res = await fetch(`${API_BASE}/exams/${id}`, { method: 'DELETE' });
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to delete exam: ${err.message}`);
}
}
// Flashcards
export async function generateFlashcards(body) {
try {
const res = await fetch(`${API_BASE}/flashcards/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to generate flashcards: ${res.status} ${text}`);
}
return res;
} catch (err) {
throw new Error(`Failed to generate flashcards: ${err.message}`);
}
}
export async function getFlashcards(params = {}) {
try {
const qs = new URLSearchParams();
if (params.seen !== undefined) qs.append('seen', params.seen);
if (params.topic) qs.append('topic', params.topic);
const url = qs.toString() ? `${API_BASE}/flashcards?${qs.toString()}` : `${API_BASE}/flashcards`;
const res = await fetch(url);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch flashcards: ${err.message}`);
}
}
export async function updateFlashcard(id, body) {
try {
const res = await fetch(`${API_BASE}/flashcards/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to update flashcard: ${err.message}`);
}
}
export async function deleteFlashcard(id) {
try {
const res = await fetch(`${API_BASE}/flashcards/${id}`, { method: 'DELETE' });
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to delete flashcard: ${err.message}`);
}
}
// Study sessions
export async function recordStudySession(date, minutes, topic) {
try {
const res = await fetch(`${API_BASE}/progress/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, minutes, topic }),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to record study session: ${err.message}`);
}
}
export async function getHeatmap(days = 365) {
try {
const res = await fetch(`${API_BASE}/progress/heatmap?days=${days}`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch heatmap: ${err.message}`);
}
}
export async function* streamSSE(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
@@ -297,6 +432,40 @@ export async function* streamSSE(response) {
}
}
export async function backupDatabase() {
try {
const res = await fetch(`${API_BASE}/config/backup`);
if (!res.ok) throw new Error(`Backup failed: ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'studyos.db';
a.click();
URL.revokeObjectURL(url);
} catch (err) {
throw new Error(`Failed to backup database: ${err.message}`);
}
}
export async function restoreDatabase(file) {
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API_BASE}/config/restore`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Restore failed: ${res.status} ${text}`);
}
return await res.json();
} catch (err) {
throw new Error(`Failed to restore database: ${err.message}`);
}
}
export async function postChatStream(body, signal) {
const res = await fetch(`${API_BASE}/chat/stream`, {
method: 'POST',
@@ -310,3 +479,150 @@ export async function postChatStream(body, signal) {
}
return res;
}
// Search
export async function searchApi(q) {
try {
const res = await fetch(`${API_BASE}/search?q=${encodeURIComponent(q)}`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to search: ${err.message}`);
}
}
// Flashcard reviews
export async function getFlashcardReviews() {
try {
const res = await fetch(`${API_BASE}/flashcards/reviews/due`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch flashcard reviews: ${err.message}`);
}
}
export async function reviewFlashcard(id, quality) {
try {
const res = await fetch(`${API_BASE}/flashcards/${id}/review`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quality }),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to review flashcard: ${err.message}`);
}
}
// Exam PDF
export async function downloadExamPdf() {
try {
const res = await fetch(`${API_BASE}/progress/exam/pdf`);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`PDF download failed: ${res.status} ${text}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `examen-simulado-${new Date().toISOString().slice(0, 10)}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
throw new Error(`Failed to download exam PDF: ${err.message}`);
}
}
// Exam generation + submission
export async function generateExam(body) {
try {
const res = await fetch(`${API_BASE}/exams/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to generate exam: ${err.message}`);
}
}
export async function getExam(id) {
try {
const res = await fetch(`${API_BASE}/exams/${id}`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch exam: ${err.message}`);
}
}
export async function startExam(id) {
try {
const res = await fetch(`${API_BASE}/exams/${id}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to start exam: ${err.message}`);
}
}
export async function submitExam(id, answers) {
try {
const res = await fetch(`${API_BASE}/exams/${id}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to submit exam: ${err.message}`);
}
}
// Study buddy share
export async function shareConversation(id, roleLabel) {
try {
const res = await fetch(`${API_BASE}/conversations/${id}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role_label: roleLabel }),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to share conversation: ${err.message}`);
}
}
export async function joinConversation(shareToken, userName) {
try {
const res = await fetch(`${API_BASE}/conversations/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ share_token: shareToken, user_name: userName }),
});
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to join conversation: ${err.message}`);
}
}
export async function getSharedConversation(token) {
try {
const res = await fetch(`${API_BASE}/conversations/shared/${token}`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to load shared conversation: ${err.message}`);
}
}
// PDF embeddings (for debugging)
export async function getPdfEmbeddings(pdfId) {
try {
const res = await fetch(`${API_BASE}/pdfs/${pdfId}/embeddings`);
return await handleResponse(res);
} catch (err) {
throw new Error(`Failed to fetch PDF embeddings: ${err.message}`);
}
}

21
client/src/lib/sm2.js Normal file
View File

@@ -0,0 +1,21 @@
export function sm2(prev, quality) {
let { ease_factor: e, interval_days: i, repetitions: r } = prev;
if (quality < 3) {
r = 0;
i = 1;
} else {
if (r === 0) i = 1;
else if (r === 1) i = 6;
else i = Math.round(i * e);
r += 1;
}
e = Math.max(1.3, e + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
const next = new Date();
next.setDate(next.getDate() + i);
return {
ease_factor: +e.toFixed(2),
interval_days: i,
repetitions: r,
next_review: next.toISOString().slice(0, 10),
};
}

View File

@@ -0,0 +1,20 @@
import { useState, useEffect } from 'react';
export default function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}

View File

@@ -10,3 +10,19 @@ root.render(
<App />
</BrowserRouter>
);
// Register PWA service worker in production
if ('serviceWorker' in navigator && import.meta.env.PROD) {
import('virtual:pwa-register').then(({ registerSW }) => {
registerSW({ immediate: true });
}).catch(() => {
// silent fail if virtual:pwa-register is unavailable
});
}
window.addEventListener('offline', () => {
document.body.setAttribute('data-offline', 'true');
});
window.addEventListener('online', () => {
document.body.removeAttribute('data-offline');
});

View File

@@ -29,6 +29,9 @@ import {
deletePdf,
getProgress,
resetProgressTopic,
backupDatabase,
restoreDatabase,
downloadExamPdf,
} from '../lib/api';
function ToggleSwitch({ checked, onChange }) {
@@ -84,6 +87,8 @@ export default function Settings() {
// Progress tab
const [progress, setProgress] = useState([]);
const [restoreFile, setRestoreFile] = useState(null);
const [restoreLoading, setRestoreLoading] = useState(false);
const refreshModels = useCallback(async () => {
try {
@@ -268,6 +273,27 @@ export default function Settings() {
}
};
const handleDownloadBackup = async () => {
try {
await backupDatabase();
} catch (err) {
alert(err.message);
}
};
const handleRestoreBackup = async () => {
if (!restoreFile) return;
if (!window.confirm('¿Restaurar base de datos? Se reemplazará el estado actual.')) return;
setRestoreLoading(true);
try {
await restoreDatabase(restoreFile);
window.location.reload();
} catch (err) {
alert(err.message);
setRestoreLoading(false);
}
};
const progressChartData = progress.map((p) => ({
name: p.topic,
pct: p.percentage,
@@ -311,6 +337,18 @@ export default function Settings() {
>
Progreso
</button>
<button
className={`settings-tab ${activeTab === 'backup' ? 'active' : ''}`}
onClick={() => setActiveTab('backup')}
>
Backup
</button>
<button
className={`settings-tab ${activeTab === 'datos' ? 'active' : ''}`}
onClick={() => setActiveTab('datos')}
>
Datos
</button>
</div>
{/* Tab 1: Modelos */}
@@ -635,6 +673,70 @@ export default function Settings() {
</div>
)}
{/* Tab 4: Backup */}
{activeTab === 'backup' && (
<div>
<div style={{ marginBottom: 24 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
Base de datos
</h2>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-secondary" onClick={handleDownloadBackup}>
Download Backup
</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="file"
accept=".db"
onChange={(e) => setRestoreFile(e.target.files?.[0] || null)}
/>
<button
className="btn btn-primary"
onClick={handleRestoreBackup}
disabled={!restoreFile || restoreLoading}
>
{restoreLoading ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />
) : null}
Restore Backup
</button>
</div>
</div>
</div>
</div>
)}
{/* Tab 5: Datos */}
{activeTab === 'datos' && (
<div>
<div style={{ marginBottom: 24 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
Exportar examen simulado
</h2>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 12 }}>
Genera un PDF con los 20 temas más recientes de tu progreso.
</p>
<button className="btn btn-primary" onClick={downloadExamPdf}>
Exportar examen simulado
</button>
</div>
</div>
)}
{/* Model Modal */}
{modelModalOpen && (
<Modal

View File

@@ -1,8 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
devOptions: { enabled: false },
manifest: {
name: 'StudyOS',
short_name: 'StudyOS',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{ src: '/pwa-icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/pwa-icons/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
}),
],
server: {
port: 5173,
proxy: {

9
client/vitest.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.js'],
},
});

1
client/vitest.setup.js Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';