import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react' import type { VaultEntry, FolderNode, SidebarSelection, ViewFile } from '../types' import { buildTypeEntryMap } from '../utils/typeColors' import { buildDynamicSections, sortSections } from '../utils/sidebarSections' import { TypeCustomizePopover } from './TypeCustomizePopover' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core' import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple, } from '@phosphor-icons/react' import { evaluateView } from '../utils/viewFilters' import { arrayMove } from '@dnd-kit/sortable' import { SlidersHorizontal } from 'lucide-react' import { type SectionGroup, isSelectionActive, NavItem, SectionContent, type SectionContentProps, VisibilityPopover, } from './SidebarParts' import { useDragRegion } from '../hooks/useDragRegion' import { FolderTree } from './FolderTree' import { NoteTitleIcon } from './NoteTitleIcon' interface SidebarProps { entries: VaultEntry[] selection: SidebarSelection onSelect: (selection: SidebarSelection) => void onSelectNote?: (entry: VaultEntry) => void onCreateType?: (type: string) => void onCreateNewType?: () => void onCustomizeType?: (typeName: string, icon: string, color: string) => void onUpdateTypeTemplate?: (typeName: string, template: string) => void onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void onRenameSection?: (typeName: string, label: string) => void onToggleTypeVisibility?: (typeName: string) => void onSelectFavorite?: (entry: VaultEntry) => void onReorderFavorites?: (orderedPaths: string[]) => void views?: ViewFile[] onCreateView?: () => void onEditView?: (filename: string) => void onDeleteView?: (filename: string) => void folders?: FolderNode[] onCreateFolder?: (name: string) => void inboxCount?: number onCollapse?: () => void } // --- Hooks --- function useOutsideClick(ref: React.RefObject, isOpen: boolean, onClose: () => void) { useEffect(() => { if (!isOpen) return const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [ref, isOpen, onClose]) } function useSidebarSections(entries: VaultEntry[]) { const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const allSectionGroups = useMemo(() => { const sections = buildDynamicSections(entries, typeEntryMap) return sortSections(sections, typeEntryMap) }, [entries, typeEntryMap]) const visibleSections = useMemo(() => allSectionGroups.filter((g) => typeEntryMap[g.type]?.visible !== false), [allSectionGroups, typeEntryMap]) const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections]) return { typeEntryMap, allSectionGroups, visibleSections, sectionIds } } const SIDEBAR_COLLAPSED_KEY = 'laputa:sidebar-collapsed' type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders' function loadCollapsedState(): Record { try { const raw = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) if (raw) return JSON.parse(raw) } catch { /* ignore */ } return { favorites: false, views: false, sections: false, folders: false } } function useSidebarCollapsed() { const [collapsed, setCollapsed] = useState>(loadCollapsedState) const toggle = useCallback((key: SidebarGroupKey) => { setCollapsed((prev) => { const next = { ...prev, [key]: !prev[key] } localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(next)) return next }) }, []) return { collapsed, toggle } } function useEntryCounts(entries: VaultEntry[]) { return useMemo(() => { let active = 0, archived = 0 for (const e of entries) { if (e.archived) archived++ else active++ } return { activeCount: active, archivedCount: archived } }, [entries]) } function computeReorder(sectionIds: string[], activeId: string, overId: string): string[] | null { const oldIndex = sectionIds.indexOf(activeId) const newIndex = sectionIds.indexOf(overId) if (oldIndex === -1 || newIndex === -1) return null const reordered = [...sectionIds] reordered.splice(oldIndex, 1) reordered.splice(newIndex, 0, activeId) return reordered } function buildCustomizeArgs(typeEntry: VaultEntry, prop: 'icon' | 'color', value: string): [string, string] { return [ prop === 'icon' ? value : (typeEntry.icon ?? 'file-text'), prop === 'color' ? value : (typeEntry.color ?? 'blue'), ] } function applyCustomization( target: string | null, typeEntryMap: Record, onCustomizeType: ((typeName: string, icon: string, color: string) => void) | undefined, prop: 'icon' | 'color', value: string, ): void { if (!target || !onCustomizeType) return const te = typeEntryMap[target] const [icon, color] = te ? buildCustomizeArgs(te, prop, value) : [prop === 'icon' ? value : 'file-text', prop === 'color' ? value : 'blue'] onCustomizeType(target, icon, color) } // --- Sub-components --- function SidebarGroupHeader({ label, collapsed, onToggle, count, children }: { label: string collapsed: boolean onToggle: () => void count?: number children?: React.ReactNode }) { return ( ) } function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView, entries }: { view: ViewFile isActive: boolean onSelect: () => void onEditView?: (filename: string) => void onDeleteView?: (filename: string) => void entries: VaultEntry[] }) { const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries]) return (
{onEditView && ( )} {onDeleteView && ( )}
) } function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreateView, onEditView, onDeleteView, entries }: { views: ViewFile[] selection: SidebarSelection onSelect: (sel: SidebarSelection) => void collapsed: boolean onToggle: () => void onCreateView?: () => void onEditView?: (filename: string) => void onDeleteView?: (filename: string) => void entries: VaultEntry[] }) { return (
{onCreateView && ( { e.stopPropagation(); onCreateView() }} /> )} {!collapsed && (
{views.map((v) => ( onSelect({ kind: 'view', filename: v.filename })} onEditView={onEditView} onDeleteView={onDeleteView} entries={entries} /> ))}
)}
) } function TypesSection({ visibleSections, allSectionGroups, sectionIds, sensors, handleDragEnd, sectionProps, collapsed, onToggle, showCustomize, setShowCustomize, isSectionVisible, toggleVisibility, onCreateNewType, customizeRef }: { visibleSections: SectionGroup[] allSectionGroups: SectionGroup[] sectionIds: string[] sensors: ReturnType handleDragEnd: (event: DragEndEvent) => void sectionProps: { entries: VaultEntry[]; selection: SidebarSelection; onSelect: (sel: SidebarSelection) => void onContextMenu: (e: React.MouseEvent, type: string) => void renamingType: string | null; renameInitialValue: string; onRenameSubmit: (v: string) => void; onRenameCancel: () => void } collapsed: boolean onToggle: () => void showCustomize: boolean setShowCustomize: React.Dispatch> isSectionVisible: (type: string) => boolean toggleVisibility: (type: string) => void onCreateNewType?: () => void customizeRef: React.RefObject }) { return (
{ e.stopPropagation(); setShowCustomize((v) => !v) }} > {onCreateNewType && ( { e.stopPropagation(); onCreateNewType() }} /> )}
{showCustomize && }
{!collapsed && ( {visibleSections.map((g) => ( ))} )}
) } function SortableSection({ group, sectionProps }: { group: SectionGroup sectionProps: Omit & { entries: VaultEntry[]; renamingType: string | null; renameInitialValue: string } }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type }) const itemCount = sectionProps.entries.filter((e) => !e.archived && (group.type === 'Note' ? (e.isA === 'Note' || !e.isA) : e.isA === group.type), ).length const isRenaming = sectionProps.renamingType === group.type return (
) } function SortableFavoriteItem({ entry, isActive, onSelect }: { entry: VaultEntry; isActive: boolean; onSelect: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path }) return (
{entry.title}
) } function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorder, collapsed, onToggle }: { entries: VaultEntry[] selection: SidebarSelection onSelect: (sel: SidebarSelection) => void onSelectNote?: (entry: VaultEntry) => void onReorder?: (orderedPaths: string[]) => void collapsed: boolean onToggle: () => void }) { const favorites = useMemo( () => entries .filter((e) => e.favorite && !e.archived) .sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)), [entries], ) const favIds = useMemo(() => favorites.map((f) => f.path), [favorites]) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return const oldIndex = favIds.indexOf(active.id as string) const newIndex = favIds.indexOf(over.id as string) if (oldIndex === -1 || newIndex === -1) return const reordered = arrayMove(favIds, oldIndex, newIndex) onReorder?.(reordered) }, [favIds, onReorder]) if (favorites.length === 0) return null return (
{!collapsed && (
{favorites.map((entry) => { const isActive = isSelectionActive(selection, { kind: 'entity', entry }) return ( { onSelect({ kind: 'filter', filter: 'favorites' }) onSelectNote?.(entry) }} /> ) })}
)}
) } function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) { const { onMouseDown } = useDragRegion() return (
{onCollapse && ( )}
) } function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize, onStartRename }: { pos: { x: number; y: number } | null; type: string | null innerRef: React.Ref onOpenCustomize: (type: string) => void onStartRename: (type: string) => void }) { if (!pos || !type) return null const btnClass = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left" return (
) } function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: { target: string | null; typeEntryMap: Record innerRef: React.Ref onCustomize: (prop: 'icon' | 'color', value: string) => void onChangeTemplate: (template: string) => void onClose: () => void }) { if (!target) return null return (
onCustomize('icon', icon)} onChangeColor={(color) => onCustomize('color', color)} onChangeTemplate={onChangeTemplate} onClose={onClose} />
) } // --- Main Sidebar --- export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection, onToggleTypeVisibility, onSelectFavorite, onReorderFavorites, views = [], onCreateView, onEditView, onDeleteView, folders = [], onCreateFolder, inboxCount = 0, onCollapse, onCreateNewType, }: SidebarProps) { const [customizeTarget, setCustomizeTarget] = useState(null) const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null) const [renamingType, setRenamingType] = useState(null) const [renameInitialValue, setRenameInitialValue] = useState('') const [contextMenuType, setContextMenuType] = useState(null) const [showCustomize, setShowCustomize] = useState(false) const contextMenuRef = useRef(null) const popoverRef = useRef(null) const customizeRef = useRef(null) const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries) const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap]) const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility]) const { activeCount, archivedCount } = useEntryCounts(entries) const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, []) const closeCustomize = useCallback(() => setShowCustomize(false), []) const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), []) useOutsideClick(customizeRef, showCustomize, closeCustomize) useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu) useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return const reordered = computeReorder(sectionIds, active.id as string, over.id as string) if (reordered) onReorderSections?.(reordered.map((typeName, i) => ({ typeName, order: i }))) }, [sectionIds, onReorderSections]) const handleContextMenu = useCallback((e: React.MouseEvent, type: string) => { e.preventDefault(); e.stopPropagation() setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type) }, []) const cancelRename = useCallback(() => setRenamingType(null), []) const handleStartRename = useCallback((type: string) => { closeContextMenu() const group = allSectionGroups.find((g) => g.type === type) setRenameInitialValue(group?.label ?? type) setRenamingType(type) }, [closeContextMenu, allSectionGroups]) const handleRenameSubmit = useCallback((value: string) => { if (renamingType) onRenameSection?.(renamingType, value) setRenamingType(null) }, [renamingType, onRenameSection]) const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => { applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value) }, [customizeTarget, typeEntryMap, onCustomizeType]) const handleChangeTemplate = useCallback((template: string) => { if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template) }, [customizeTarget, onUpdateTypeTemplate]) const sectionProps = { entries, selection, onSelect, onContextMenu: handleContextMenu, renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename, } const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed() const hasFavorites = entries.some((e) => e.favorite && !e.archived) const hasViews = views.length > 0 || !!onCreateView return ( ) })