2026-02-21 09:41:46 +01:00
|
|
|
import { useState, useMemo, memo, type ComponentType } from 'react'
|
2026-02-14 19:35:10 +01:00
|
|
|
import type { VaultEntry, SidebarSelection } from '../types'
|
2026-02-16 16:56:44 +01:00
|
|
|
import { cn } from '@/lib/utils'
|
2026-02-20 23:49:30 +01:00
|
|
|
import { ChevronRight, ChevronDown, GitCommitHorizontal, Plus } from 'lucide-react'
|
2026-02-17 18:18:41 +01:00
|
|
|
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
2026-02-17 11:03:23 +01:00
|
|
|
import {
|
|
|
|
|
FileText,
|
|
|
|
|
Star,
|
2026-02-17 13:45:09 +01:00
|
|
|
Wrench,
|
2026-02-17 11:03:23 +01:00
|
|
|
Flask,
|
|
|
|
|
Target,
|
|
|
|
|
ArrowsClockwise,
|
|
|
|
|
Users,
|
|
|
|
|
CalendarBlank,
|
|
|
|
|
Tag,
|
|
|
|
|
TagSimple,
|
|
|
|
|
Trash,
|
2026-02-20 21:45:54 +01:00
|
|
|
StackSimple,
|
2026-02-17 11:03:23 +01:00
|
|
|
type IconProps,
|
|
|
|
|
} from '@phosphor-icons/react'
|
2026-02-14 18:22:42 +01:00
|
|
|
|
2026-02-14 19:35:10 +01:00
|
|
|
interface SidebarProps {
|
|
|
|
|
entries: VaultEntry[]
|
|
|
|
|
selection: SidebarSelection
|
|
|
|
|
onSelect: (selection: SidebarSelection) => void
|
2026-02-15 10:00:29 +01:00
|
|
|
onSelectNote?: (entry: VaultEntry) => void
|
2026-02-20 23:49:30 +01:00
|
|
|
onCreateType?: (type: string) => void
|
2026-02-21 09:41:46 +01:00
|
|
|
onCreateNewType?: () => void
|
2026-02-15 12:56:28 +01:00
|
|
|
modifiedCount?: number
|
2026-02-15 13:00:10 +01:00
|
|
|
onCommitPush?: () => void
|
2026-02-14 19:35:10 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
const TOP_NAV = [
|
|
|
|
|
{ label: 'All Notes', filter: 'all' as const, Icon: FileText },
|
|
|
|
|
{ label: 'Favorites', filter: 'favorites' as const, Icon: Star },
|
2026-02-14 19:38:38 +01:00
|
|
|
]
|
|
|
|
|
|
2026-02-21 09:41:46 +01:00
|
|
|
interface SectionGroup {
|
|
|
|
|
label: string
|
|
|
|
|
type: string
|
|
|
|
|
Icon: ComponentType<IconProps>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
2026-02-17 18:18:41 +01:00
|
|
|
{ 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 },
|
2026-02-20 21:45:54 +01:00
|
|
|
{ label: 'Types', type: 'Type', Icon: StackSimple },
|
2026-02-17 11:03:23 +01:00
|
|
|
]
|
2026-02-14 19:35:10 +01:00
|
|
|
|
2026-02-21 09:41:46 +01:00
|
|
|
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, modifiedCount = 0, onCommitPush }: SidebarProps) {
|
2026-02-14 19:35:10 +01:00
|
|
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
|
|
|
|
const toggleSection = (type: string) => {
|
|
|
|
|
setCollapsed((prev) => ({ ...prev, [type]: !prev[type] }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 23:07:10 +01:00
|
|
|
const getSectionColor = (entry: VaultEntry) => getTypeColor(entry.isA ?? 'Note')
|
2026-02-17 14:24:13 +01:00
|
|
|
|
2026-02-14 19:35:10 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 09:41:46 +01:00
|
|
|
// 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: FileText,
|
|
|
|
|
}))
|
|
|
|
|
}, [entries])
|
|
|
|
|
|
|
|
|
|
const allSectionGroups = useMemo(
|
|
|
|
|
() => [...BUILT_IN_SECTION_GROUPS, ...customSectionGroups],
|
|
|
|
|
[customSectionGroups],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const renderSection = ({ label, type, Icon }: SectionGroup) => {
|
|
|
|
|
const items = entries.filter((e) => e.isA === type)
|
|
|
|
|
const isCollapsed = collapsed[type] ?? false
|
|
|
|
|
const isTopic = type === 'Topic'
|
|
|
|
|
const isTypeSection = type === 'Type'
|
|
|
|
|
|
|
|
|
|
const handlePlusClick = (e: React.MouseEvent) => {
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
if (isTypeSection) {
|
|
|
|
|
onCreateNewType?.()
|
|
|
|
|
} else {
|
|
|
|
|
onCreateType?.(type)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={type} style={{ padding: '4px 6px' }}>
|
|
|
|
|
{/* 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 16px', borderRadius: 4, gap: 8 }}
|
|
|
|
|
onClick={() => onSelect({ kind: 'sectionGroup', type })}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center" style={{ gap: 8 }}>
|
|
|
|
|
<Icon size={16} style={{ color: getTypeColor(type) }} />
|
|
|
|
|
<span className="text-[13px] font-medium text-foreground">{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: getTypeLightColor(entry.isA ?? ''),
|
|
|
|
|
color: getSectionColor(entry),
|
|
|
|
|
}),
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
|
|
|
|
|
onSelectNote?.(entry)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{entry.title}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 18:22:42 +01:00
|
|
|
return (
|
2026-02-18 10:52:01 +01:00
|
|
|
<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}>
|
2026-02-17 16:55:23 +01:00
|
|
|
{/* Native macOS title bar on top */}
|
2026-02-14 19:35:10 +01:00
|
|
|
|
2026-02-16 16:56:44 +01:00
|
|
|
{/* Navigation */}
|
2026-02-17 11:03:23 +01:00
|
|
|
<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.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"
|
2026-02-17 14:12:56 +01:00
|
|
|
: "text-foreground hover:bg-accent"
|
2026-02-16 16:56:44 +01:00
|
|
|
)}
|
2026-02-17 11:03:23 +01:00
|
|
|
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
|
2026-02-17 13:45:09 +01:00
|
|
|
className="flex items-center justify-center bg-primary text-primary-foreground"
|
2026-02-17 11:03:23 +01:00
|
|
|
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10 }}
|
|
|
|
|
>
|
|
|
|
|
{count}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
{/* Disabled placeholders */}
|
|
|
|
|
<div
|
2026-02-17 14:12:56 +01:00
|
|
|
className="flex select-none items-center gap-2 rounded text-foreground"
|
2026-02-17 11:03:23 +01:00
|
|
|
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>
|
|
|
|
|
<div
|
2026-02-17 14:12:56 +01:00
|
|
|
className="flex select-none items-center gap-2 rounded text-foreground"
|
2026-02-17 11:03:23 +01:00
|
|
|
style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }}
|
|
|
|
|
title="Coming soon"
|
|
|
|
|
>
|
|
|
|
|
<Trash size={16} />
|
|
|
|
|
<span className="flex-1 text-[13px] font-medium">Trash</span>
|
|
|
|
|
</div>
|
2026-02-14 19:38:38 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-21 09:41:46 +01:00
|
|
|
{/* Section Groups (built-in + custom) */}
|
|
|
|
|
{allSectionGroups.map(renderSection)}
|
2026-02-14 18:22:42 +01:00
|
|
|
</nav>
|
2026-02-16 16:56:44 +01:00
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
{/* 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>
|
2026-02-15 13:00:10 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-14 18:22:42 +01:00
|
|
|
</aside>
|
|
|
|
|
)
|
2026-02-17 17:14:50 +01:00
|
|
|
})
|