← Назад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>
);
}