import { useState, useMemo, useRef, useEffect, useCallback, memo, type ComponentType } from 'react' import type { VaultEntry, SidebarSelection } from '../types' import { cn } from '@/lib/utils' import { ChevronRight, ChevronDown, GitCommitHorizontal, Plus, SlidersHorizontal, GripVertical } from 'lucide-react' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { resolveIcon, TypeCustomizePopover } from './TypeCustomizePopover' import { useSectionVisibility } from '../hooks/useSectionVisibility' 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, Star, Wrench, Flask, Target, ArrowsClockwise, Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, type IconProps, } from '@phosphor-icons/react' 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 onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void modifiedCount?: number onCommitPush?: () => void } const TOP_NAV = [ { label: 'All Notes', filter: 'all' as const, Icon: FileText }, { label: 'Favorites', filter: 'favorites' as const, Icon: Star }, ] interface SectionGroup { label: string type: string Icon: ComponentType customColor?: string | null } const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [ { label: 'Projects', type: 'Project', Icon: Wrench }, { label: 'Experiments', type: 'Experiment', Icon: Flask }, { label: 'Responsibilities', type: 'Responsibility', Icon: Target }, { label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise }, { label: 'People', type: 'Person', Icon: Users }, { label: 'Events', type: 'Event', Icon: CalendarBlank }, { label: 'Topics', type: 'Topic', Icon: Tag }, { label: 'Types', type: 'Type', Icon: StackSimple }, ] const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type)) export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush }: SidebarProps) { const [collapsed, setCollapsed] = useState>({}) const [customizeTarget, setCustomizeTarget] = useState(null) const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null) const [contextMenuType, setContextMenuType] = useState(null) const contextMenuRef = useRef(null) const popoverRef = useRef(null) const [showCustomize, setShowCustomize] = useState(false) const customizeRef = useRef(null) const { toggleSection: toggleVisibility, isSectionVisible } = useSectionVisibility() // Close section-visibility popover on outside click useEffect(() => { if (!showCustomize) return const handleClick = (e: MouseEvent) => { if (customizeRef.current && !customizeRef.current.contains(e.target as Node)) { setShowCustomize(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [showCustomize]) const toggleSection = (type: string) => { setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) })) } // Build a map of type name → type entry for quick lookup of icon/color const typeEntryMap = useMemo(() => { const map: Record = {} for (const e of entries) { if (e.isA === 'Type') map[e.title] = e } return map }, [entries]) const isActive = (sel: SidebarSelection): boolean => { if (selection.kind !== sel.kind) return false if (sel.kind === 'filter' && selection.kind === 'filter') return sel.filter === selection.filter if (sel.kind === 'sectionGroup' && selection.kind === 'sectionGroup') return sel.type === selection.type if (sel.kind === 'entity' && selection.kind === 'entity') return sel.entry.path === selection.entry.path if (sel.kind === 'topic' && selection.kind === 'topic') return sel.entry.path === selection.entry.path return false } // Derive custom type sections from Type entries not in the built-in list const customSectionGroups: SectionGroup[] = useMemo(() => { return entries .filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title)) .sort((a, b) => a.title.localeCompare(b.title)) .map((e) => ({ label: e.title + 's', type: e.title, Icon: resolveIcon(e.icon), customColor: e.color, })) }, [entries]) // For built-in types, check if they have custom icon/color overrides from their type entry const builtInWithOverrides: SectionGroup[] = useMemo(() => { return BUILT_IN_SECTION_GROUPS.map((sg) => { const typeEntry = typeEntryMap[sg.type] if (!typeEntry?.icon && !typeEntry?.color) return sg return { ...sg, Icon: typeEntry?.icon ? resolveIcon(typeEntry.icon) : sg.Icon, customColor: typeEntry?.color ?? null, } }) }, [typeEntryMap]) // Merge built-in and custom sections, then sort by order from type entries const allSectionGroups = useMemo(() => { const merged = [...builtInWithOverrides, ...customSectionGroups] return merged.sort((a, b) => { const orderA = typeEntryMap[a.type]?.order ?? Infinity const orderB = typeEntryMap[b.type]?.order ?? Infinity if (orderA !== orderB) return orderA - orderB return a.label.localeCompare(b.label) }) }, [builtInWithOverrides, customSectionGroups, typeEntryMap]) // DnD sensors with activation distance to avoid accidental drags on click const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) const visibleSections = useMemo( () => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible], ) const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections]) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return const oldIndex = sectionIds.indexOf(active.id as string) const newIndex = sectionIds.indexOf(over.id as string) if (oldIndex === -1 || newIndex === -1) return // Compute new order: assign sequential order values to the reordered list const reordered = [...sectionIds] reordered.splice(oldIndex, 1) reordered.splice(newIndex, 0, active.id as string) const updates = reordered.map((typeName, i) => ({ typeName, order: i })) onReorderSections?.(updates) }, [sectionIds, onReorderSections]) const archivedCount = useMemo(() => entries.filter((e) => e.archived).length, [entries]) const trashedCount = useMemo(() => entries.filter((e) => e.trashed).length, [entries]) // Close context menu on outside click useEffect(() => { if (!contextMenuPos) return const handler = (e: MouseEvent) => { if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { setContextMenuPos(null) setContextMenuType(null) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [contextMenuPos]) // Close customize popover on outside click useEffect(() => { if (!customizeTarget) return const handler = (e: MouseEvent) => { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { setCustomizeTarget(null) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [customizeTarget]) const handleContextMenu = useCallback((e: React.MouseEvent, type: string) => { e.preventDefault() e.stopPropagation() setContextMenuPos({ x: e.clientX, y: e.clientY }) setContextMenuType(type) }, []) const openCustomizePopover = useCallback((type: string) => { setContextMenuPos(null) setContextMenuType(null) setCustomizeTarget(type) }, []) const handleCustomizeIcon = useCallback((icon: string) => { if (!customizeTarget) return const typeEntry = typeEntryMap[customizeTarget] if (typeEntry && onCustomizeType) { onCustomizeType(customizeTarget, icon, typeEntry.color ?? 'blue') } }, [customizeTarget, typeEntryMap, onCustomizeType]) const handleCustomizeColor = useCallback((color: string) => { if (!customizeTarget) return const typeEntry = typeEntryMap[customizeTarget] if (typeEntry && onCustomizeType) { onCustomizeType(customizeTarget, typeEntry.icon ?? 'file-text', color) } }, [customizeTarget, typeEntryMap, onCustomizeType]) const renderSectionContent = ({ label, type, Icon, customColor }: SectionGroup, dragHandleProps?: Record) => { const items = entries.filter((e) => e.isA === type && !e.archived && !e.trashed) const isCollapsed = collapsed[type] ?? true const isTopic = type === 'Topic' const isTypeSection = type === 'Type' const sectionColor = getTypeColor(type, customColor) const sectionLightColor = getTypeLightColor(type, customColor) const handlePlusClick = (e: React.MouseEvent) => { e.stopPropagation() if (isTypeSection) { onCreateNewType?.() } else { onCreateType?.(type) } } return ( <> {/* Section header row */}
onSelect({ kind: 'sectionGroup', type })} onContextMenu={(e) => handleContextMenu(e, type)} >
{/* Drag handle */}
{label}
{(onCreateType || (isTypeSection && onCreateNewType)) && ( )}
{/* Children items */} {!isCollapsed && items.length > 0 && (
{items.map((entry) => (
{ onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) onSelectNote?.(entry) }} > {entry.title}
))}
)} ) } function SortableSection({ group }: { group: SectionGroup }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: group.type }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '4px 6px', } return (
{renderSectionContent(group, listeners)}
) } return ( ) })