import { useState, useRef, useEffect } from 'react' import { AnimatePresence } from 'framer-motion' import { Search, Plus, Star, FileText, Folder, FolderOpen, ChevronRight, ChevronDown, Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen, ArrowUpDown, Check, X, Bot, Upload, } from 'lucide-react' import { ModelSettingsModal } from '../ai/ModelSettingsModal' import { importMarkdown, importTxt, importDocx, importPDF } from '../../lib/import' import { DndContext, DragOverlay, useDraggable, useDroppable, useSensor, useSensors, MouseSensor, TouchSensor, type DragEndEvent, type DragStartEvent, } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' import { useAppStore } from '../../stores/appStore' import { formatDate } from '../../lib/utils' import type { Folder as FolderType, Note } from '../../db' export function Sidebar() { const { notes, folders, activeNoteId, activeFolderId, searchQuery, theme, createNote, createFolder, deleteNote, deleteFolder, updateNote, updateFolder, setActiveNote, setActiveFolder, setSearch, toggleTheme, toggleStar, filteredNotes, activeTag, setActiveTag, sortBy, sortOrder, setSortBy, setSortOrder, } = useAppStore() const [expandedFolders, setExpandedFolders] = useState>(new Set()) const [editingFolderId, setEditingFolderId] = useState(null) const [newFolderName, setNewFolderName] = useState('') const [creatingFolder, setCreatingFolder] = useState(false) const [newFolderInput, setNewFolderInput] = useState('') const [editingNoteId, setEditingNoteId] = useState(null) const [noteEditValue, setNoteEditValue] = useState('') const [sortMenuOpen, setSortMenuOpen] = useState(false) const [modelModalOpen, setModelModalOpen] = useState(false) const [localSearch, setLocalSearch] = useState(searchQuery) const [contextMenu, setContextMenu] = useState<{ type: 'note' | 'folder' id: string x: number y: number } | null>(null) const [draggingNoteId, setDraggingNoteId] = useState(null) const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }), ) const editInputRef = useRef(null) const noteEditRef = useRef(null) const newFolderRef = useRef(null) const displayed = filteredNotes() useEffect(() => { const t = setTimeout(() => setSearch(localSearch), 300) return () => clearTimeout(t) }, [localSearch, setSearch]) useEffect(() => { if (editingFolderId && editInputRef.current) editInputRef.current.focus() }, [editingFolderId]) useEffect(() => { if (editingNoteId && noteEditRef.current) noteEditRef.current.focus() }, [editingNoteId]) useEffect(() => { if (creatingFolder && newFolderRef.current) newFolderRef.current.focus() }, [creatingFolder]) useEffect(() => { const close = () => { setContextMenu(null); setSortMenuOpen(false) } window.addEventListener('click', close) return () => window.removeEventListener('click', close) }, []) const toggleFolder = (id: string) => { setExpandedFolders(s => { const next = new Set(s) next.has(id) ? next.delete(id) : next.add(id) return next }) } const handleRenameFolder = async (id: string) => { if (!newFolderName.trim()) return await updateFolder(id, { name: newFolderName.trim() }) setEditingFolderId(null) } const handleRenameNote = async (id: string) => { await updateNote(id, { title: noteEditValue.trim() || '无标题笔记' }) setEditingNoteId(null) } const handleCreateFolder = async () => { if (!newFolderInput.trim()) { setCreatingFolder(false); return } await createFolder(newFolderInput.trim()) setNewFolderInput('') setCreatingFolder(false) } const handleNewNote = async () => { const folderId = typeof activeFolderId === 'string' && activeFolderId !== 'all' && activeFolderId !== 'starred' ? activeFolderId : null await createNote(folderId) } const importFileRef = useRef(null) const handleImportFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return e.target.value = '' const folderId = typeof activeFolderId === 'string' && activeFolderId !== 'all' && activeFolderId !== 'starred' ? activeFolderId : null const ext = file.name.split('.').pop()?.toLowerCase() ?? '' let result: { title: string; content: string } if (ext === 'md' || ext === 'markdown') { result = importMarkdown(await file.text()) } else if (ext === 'txt') { result = importTxt(await file.text()) } else if (ext === 'docx') { result = await importDocx(await file.arrayBuffer()) } else if (ext === 'pdf') { result = await importPDF(await file.arrayBuffer()) } else { return } await createNote(folderId, result) } const handleDragStart = (event: DragStartEvent) => { setDraggingNoteId(event.active.id as string) } const handleDragEnd = (event: DragEndEvent) => { setDraggingNoteId(null) if (event.over) { updateNote(event.active.id as string, { folderId: event.over.id as string }) } } const draggingNote = draggingNoteId ? notes.find(n => n.id === draggingNoteId) : null const renderFolder = (parentId: string | null, depth: number): React.ReactNode => folders .filter(f => f.parentId === parentId) .map(folder => (
toggleFolder(folder.id)} onClick={() => setActiveFolder(folder.id)} onContextMenu={(e) => { e.preventDefault() setContextMenu({ type: 'folder', id: folder.id, x: e.clientX, y: e.clientY }) }} onEditChange={setNewFolderName} onEditBlur={() => handleRenameFolder(folder.id)} onEditKeyDown={(e) => { if (e.key === 'Enter') handleRenameFolder(folder.id) if (e.key === 'Escape') setEditingFolderId(null) }} /> {expandedFolders.has(folder.id) && renderFolder(folder.id, depth + 1)}
)) const SORT_OPTIONS: { by: typeof sortBy; order: typeof sortOrder; label: string }[] = [ { by: 'updatedAt', order: 'desc', label: '最近修改' }, { by: 'createdAt', order: 'desc', label: '最近创建' }, { by: 'title', order: 'asc', label: '标题 A–Z' }, { by: 'title', order: 'desc', label: '标题 Z–A' }, ] const currentSort = SORT_OPTIONS.find(o => o.by === sortBy && o.order === sortOrder) return ( {modelModalOpen && setModelModalOpen(false)} />} {/* Drag overlay */} {draggingNote && (
{draggingNote.title || '无标题笔记'}
)}
) } function NavItem({ icon, label, count, active, onClick }: { icon: React.ReactNode; label: string; count?: number; active: boolean; onClick: () => void }) { return ( ) } function DroppableFolderItem({ folder, depth, expanded, active, editing, editValue, editRef, onToggle, onClick, onContextMenu, onEditChange, onEditBlur, onEditKeyDown }: { folder: FolderType; depth: number; expanded: boolean; active: boolean; editing: boolean editValue: string; editRef: React.RefObject onToggle: () => void; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void onEditChange: (v: string) => void; onEditBlur: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void }) { const { isOver, setNodeRef } = useDroppable({ id: folder.id }) return (
{expanded ? : } {editing ? ( onEditChange(e.target.value)} onBlur={onEditBlur} onKeyDown={onEditKeyDown} className="bg-transparent outline-none flex-1 text-sm" style={{ color: 'var(--text)' }} onClick={e => e.stopPropagation()} /> ) : ( {folder.name} )}
) } function CtxItem({ icon, label, danger, onClick }: { icon: React.ReactNode; label: string; danger?: boolean; onClick: () => void }) { return ( ) } function DraggableNoteItem({ note, active, editing, editValue, editRef, activeTag, onEditChange, onEditBlur, onEditKeyDown, onClick, onDelete, onToggleStar, onTagClick, onContextMenu }: { note: Note active: boolean editing: boolean editValue: string editRef: React.RefObject activeTag: string | null onEditChange: (v: string) => void onEditBlur: () => void onEditKeyDown: (e: React.KeyboardEvent) => void onClick: () => void onDelete: () => void onToggleStar: () => void onTagClick: (tag: string) => void onContextMenu: (e: React.MouseEvent) => void }) { const [hovered, setHovered] = useState(false) const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: note.id }) const style = { transform: CSS.Translate.toString(transform), opacity: isDragging ? 0.4 : 1, cursor: isDragging ? 'grabbing' : 'grab', } return (
setHovered(true)} onMouseLeave={() => setHovered(false)} {...attributes} {...listeners} >
{note.starred && } {editing ? ( onEditChange(e.target.value)} onBlur={onEditBlur} onKeyDown={onEditKeyDown} className="bg-transparent outline-none text-sm font-medium flex-1 min-w-0" style={{ color: 'var(--text)' }} onClick={e => e.stopPropagation()} /> ) : ( {note.title || '无标题笔记'} )}
{hovered ? (
) : ( {formatDate(note.updatedAt)} )}
{note.tags.length > 0 && (
{note.tags.slice(0, 3).map(tag => ( { e.stopPropagation(); onTagClick(tag) }} > {tag} ))}
)}
) }