Initial commit: StudyOS platform
This commit is contained in:
238
client/src/components/ChatInput.jsx
Normal file
238
client/src/components/ChatInput.jsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Paperclip, X, ChevronDown, Check } from 'lucide-react';
|
||||
|
||||
export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
|
||||
const [text, setText] = useState('');
|
||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||
const [selectedPdfIds, setSelectedPdfIds] = useState([]);
|
||||
const [pdfDropdownOpen, setPdfDropdownOpen] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// Auto-resize textarea (max ~5 lines ~120px)
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
const newHeight = Math.min(el.scrollHeight, 120);
|
||||
el.style.height = `${newHeight}px`;
|
||||
}, [text]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||
setPdfDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isStreaming) return;
|
||||
const attachmentTexts = attachedFiles.map((f) => f.text);
|
||||
onSend(trimmed, selectedPdfIds, attachmentTexts);
|
||||
setText('');
|
||||
setAttachedFiles([]);
|
||||
setSelectedPdfIds([]);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const loaded = await Promise.all(
|
||||
files.map(
|
||||
(file) =>
|
||||
new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) =>
|
||||
resolve({ name: file.name, text: ev.target.result });
|
||||
reader.onerror = () =>
|
||||
resolve({ name: file.name, text: '' });
|
||||
reader.readAsText(file);
|
||||
})
|
||||
)
|
||||
);
|
||||
setAttachedFiles((prev) => [...prev, ...loaded]);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeAttachment = (index) => {
|
||||
setAttachedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const togglePdf = (id) => {
|
||||
setSelectedPdfIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const canSend = text.trim().length > 0 && !isStreaming;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '10px 16px',
|
||||
background: 'var(--bg-surface)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Attached files chips */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||
{attachedFiles.map((file, idx) => (
|
||||
<span key={idx} className="attachment-chip">
|
||||
{file.name}
|
||||
<button onClick={() => removeAttachment(idx)}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
|
||||
{/* Attachment button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Adjuntar archivo (.txt, .md, .ics)"
|
||||
className="icon-btn"
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,.ics"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* PDF selector */}
|
||||
<div ref={dropdownRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setPdfDropdownOpen((s) => !s)}
|
||||
title="Seleccionar PDFs"
|
||||
className="icon-btn"
|
||||
style={{
|
||||
color: selectedPdfIds.length ? 'var(--accent-green)' : undefined,
|
||||
}}
|
||||
>
|
||||
<FileIcon size={18} />
|
||||
{selectedPdfIds.length > 0 && (
|
||||
<span style={{ marginLeft: 4, fontSize: 10, fontWeight: 700 }}>
|
||||
{selectedPdfIds.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={14} style={{ marginLeft: 2 }} />
|
||||
</button>
|
||||
|
||||
{pdfDropdownOpen && (
|
||||
<div className="pdf-dropdown-menu">
|
||||
{availablePdfs.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
No hay PDFs
|
||||
</div>
|
||||
)}
|
||||
{availablePdfs.map((pdf) => {
|
||||
const checked = selectedPdfIds.includes(pdf.id);
|
||||
return (
|
||||
<label
|
||||
key={pdf.id}
|
||||
className="pdf-dropdown-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => togglePdf(pdf.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={pdf.original_name}
|
||||
>
|
||||
{pdf.original_name}
|
||||
</span>
|
||||
{checked && (
|
||||
<Check size={12} style={{ marginLeft: 'auto', color: 'var(--accent-green)' }} />
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe un mensaje..."
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
className="chat-input-textarea"
|
||||
/>
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
className="chat-input-send-btn"
|
||||
style={{
|
||||
background: canSend ? 'var(--accent-green)' : 'var(--bg-elevated)',
|
||||
color: canSend ? '#fff' : 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon(props) {
|
||||
// Simple file icon similar to lucide FileText
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={props.size || 18}
|
||||
height={props.size || 18}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
213
client/src/components/ForkPanel.jsx
Normal file
213
client/src/components/ForkPanel.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
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';
|
||||
|
||||
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 }) {
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const chatHook = useChat({
|
||||
conversationId: forkId ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (forkId) {
|
||||
chatHook.setActiveId(forkId);
|
||||
setInputText('');
|
||||
}
|
||||
}, [forkId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [chatHook.messages, chatHook.isStreaming]);
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmed = inputText.trim();
|
||||
if (!trimmed || chatHook.isStreaming) return;
|
||||
chatHook.sendMessage(trimmed);
|
||||
setInputText('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!forkId || merging) return;
|
||||
setMerging(true);
|
||||
try {
|
||||
await onMerge();
|
||||
} finally {
|
||||
setMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isOpen = forkId !== null;
|
||||
|
||||
return (
|
||||
<aside className={`fork-panel ${isOpen ? 'open' : ''}`}>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: 280,
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
{/* 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 && <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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
163
client/src/components/MainChat.jsx
Normal file
163
client/src/components/MainChat.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { MessageSquare, GitBranch, X, Maximize2 } from 'lucide-react';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import ModelSelector from './ModelSelector';
|
||||
|
||||
function CoordinatePlane({ data }) {
|
||||
try {
|
||||
const config = JSON.parse(data);
|
||||
const { width = 260, height = 200, points = [], segments = [], grid = [-6, 6, -6, 6] } = config;
|
||||
const pad = 30;
|
||||
const [xMin, xMax, yMin, yMax] = grid;
|
||||
const sx = (x) => pad + ((x - xMin) / (xMax - xMin)) * (width - 2 * pad);
|
||||
const sy = (y) => height - pad - ((y - yMin) / (yMax - yMin)) * (height - 2 * pad);
|
||||
const gridLines = [];
|
||||
for (let i = Math.ceil(xMin); i <= xMax; i++) gridLines.push(<line key={`gx${i}`} x1={sx(i)} y1={pad} x2={sx(i)} y2={height - pad} stroke="var(--border)" strokeWidth="0.5" />);
|
||||
for (let i = Math.ceil(yMin); i <= yMax; i++) gridLines.push(<line key={`gy${i}`} x1={pad} y1={sy(i)} x2={width - pad} y2={sy(i)} stroke="var(--border)" strokeWidth="0.5" />);
|
||||
return (
|
||||
<svg width={width} height={height} style={{ display: 'block', margin: '0 auto', background: 'var(--bg-base)', borderRadius: 'var(--radius-md)' }}>
|
||||
{gridLines}
|
||||
<line x1={sx(xMin)} y1={sy(0)} x2={sx(xMax)} y2={sy(0)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
|
||||
<line x1={sx(0)} y1={sy(yMin)} x2={sx(0)} y2={sy(yMax)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
|
||||
{segments.map((seg, i) => <line key={`s${i}`} x1={sx(seg[0])} y1={sy(seg[1])} x2={sx(seg[2])} y2={sy(seg[3])} stroke="var(--accent-info)" strokeWidth="2" />)}
|
||||
{points.map((pt, i) => (
|
||||
<g key={`p${i}`}>
|
||||
<circle cx={sx(pt[0])} cy={sy(pt[1])} r="4" fill="var(--accent-info)" />
|
||||
<text x={sx(pt[0]) + 6} y={sy(pt[1]) - 6} fill="var(--text-primary)" fontSize="10" fontFamily="var(--font-mono)">({pt[0]},{pt[1]})</text>
|
||||
</g>
|
||||
))}
|
||||
{sx(0) >= pad && sx(0) <= width - pad && sy(0) >= pad && sy(0) <= height - pad && (
|
||||
<text x={sx(0) + 5} y={sy(0) - 5} fill="var(--text-tertiary)" fontSize="10">O</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
} 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">
|
||||
<MessageSquare size={28} className="chat-empty-state-icon" />
|
||||
<div>
|
||||
{hasConversation
|
||||
? 'Escribe un mensaje para comenzar'
|
||||
: 'Seleccioná una conversación del sidebar o creá una nueva'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphPanel({ messages, onClose }) {
|
||||
const graphs = useMemo(() => {
|
||||
const result = [];
|
||||
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 { JSON.parse(match[1]); result.push({ data: match[1], key: msg.id + '-' + result.length }); } catch {}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [messages]);
|
||||
|
||||
if (graphs.length === 0) return null;
|
||||
return (
|
||||
<div style={{ width: 360, flexShrink: 0, borderLeft: '1px solid var(--border)', background: 'var(--bg-surface)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'rgba(129,140,248,0.06)' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--accent-info)', display: 'flex', alignItems: 'center', gap: 6 }}>📐 Gráficos ({graphs.length})</span>
|
||||
<button onClick={onClose} className="icon-btn"><X size={14} /></button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{graphs.map((g) => (
|
||||
<div key={g.key} style={{ marginBottom: 12, background: 'var(--bg-base)', borderRadius: 'var(--radius-md)', padding: 10, border: '1px solid var(--border)' }}>
|
||||
<CoordinatePlane data={g.data} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
}) {
|
||||
const messagesEndRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const [graphPanelOpen, setGraphPanelOpen] = useState(false);
|
||||
|
||||
const handleForkFromMessage = (topic) => { if (onFork) onFork(topic); };
|
||||
|
||||
const hasGraphs = useMemo(() => {
|
||||
return messages.some(m => m.role === 'assistant' && (m.content || '').includes('```graph'));
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
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)' }}>
|
||||
{/* 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>
|
||||
</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} />
|
||||
{hasGraphs && (
|
||||
<button onClick={() => setGraphPanelOpen(!graphPanelOpen)}
|
||||
style={{ background: graphPanelOpen ? 'var(--accent-info)' : 'transparent', border: graphPanelOpen ? 'none' : '1px solid var(--border)', color: graphPanelOpen ? '#fff' : 'var(--text-secondary)', borderRadius: 'var(--radius-sm)', padding: '4px 10px', fontSize: 12, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontWeight: 500 }}>
|
||||
<Maximize2 size={14} /> Gráficos
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message list */}
|
||||
<div ref={containerRef} style={{ flex: 1, overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{messages.length === 0 && <EmptyState hasConversation={!!conversation} />}
|
||||
{messages.map((msg) => (
|
||||
<MessageBubbleComponent key={msg.id ?? msg.created_at} message={msg} onFork={msg.role === 'assistant' ? handleForkFromMessage : undefined} />
|
||||
))}
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
{graphPanelOpen && hasGraphs && <GraphPanel messages={messages} onClose={() => setGraphPanelOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
client/src/components/MessageBubble.jsx
Normal file
146
client/src/components/MessageBubble.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { GitMerge, GitBranch } from 'lucide-react';
|
||||
import katex from 'katex';
|
||||
|
||||
function LatexRenderer({ text }) {
|
||||
const parts = useMemo(() => {
|
||||
const result = [];
|
||||
const regex = /(\$\$[\s\S]*?\$\$|\$[^$\n]+?\$)/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) result.push({ type: 'text', content: text.slice(lastIndex, match.index) });
|
||||
const latex = match[0];
|
||||
const isBlock = latex.startsWith('$$');
|
||||
const formula = latex.slice(isBlock ? 2 : 1, latex.length - (isBlock ? 2 : 1));
|
||||
try {
|
||||
const html = katex.renderToString(formula, { throwOnError: false, displayMode: isBlock });
|
||||
result.push({ type: 'latex', html, isBlock });
|
||||
} catch { result.push({ type: 'text', content: latex }); }
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) result.push({ type: 'text', content: text.slice(lastIndex) });
|
||||
return result;
|
||||
}, [text]);
|
||||
|
||||
return parts.map((part, i) =>
|
||||
part.type === 'latex' ? (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: part.html }}
|
||||
style={part.isBlock ? { display: 'block', margin: '12px 0', textAlign: 'center' } : {}} />
|
||||
) : <React.Fragment key={i}>{part.content}</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function CoordinatePlane({ data }) {
|
||||
try {
|
||||
const config = JSON.parse(data);
|
||||
const { width = 260, height = 200, points = [], segments = [], grid = [-6, 6, -6, 6] } = config;
|
||||
const pad = 30;
|
||||
const [xMin, xMax, yMin, yMax] = grid;
|
||||
const sx = (x) => pad + ((x - xMin) / (xMax - xMin)) * (width - 2 * pad);
|
||||
const sy = (y) => height - pad - ((y - yMin) / (yMax - yMin)) * (height - 2 * pad);
|
||||
const gridLines = [];
|
||||
for (let i = Math.ceil(xMin); i <= xMax; i++) gridLines.push(<line key={`gx${i}`} x1={sx(i)} y1={pad} x2={sx(i)} y2={height - pad} stroke="var(--border)" strokeWidth="0.5" />);
|
||||
for (let i = Math.ceil(yMin); i <= yMax; i++) gridLines.push(<line key={`gy${i}`} x1={pad} y1={sy(i)} x2={width - pad} y2={sy(i)} stroke="var(--border)" strokeWidth="0.5" />);
|
||||
const origin = { x: sx(0), y: sy(0) };
|
||||
return (
|
||||
<div style={{ margin: '12px 0', display: 'flex', justifyContent: 'center' }}>
|
||||
<svg width={width} height={height} style={{ background: 'var(--bg-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
|
||||
{gridLines}
|
||||
<line x1={sx(xMin)} y1={sy(0)} x2={sx(xMax)} y2={sy(0)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
|
||||
<line x1={sx(0)} y1={sy(yMin)} x2={sx(0)} y2={sy(yMax)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
|
||||
<polygon points={`${sx(xMax)-5},${sy(0)-3} ${sx(xMax)},${sy(0)} ${sx(xMax)-5},${sy(0)+3}`} fill="var(--text-tertiary)" />
|
||||
<polygon points={`${sx(0)-3},${sy(yMin)+5} ${sx(0)},${sy(yMin)} ${sx(0)+3},${sy(yMin)+5}`} fill="var(--text-tertiary)" />
|
||||
{segments.map((seg, i) => <line key={`seg${i}`} x1={sx(seg[0])} y1={sy(seg[1])} x2={sx(seg[2])} y2={sy(seg[3])} stroke="var(--accent-green)" strokeWidth="2" />)}
|
||||
{points.map((pt, i) => (
|
||||
<g key={`pt${i}`}>
|
||||
<circle cx={sx(pt[0])} cy={sy(pt[1])} r="4" fill="var(--accent-green)" />
|
||||
<text x={sx(pt[0]) + 6} y={sy(pt[1]) - 6} fill="var(--text-primary)" fontSize="10" fontFamily="var(--font-mono)">
|
||||
({pt[0]},{pt[1]})
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{origin.x >= pad && origin.x <= width - pad && origin.y >= pad && origin.y <= height - pad && (
|
||||
<text x={origin.x + 5} y={origin.y - 5} fill="var(--text-tertiary)" fontSize="10">O</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
} catch { return <div style={{ color: 'var(--accent-coral)', fontSize: 12 }}>Error al renderizar gráfico</div>; }
|
||||
}
|
||||
|
||||
function MarkdownContent({ content }) {
|
||||
return (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({ node, className, children, ...props }) {
|
||||
const code = String(children).trim();
|
||||
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>;
|
||||
},
|
||||
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>;
|
||||
},
|
||||
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>; },
|
||||
h3({ children }) { return <h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 6, marginTop: 4 }}>{children}</h3>; },
|
||||
li({ children }) { return <li style={{ marginBottom: 4 }}>{children}</li>; },
|
||||
ul({ children }) { return <ul style={{ paddingLeft: 18, marginBottom: 8 }}>{children}</ul>; },
|
||||
ol({ children }) { return <ol style={{ paddingLeft: 18, marginBottom: 8 }}>{children}</ol>; },
|
||||
blockquote({ children }) { return <blockquote style={{ borderLeft: '3px solid var(--accent-green)', paddingLeft: 12, paddingRight: 8, paddingTop: 6, paddingBottom: 6, marginBottom: 8, background: 'rgba(74,222,128,0.05)', borderRadius: '0 var(--radius-sm) var(--radius-sm) 0' }}>{children}</blockquote>; },
|
||||
a({ children, href }) { return <a href={href} target="_blank" rel="noreferrer">{children}</a>; },
|
||||
}}
|
||||
>
|
||||
{content || ''}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message, onFork }) {
|
||||
const { role, content, created_at } = message;
|
||||
if (role === 'system') return null;
|
||||
if (!content) return null;
|
||||
|
||||
if (role === 'context_merge') {
|
||||
return (
|
||||
<div className="context-merge-badge">
|
||||
<GitMerge size={14} style={{ color: 'var(--accent-green)', flexShrink: 0 }} />
|
||||
<span style={{ lineHeight: 1.4 }}>{content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = role === 'user';
|
||||
return (
|
||||
<div className="message-bubble" style={{ display: 'flex', justifyContent: isUser ? 'flex-end' : 'flex-start', width: '100%' }}>
|
||||
<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 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const topic = (content || '').split('\n')[0].replace(/^#+\s*/, '').substring(0, 50) || 'Estudio';
|
||||
onFork(topic);
|
||||
}}
|
||||
title="Abrir fork sobre este tema"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'rgba(192,132,252,0.08)', border: '1px solid rgba(192,132,252,0.2)',
|
||||
color: 'var(--accent-purple)', borderRadius: 'var(--radius-sm)', padding: '3px 8px',
|
||||
fontSize: 10, cursor: 'pointer', marginTop: 6, fontWeight: 500,
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
<GitBranch size={11} /> Fork
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
client/src/components/ModelSelector.jsx
Normal file
129
client/src/components/ModelSelector.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
|
||||
export default function ModelSelector({ selectedModelId, onSelect, models }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const selected = models.find((m) => m.id === selectedModelId) || models[0] || null;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (model) => {
|
||||
onSelect(model.id);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="model-selector" ref={containerRef}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
transition: 'border-color var(--transition-fast), box-shadow var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--text-tertiary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border)';
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent-green)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px rgba(59, 109, 17, 0.2)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{selected && (
|
||||
<span
|
||||
className={`provider-badge ${selected.provider}`}
|
||||
style={{ fontSize: 9 }}
|
||||
>
|
||||
{selected.provider}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{selected?.name || 'Select model'}
|
||||
</span>
|
||||
<ChevronDown size={12} style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="model-selector-dropdown">
|
||||
{models.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`model-selector-item ${model.id === selectedModelId ? 'active' : ''}`}
|
||||
onClick={() => handleSelect(model)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className={`provider-badge ${model.provider}`}>
|
||||
{model.provider}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{model.name}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2 }}>
|
||||
{model.is_default_main && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 10, color: 'var(--text-tertiary)' }}>
|
||||
<span className="role-dot main" />
|
||||
main
|
||||
</span>
|
||||
)}
|
||||
{model.is_default_fork && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 10, color: 'var(--text-tertiary)' }}>
|
||||
<span className="role-dot fork" />
|
||||
fork
|
||||
</span>
|
||||
)}
|
||||
{model.is_default_exam && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 10, color: 'var(--text-tertiary)' }}>
|
||||
<span className="role-dot exam" />
|
||||
exam
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{model.id === selectedModelId && (
|
||||
<Check size={14} style={{ color: 'var(--accent-green)', flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
client/src/components/Sidebar.jsx
Normal file
177
client/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
DndContext, closestCenter, PointerSensor, useSensor, useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove, SortableContext, verticalListSortingStrategy, useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
Settings, GripVertical, Trash2, FileText, Plus, StickyNote, Search, ChevronDown, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
function DragOverlayItem({ pdf }) {
|
||||
return (
|
||||
<div className="dnd-drag-overlay">
|
||||
<GripVertical size={14} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pdf.original_name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortablePdfItem({ pdf, onDelete }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pdf.id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="sidebar-pdf-item">
|
||||
<button className="sidebar-drag-handle" {...attributes} {...listeners}><GripVertical size={14} /></button>
|
||||
<span className="sidebar-pdf-name" title={pdf.original_name}>{pdf.original_name}</span>
|
||||
<button className="sidebar-pdf-delete" onClick={() => onDelete(pdf.id)} title="Eliminar"><Trash2 size={12} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
collapsed, onToggle, pdfs, progress, conversations, activeConversation, notes,
|
||||
onSelectConversation, onNewConversation, onUploadPdf, onReorderPdf, onDeletePdf,
|
||||
onResetTopic, onNavigateSettings,
|
||||
}) {
|
||||
const fileInputRef = useRef(null);
|
||||
const [pdfItems, setPdfItems] = useState(() => pdfs);
|
||||
const [pdfSearch, setPdfSearch] = useState('');
|
||||
const [pdfsExpanded, setPdfsExpanded] = useState(true);
|
||||
const [progressExpanded, setProgressExpanded] = useState(true);
|
||||
const [chatsExpanded, setChatsExpanded] = useState(true);
|
||||
const [notesExpanded, setNotesExpanded] = useState(true);
|
||||
|
||||
React.useEffect(() => { setPdfItems(pdfs); }, [pdfs]);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = pdfItems.findIndex(p => p.id === active.id);
|
||||
const newIndex = pdfItems.findIndex(p => p.id === over.id);
|
||||
setPdfItems(arrayMove(pdfItems, oldIndex, newIndex));
|
||||
onReorderPdf(active.id, newIndex);
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => { const f = e.target.files?.[0]; if (f) onUploadPdf(f); e.target.value = ''; };
|
||||
const filteredPdfs = pdfSearch ? pdfItems.filter(p => p.original_name.toLowerCase().includes(pdfSearch.toLowerCase())) : pdfItems;
|
||||
|
||||
const SectionHeader = ({ icon, label, expanded, onToggle, extra }) => (
|
||||
<div className="sidebar-section-header" onClick={onToggle} style={{ cursor: 'pointer' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
||||
<span>{icon} {label}</span>
|
||||
</div>
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="app-sidebar" style={{ width: 48, alignItems: 'center', paddingTop: 12 }}>
|
||||
<button onClick={onToggle} title="Expandir" className="sidebar-collapsed-btn"><FileText size={20} /></button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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.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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user