import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react' import type { VaultEntry, SidebarSelection } from '../types' import { resolveIcon } from '../utils/iconRegistry' import { 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, CaretLeft, GitDiff, } 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 onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void modifiedCount?: number onCommitPush?: () => void onCollapse?: () => void } 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)) // --- 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 buildTypeEntryMap(entries: VaultEntry[]): Record { const map: Record = {} for (const e of entries) { if (e.isA === 'Type') map[e.title] = e } return map } function applyOverrides(typeEntryMap: Record): SectionGroup[] { return BUILT_IN_SECTION_GROUPS.map((sg) => { const te = typeEntryMap[sg.type] if (!te?.icon && !te?.color) return sg return { ...sg, Icon: te?.icon ? resolveIcon(te.icon) : sg.Icon, customColor: te?.color ?? null } }) } function buildCustomSections(entries: VaultEntry[]): SectionGroup[] { 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 })) } 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[], isSectionVisible: (type: string) => boolean) { const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const allSectionGroups = useMemo(() => { const built = applyOverrides(typeEntryMap) const custom = buildCustomSections(entries) return sortSections([...built, ...custom], typeEntryMap) }, [entries, typeEntryMap]) const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible]) 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'), ] } // --- Sub-components --- function SortableSection({ group, sectionProps }: { group: SectionGroup sectionProps: Omit & { entries: VaultEntry[]; collapsed: Record; onToggle: (type: string) => void } }) { 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 return (
sectionProps.onToggle(group.type)} dragHandleProps={listeners} />
) } 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 }: { pos: { x: number; y: number } | null; type: string | null innerRef: React.Ref onOpenCustomize: (type: string) => void }) { if (!pos || !type) return null return (
) } function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: { target: string | null; typeEntryMap: Record innerRef: React.Ref onCustomize: (prop: 'icon' | 'color', value: string) => void onClose: () => void }) { if (!target) return null return (
onCustomize('icon', icon)} onChangeColor={(color) => onCustomize('color', color)} onClose={onClose} />
) } // --- Main Sidebar --- export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse, }: 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 [showCustomize, setShowCustomize] = useState(false) const contextMenuRef = useRef(null) const popoverRef = useRef(null) const customizeRef = useRef(null) const { toggleSection: toggleVisibility, isSectionVisible } = useSectionVisibility() const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, isSectionVisible) 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 handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => { if (!customizeTarget || !onCustomizeType) return const te = typeEntryMap[customizeTarget] if (!te) return const [icon, color] = buildCustomizeArgs(te, prop, value) onCustomizeType(customizeTarget, icon, color) }, [customizeTarget, typeEntryMap, onCustomizeType]) const sectionProps = { entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onContextMenu: handleContextMenu, onToggle: toggleSection, } return ( ) })