Files
tolaria/src/components/Sidebar.tsx
lucaronin 081f1e5743 feat: replace 'Customize Sections' label with 'Sections' header
Changed the sidebar section header from a button-style "Customize sections"
label with inline icon to a clean "Sections" heading with the settings icon
pushed to the far right. The settings icon still toggles the customize
popover — no functionality change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 18:40:06 +01:00

604 lines
23 KiB
TypeScript

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<IconProps>
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<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const [showCustomize, setShowCustomize] = useState(false)
const customizeRef = useRef<HTMLDivElement>(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<string, VaultEntry> = {}
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<string, unknown>) => {
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 */}
<div
className={cn(
"group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors",
isActive({ kind: 'sectionGroup', type })
? "bg-secondary"
: "hover:bg-accent"
)}
style={{ padding: '6px 8px 6px 6px', borderRadius: 4, gap: 4 }}
onClick={() => onSelect({ kind: 'sectionGroup', type })}
onContextMenu={(e) => handleContextMenu(e, type)}
>
<div className="flex items-center" style={{ gap: 4 }}>
{/* Drag handle */}
<div
className="flex shrink-0 items-center justify-center text-muted-foreground opacity-0 group-hover/section:opacity-50 hover:!opacity-100 cursor-grab"
style={{ width: 16, height: 16 }}
{...dragHandleProps}
aria-label={`Drag to reorder ${label}`}
>
<GripVertical size={12} />
</div>
<Icon size={16} style={{ color: sectionColor }} />
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
</div>
<div className="flex items-center" style={{ gap: 2 }}>
{(onCreateType || (isTypeSection && onCreateNewType)) && (
<button
className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer"
style={{ width: 20, height: 20 }}
onClick={handlePlusClick}
aria-label={isTypeSection ? 'Create new Type' : `Create new ${type}`}
title={isTypeSection ? 'New Type' : `New ${type}`}
>
<Plus size={14} />
</button>
)}
<button
className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
toggleSection(type)
}}
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
</div>
</div>
{/* Children items */}
{!isCollapsed && items.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{items.map((entry) => (
<div
key={entry.path}
className={cn(
"cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors",
isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
? "text-foreground"
: "text-muted-foreground hover:bg-accent"
)}
style={{
padding: '4px 16px 4px 28px',
...(isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) && {
backgroundColor: sectionLightColor,
color: sectionColor,
}),
}}
onClick={() => {
onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
onSelectNote?.(entry)
}}
>
{entry.title}
</div>
))}
</div>
)}
</>
)
}
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 (
<div ref={setNodeRef} style={style} {...attributes}>
{renderSectionContent(group, listeners)}
</div>
)
}
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground" style={{ paddingTop: 38 } as React.CSSProperties}>
{/* Native macOS title bar on top */}
{/* Navigation */}
<nav className="flex-1 overflow-y-auto">
{/* Top nav — All Notes + Favorites */}
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
{TOP_NAV.map(({ label, filter, Icon }) => {
const count = filter === 'all' ? entries.filter((e) => !e.archived && !e.trashed).length : 0
return (
<div
key={filter}
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
isActive({ kind: 'filter', filter })
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4 }}
onClick={() => onSelect({ kind: 'filter', filter })}
>
<Icon size={16} />
<span className="flex-1 text-[13px] font-medium">{label}</span>
{count > 0 && (
<span
className="flex items-center justify-center bg-primary text-primary-foreground"
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10 }}
>
{count}
</span>
)}
</div>
)
})}
{/* Archive filter */}
<div
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
isActive({ kind: 'filter', filter: 'archived' })
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4 }}
onClick={() => onSelect({ kind: 'filter', filter: 'archived' })}
>
<Archive size={16} />
<span className="flex-1 text-[13px] font-medium">Archive</span>
{archivedCount > 0 && (
<span
className="flex items-center justify-center text-muted-foreground"
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
>
{archivedCount}
</span>
)}
</div>
{/* Disabled placeholders */}
<div
className="flex select-none items-center gap-2 rounded text-foreground"
style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }}
title="Coming soon"
>
<TagSimple size={16} />
<span className="flex-1 text-[13px] font-medium">Untagged</span>
</div>
{/* Trash filter */}
<div
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
isActive({ kind: 'filter', filter: 'trash' })
? "bg-destructive/10 text-destructive"
: "text-foreground hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4 }}
onClick={() => onSelect({ kind: 'filter', filter: 'trash' })}
>
<Trash size={16} />
<span className="flex-1 text-[13px] font-medium">Trash</span>
{trashedCount > 0 && (
<span
className="flex items-center justify-center text-muted-foreground"
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
>
{trashedCount}
</span>
)}
</div>
</div>
{/* Sections header + customize popover */}
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px 0' }}>
<div
className="flex w-full select-none items-center justify-between"
style={{ padding: '4px 16px' }}
>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Sections</span>
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground"
style={{ width: 20, height: 20 }}
onClick={() => setShowCustomize((v) => !v)}
aria-label="Customize sections"
title="Customize sections"
>
<SlidersHorizontal size={14} />
</button>
</div>
{showCustomize && (
<div
className="border border-border bg-popover text-popover-foreground"
style={{
position: 'absolute',
top: '100%',
left: 6,
right: 6,
zIndex: 50,
borderRadius: 8,
padding: '8px 0',
boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
}}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>
Show in sidebar
</div>
{allSectionGroups.map(({ label, type, Icon }) => (
<button
key={type}
className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent"
style={{ padding: '6px 12px', gap: 8 }}
onClick={() => toggleVisibility(type)}
aria-label={`Toggle ${label}`}
>
<Icon size={14} style={{ color: getTypeColor(type) }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
<div
className="flex items-center"
style={{
width: 32,
height: 18,
borderRadius: 9,
padding: 2,
backgroundColor: isSectionVisible(type) ? 'var(--primary)' : 'var(--muted)',
justifyContent: isSectionVisible(type) ? 'flex-end' : 'flex-start',
transition: 'background-color 150ms',
}}
>
<div
style={{
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: 'white',
transition: 'transform 150ms',
}}
/>
</div>
</button>
))}
</div>
)}
</div>
{/* Section Groups (built-in + custom), filtered by visibility, sortable by drag */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{visibleSections.map((g) => (
<SortableSection key={g.type} group={g} />
))}
</SortableContext>
</DndContext>
</nav>
{/* Commit button — always visible */}
{onCommitPush && (
<div className="shrink-0 border-t border-border" style={{ padding: 12 }}>
<button
className="flex w-full items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
style={{ borderRadius: 6, gap: 6, padding: '8px 16px', border: 'none', cursor: 'pointer' }}
onClick={onCommitPush}
>
<GitCommitHorizontal size={14} />
<span className="text-[13px] font-medium">Commit & Push</span>
{modifiedCount > 0 && (
<span
className="text-white font-semibold"
style={{ background: '#ffffff40', borderRadius: 9, padding: '0 6px', fontSize: 10 }}
>
{modifiedCount}
</span>
)}
</button>
</div>
)}
{/* Context menu (right-click on section header) */}
{contextMenuPos && contextMenuType && (
<div
ref={contextMenuRef}
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
style={{ left: contextMenuPos.x, top: contextMenuPos.y, minWidth: 180 }}
>
<button
className="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"
onClick={() => openCustomizePopover(contextMenuType)}
>
Customize icon & color
</button>
</div>
)}
{/* Customize popover */}
{customizeTarget && (
<div
ref={popoverRef}
className="fixed z-50"
style={{ left: 20, top: 100 }}
>
<TypeCustomizePopover
currentIcon={typeEntryMap[customizeTarget]?.icon ?? null}
currentColor={typeEntryMap[customizeTarget]?.color ?? null}
onChangeIcon={handleCustomizeIcon}
onChangeColor={handleCustomizeColor}
onClose={() => setCustomizeTarget(null)}
/>
</div>
)}
</aside>
)
})