import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react' import type { VaultEntry, SidebarSelection } from '../types' import { resolveIcon } from '../utils/iconRegistry' import { buildTypeEntryMap } from '../utils/typeColors' import { pluralizeType } from '../hooks/useCommandRegistry' 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, Wrench, Flask, Target, ArrowsClockwise, Users, CalendarBlank, Tag, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse, } from '@phosphor-icons/react' import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react' import { type SectionGroup, isSelectionActive, NavItem, SectionContent, type SectionContentProps, VisibilityPopover, } from './SidebarParts' import { useDragRegion } from '../hooks/useDragRegion' 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 modifiedCount?: number onCommitPush?: () => void onCollapse?: () => void isGitVault?: boolean } 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 }, ] /** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */ const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg])) // --- 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]) } /** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */ function collectActiveTypes(entries: VaultEntry[]): Set { const types = new Set() for (const e of entries) { if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA) } return types } /** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */ function buildSectionGroup(type: string, typeEntryMap: Record): SectionGroup { const builtIn = BUILT_IN_TYPE_MAP.get(type) const typeEntry = typeEntryMap[type] const customColor = typeEntry?.color ?? null const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type)) if (builtIn) { const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon return { ...builtIn, label, Icon, customColor } } return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor } } /** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */ function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record): SectionGroup[] { const activeTypes = collectActiveTypes(entries) return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap)) } function sortSections(groups: SectionGroup[], typeEntryMap: Record): SectionGroup[] { return [...groups].sort((a, b) => { const orderA = typeEntryMap[a.type]?.order ?? Infinity const orderB = typeEntryMap[b.type]?.order ?? Infinity return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label) }) } 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 } } function useEntryCounts(entries: VaultEntry[]) { return useMemo(() => { let active = 0, archived = 0, trashed = 0 for (const e of entries) { if (e.trashed) trashed++ else if (e.archived) archived++ else active++ } return { activeCount: active, archivedCount: archived, trashedCount: trashed } }, [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 SortableSection({ group, sectionProps }: { group: SectionGroup sectionProps: Omit & { entries: VaultEntry[]; collapsed: Record; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string } }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type }) const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed) const isCollapsed = sectionProps.collapsed[group.type] ?? true const isRenaming = sectionProps.renamingType === group.type return (
sectionProps.onToggle(group.type)} dragHandleProps={listeners} isRenaming={isRenaming} renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined} onRenameSubmit={sectionProps.onRenameSubmit} onRenameCancel={sectionProps.onRenameCancel} />
) } function CommitButton({ modifiedCount, onClick }: { modifiedCount: number; onClick?: () => void }) { if (!onClick) return null return (
) } 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, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection, onToggleTypeVisibility, modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false, }: SidebarProps) { const [collapsed, setCollapsed] = useState>({}) 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, trashedCount } = 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 toggleSection = useCallback((type: string) => { setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) })) }, []) 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, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onContextMenu: handleContextMenu, onToggle: toggleSection, renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename, } return ( ) })