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:
60
client/src/components/AutoForkPrompt.css
Normal file
60
client/src/components/AutoForkPrompt.css
Normal 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;
|
||||
}
|
||||
25
client/src/components/AutoForkPrompt.jsx
Normal file
25
client/src/components/AutoForkPrompt.jsx
Normal 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}>
|
||||
Sí, practicar
|
||||
</button>
|
||||
<button className="auto-fork-btn dismiss" onClick={onDismiss}>
|
||||
Ahora no
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
client/src/components/CalendarHeatmap.jsx
Normal file
152
client/src/components/CalendarHeatmap.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
144
client/src/components/ExamHistory.jsx
Normal file
144
client/src/components/ExamHistory.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
client/src/components/ExamPanel.css
Normal file
154
client/src/components/ExamPanel.css
Normal 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;
|
||||
}
|
||||
107
client/src/components/ExamPanel.jsx
Normal file
107
client/src/components/ExamPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
client/src/components/FlashcardReview.jsx
Normal file
151
client/src/components/FlashcardReview.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
95
client/src/components/LatexEditor.jsx
Normal file
95
client/src/components/LatexEditor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
149
client/src/components/MultiPdfCompare.jsx
Normal file
149
client/src/components/MultiPdfCompare.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
client/src/components/PomodoroTimer.jsx
Normal file
137
client/src/components/PomodoroTimer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
client/src/components/RoadmapVisual.jsx
Normal file
133
client/src/components/RoadmapVisual.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
client/src/components/ScrollFab.jsx
Normal file
39
client/src/components/ScrollFab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
client/src/components/SearchBar.jsx
Normal file
281
client/src/components/SearchBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
21
client/src/components/Skeleton.jsx
Normal file
21
client/src/components/Skeleton.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
client/src/components/StudyBuddyPanel.css
Normal file
152
client/src/components/StudyBuddyPanel.css
Normal 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;
|
||||
}
|
||||
78
client/src/components/StudyBuddyPanel.jsx
Normal file
78
client/src/components/StudyBuddyPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
client/src/components/ThemeToggle.jsx
Normal file
41
client/src/components/ThemeToggle.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
client/src/components/Toast.jsx
Normal file
75
client/src/components/Toast.jsx
Normal 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;
|
||||
}
|
||||
9
client/src/components/TypingDots.jsx
Normal file
9
client/src/components/TypingDots.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
client/src/components/VoiceInput.jsx
Normal file
125
client/src/components/VoiceInput.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user