← Назад
import { authFetch } from '../../lib/api'; import { useState, useCallback, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { marked } from 'marked'; import { Brain, Folder, FolderOpen, FileText, ChevronRight, ChevronDown, Loader2, AlertCircle, Edit3, Save, X, Plus, Check, } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose, DialogBody } from '../ui/dialog'; import { toast } from 'sonner'; interface NoteNode { name: string; type: 'file' | 'dir'; path: string; children?: NoteNode[]; } // Configure marked for safe rendering marked.setOptions({ breaks: true, gfm: true }); function renderMarkdown(content: string): string { return marked.parse(content) as string; } function NoteTreeNode({ node, selectedPath, onSelect, depth = 0, }: { node: NoteNode; selectedPath: string | null; onSelect: (path: string) => void; depth?: number; }) { const [expanded, setExpanded] = useState(depth < 1); const indent = depth * 14 + 8; if (node.type === 'dir') { return ( <div> <button onClick={() => setExpanded(v => !v)} className="flex items-center gap-1.5 w-full text-left py-1 rounded hover:bg-white/5 text-sm transition-colors" style={{ paddingLeft: `${indent}px`, paddingRight: '8px' }} > {expanded ? <ChevronDown className="w-3 h-3 text-gray-500 shrink-0" /> : <ChevronRight className="w-3 h-3 text-gray-500 shrink-0" />} {expanded ? <FolderOpen className="w-3.5 h-3.5 text-purple-400 shrink-0" /> : <Folder className="w-3.5 h-3.5 text-purple-500 shrink-0" />} <span className="text-gray-300 truncate">{node.name}</span> </button> {expanded && node.children && ( <div> {node.children.map(child => ( <NoteTreeNode key={child.path} node={child} selectedPath={selectedPath} onSelect={onSelect} depth={depth + 1} /> ))} </div> )} </div> ); } const isSelected = selectedPath === node.path; return ( <button onClick={() => onSelect(node.path)} title={node.path} className={`flex items-center gap-1.5 w-full text-left py-1 rounded text-sm transition-colors ${ isSelected ? 'bg-purple-500/20 text-purple-300' : 'hover:bg-white/5 text-gray-400 hover:text-gray-200' }`} style={{ paddingLeft: `${indent}px`, paddingRight: '8px' }} > <FileText className="w-3.5 h-3.5 shrink-0 opacity-60" /> <span className="truncate flex-1">{node.name.replace(/\.md$/, '')}</span> </button> ); } export function BrainModal({ open, onOpenChange, }: { open: boolean; onOpenChange: (open: boolean) => void; }) { const qc = useQueryClient(); const [selectedPath, setSelectedPath] = useState<string | null>(null); const [editMode, setEditMode] = useState(false); const [editContent, setEditContent] = useState(''); const [newNotePath, setNewNotePath] = useState(''); const [showNewNote, setShowNewNote] = useState(false); // Tree const { data: treeData, isLoading: treeLoading, error: treeError } = useQuery<NoteNode[], Error>({ queryKey: ['brain-tree'], queryFn: async () => { const res = await authFetch('/api/brain/tree'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); return json.data; }, enabled: open, staleTime: 10000, }); // Note content const { data: noteData, isLoading: noteLoading } = useQuery<{ path: string; content: string }, Error>({ queryKey: ['brain-note', selectedPath], queryFn: async () => { const res = await authFetch(`/api/brain/note?path=${encodeURIComponent(selectedPath!)}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); return json.data; }, enabled: open && !!selectedPath, staleTime: 10000, }); // When note loads, update edit content useEffect(() => { if (noteData) setEditContent(noteData.content); }, [noteData]); // Save mutation const saveMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => { const res = await authFetch('/api/brain/note', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }, onSuccess: (_, vars) => { toast.success('Заметка сохранена ✓'); setEditMode(false); qc.invalidateQueries({ queryKey: ['brain-note', vars.path] }); qc.invalidateQueries({ queryKey: ['brain-tree'] }); }, onError: (e: Error) => toast.error(`Ошибка: ${e.message}`), }); const handleSave = useCallback(() => { if (!selectedPath) return; saveMutation.mutate({ path: selectedPath, content: editContent }); }, [selectedPath, editContent, saveMutation]); const handleCreateNote = useCallback(() => { let p = newNotePath.trim(); if (!p) return; if (!p.endsWith('.md')) p += '.md'; saveMutation.mutate({ path: p, content: `# ${p.replace(/\.md$/, '').split('/').pop()}\n\n` }); setSelectedPath(p); setShowNewNote(false); setNewNotePath(''); setEditMode(true); }, [newNotePath, saveMutation]); return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-6xl h-[90vh] overflow-hidden flex flex-col p-6"> <DialogHeader className="shrink-0"> <DialogTitle className="flex items-center gap-2"> <Brain className="w-5 h-5 text-purple-400" /> Brain — Second Brain </DialogTitle> <DialogClose onClick={() => onOpenChange(false)} /> </DialogHeader> <DialogBody className="flex-1 min-h-0 overflow-hidden py-4 -mx-6 px-6 flex flex-col"> {treeLoading && ( <div className="flex items-center justify-center py-20"> <Loader2 className="w-8 h-8 animate-spin text-purple-400" /> </div> )} {treeError && ( <div className="flex items-center justify-center py-20"> <div className="text-center space-y-2"> <AlertCircle className="w-10 h-10 text-red-400 mx-auto" /> <div className="text-red-400 text-sm">{treeError.message}</div> </div> </div> )} {!treeLoading && !treeError && treeData && ( <div className="flex flex-col md:flex-row flex-1 min-h-0 gap-4"> {/* LEFT: note tree */} <div className="w-full md:w-56 md:shrink-0 flex flex-col bg-white/[0.03] rounded-xl border border-white/10 min-h-0 max-h-48 md:max-h-none"> {/* New note button */} <div className="px-2 py-2 border-b border-white/5 shrink-0"> {showNewNote ? ( <div className="flex gap-1"> <input autoFocus value={newNotePath} onChange={e => setNewNotePath(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreateNote(); if (e.key === 'Escape') setShowNewNote(false); }} placeholder="folder/note.md" className="flex-1 bg-black/30 border border-purple-500/40 rounded px-2 py-1 text-xs text-gray-200 placeholder-gray-600 outline-none focus:border-purple-400" /> <button onClick={handleCreateNote} className="p-1 rounded hover:bg-green-500/20 text-green-400"> <Check className="w-3.5 h-3.5" /> </button> <button onClick={() => setShowNewNote(false)} className="p-1 rounded hover:bg-white/10 text-gray-500"> <X className="w-3.5 h-3.5" /> </button> </div> ) : ( <button onClick={() => setShowNewNote(true)} className="flex items-center gap-1.5 w-full px-2 py-1 rounded text-xs text-purple-400 hover:bg-purple-500/10 transition-colors" > <Plus className="w-3.5 h-3.5" /> Новая заметка </button> )} </div> <div className="flex-1 overflow-y-auto p-1"> {treeData.map(node => ( <NoteTreeNode key={node.path} node={node} selectedPath={selectedPath} onSelect={(p) => { setSelectedPath(p); setEditMode(false); }} /> ))} </div> </div> {/* RIGHT: content */} <div className="flex-1 flex flex-col bg-black/20 rounded-xl border border-white/10 min-h-0 min-w-0"> {!selectedPath && ( <div className="flex-1 flex items-center justify-center text-gray-600"> <div className="text-center"> <Brain className="w-12 h-12 mx-auto mb-3 opacity-20 text-purple-400" /> <div className="text-sm">Выберите заметку</div> </div> </div> )} {selectedPath && noteLoading && ( <div className="flex-1 flex items-center justify-center"> <Loader2 className="w-6 h-6 animate-spin text-purple-400" /> </div> )} {selectedPath && noteData && !noteLoading && ( <> {/* Header */} <div className="flex items-center gap-2 px-4 py-2 border-b border-white/10 shrink-0"> <span className="text-xs text-gray-500 font-mono flex-1 truncate">{selectedPath}</span> {editMode ? ( <> <button onClick={handleSave} disabled={saveMutation.isPending} className="flex items-center gap-1 px-3 py-1 rounded text-xs bg-purple-500/30 hover:bg-purple-500/50 text-purple-300 transition-colors" > {saveMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />} Сохранить </button> <button onClick={() => { setEditMode(false); setEditContent(noteData.content); }} className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-white/5 hover:bg-white/10 text-gray-400 transition-colors" > <X className="w-3 h-3" /> Отмена </button> </> ) : ( <button onClick={() => setEditMode(true)} className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors" > <Edit3 className="w-3 h-3" /> Редактировать </button> )} </div> {/* Content */} {editMode ? ( <textarea value={editContent} onChange={e => setEditContent(e.target.value)} className="flex-1 bg-transparent p-4 text-sm text-gray-200 font-mono leading-relaxed resize-none outline-none" spellCheck={false} /> ) : ( <div className="flex-1 overflow-y-auto p-4 prose prose-invert prose-sm max-w-none prose-headings:text-purple-200 prose-headings:font-semibold prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-p:text-gray-300 prose-p:leading-relaxed prose-a:text-purple-400 prose-a:no-underline hover:prose-a:underline prose-code:text-purple-300 prose-code:bg-purple-950/40 prose-code:px-1 prose-code:rounded prose-pre:bg-black/40 prose-pre:border prose-pre:border-white/10 prose-blockquote:border-purple-500 prose-blockquote:text-gray-400 prose-strong:text-gray-100 prose-table:text-sm prose-th:text-gray-300 prose-td:text-gray-400 prose-li:text-gray-300 prose-ul:space-y-1 prose-ol:space-y-1 prose-hr:border-white/10" dangerouslySetInnerHTML={{ __html: renderMarkdown(noteData.content) }} /> )} </> )} </div> </div> )} </DialogBody> </DialogContent> </Dialog> ); }