Files
tolaria/src/components/sidebar/SidebarSections.tsx
2026-04-11 14:06:03 +02:00

566 lines
18 KiB
TypeScript

import {
useMemo, useCallback, type Dispatch, type Ref, type RefObject, type SetStateAction,
} from 'react'
import type { VaultEntry, SidebarSelection, ViewFile } from '../../types'
import { evaluateView } from '../../utils/viewFilters'
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext, useSortable, verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { arrayMove } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { SlidersHorizontal } from 'lucide-react'
import {
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
} from '@phosphor-icons/react'
import {
type SectionGroup, isSelectionActive, NavItem, SectionContent, VisibilityPopover,
} from '../SidebarParts'
import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { NoteTitleIcon } from '../NoteTitleIcon'
export interface SidebarSectionProps {
entries: VaultEntry[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
onContextMenu: (event: React.MouseEvent, type: string) => void
renamingType: string | null
renameInitialValue: string
onRenameSubmit: (value: string) => void
onRenameCancel: () => void
}
function SidebarGroupHeader({
label,
collapsed,
onToggle,
count,
children,
}: {
label: string
collapsed: boolean
onToggle: () => void
count?: number
children?: React.ReactNode
}) {
return (
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
style={{ padding: '8px 14px 8px 16px' }}
onClick={onToggle}
>
<div className="flex items-center gap-1">
{collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>{label}</span>
</div>
{children ?? (count != null && (
<span
className="flex items-center justify-center text-muted-foreground"
style={{ height: 18, borderRadius: 9999, padding: '0 5px', fontSize: 10, background: 'var(--muted)' }}
>
{count}
</span>
))}
</button>
)
}
function ViewItem({
view,
isActive,
onSelect,
onEditView,
onDeleteView,
entries,
}: {
view: ViewFile
isActive: boolean
onSelect: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
entries: VaultEntry[]
}) {
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
return (
<div className="group relative">
<NavItem
icon={Funnel}
emoji={view.definition.icon}
label={view.definition.name}
count={count}
badgeClassName="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
isActive={isActive}
onClick={onSelect}
/>
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(event) => { event.stopPropagation(); onEditView(view.filename) }}
title="Edit view"
>
<PencilSimple size={12} />
</button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={(event) => { event.stopPropagation(); onDeleteView(view.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
</div>
</div>
)
}
export function SidebarTopNav({
selection,
onSelect,
showInbox,
inboxCount,
activeCount,
archivedCount,
}: {
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
showInbox: boolean
inboxCount: number
activeCount: number
archivedCount: number
}) {
return (
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
{showInbox && (
<NavItem
icon={Tray}
label="Inbox"
count={inboxCount}
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })}
badgeClassName="text-muted-foreground"
badgeStyle={{ background: 'var(--muted)' }}
activeBadgeClassName="bg-primary text-primary-foreground"
onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })}
/>
)}
<NavItem
icon={FileText}
label="All Notes"
count={activeCount}
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })}
badgeClassName="text-muted-foreground"
badgeStyle={{ background: 'var(--muted)' }}
activeBadgeClassName="bg-primary text-primary-foreground"
onClick={() => onSelect({ kind: 'filter', filter: 'all' })}
/>
<NavItem
icon={Archive}
label="Archive"
count={archivedCount}
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })}
badgeClassName="text-muted-foreground"
badgeStyle={{ background: 'var(--muted)' }}
activeBadgeClassName="bg-primary text-primary-foreground"
onClick={() => onSelect({ kind: 'filter', filter: 'archived' })}
/>
</div>
)
}
export function ViewsSection({
views,
selection,
onSelect,
collapsed,
onToggle,
onCreateView,
onEditView,
onDeleteView,
entries,
}: {
views: ViewFile[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
collapsed: boolean
onToggle: () => void
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
entries: VaultEntry[]
}) {
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label="VIEWS" collapsed={collapsed} onToggle={onToggle}>
{onCreateView && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
onClick={(event) => { event.stopPropagation(); onCreateView() }}
/>
)}
</SidebarGroupHeader>
{!collapsed && (
<div style={{ paddingBottom: 4 }}>
{views.map((view) => (
<ViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
/>
))}
</div>
)}
</div>
)
}
function SortableSection({
group,
sectionProps,
}: {
group: SectionGroup
sectionProps: SidebarSectionProps
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const itemCount = sectionProps.entries.filter((entry) =>
!entry.archived && (group.type === 'Note' ? (entry.isA === 'Note' || !entry.isA) : entry.isA === group.type),
).length
const isRenaming = sectionProps.renamingType === group.type
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
padding: '0 6px',
}}
{...attributes}
>
<SectionContent
group={group}
itemCount={itemCount}
selection={sectionProps.selection}
onSelect={sectionProps.onSelect}
onContextMenu={sectionProps.onContextMenu}
dragHandleProps={listeners}
isRenaming={isRenaming}
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
onRenameSubmit={sectionProps.onRenameSubmit}
onRenameCancel={sectionProps.onRenameCancel}
/>
</div>
)
}
export function TypesSection({
visibleSections,
allSectionGroups,
sectionIds,
sensors,
handleDragEnd,
sectionProps,
collapsed,
onToggle,
showCustomize,
setShowCustomize,
isSectionVisible,
toggleVisibility,
onCreateNewType,
customizeRef,
}: {
visibleSections: SectionGroup[]
allSectionGroups: SectionGroup[]
sectionIds: string[]
sensors: ReturnType<typeof useSensors>
handleDragEnd: (event: DragEndEvent) => void
sectionProps: SidebarSectionProps
collapsed: boolean
onToggle: () => void
showCustomize: boolean
setShowCustomize: Dispatch<SetStateAction<boolean>>
isSectionVisible: (type: string) => boolean
toggleVisibility: (type: string) => void
onCreateNewType?: () => void
customizeRef: RefObject<HTMLDivElement | null>
}) {
return (
<div className="border-b border-border">
<div ref={customizeRef} style={{ position: 'relative', padding: '0 6px' }}>
<SidebarGroupHeader label="TYPES" collapsed={collapsed} onToggle={onToggle}>
<div className="flex items-center gap-1.5">
<span
role="button"
title="Customize sections"
aria-label="Customize sections"
onClick={(event) => { event.stopPropagation(); setShowCustomize((value) => !value) }}
>
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
</span>
{onCreateNewType && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
data-testid="create-type-btn"
onClick={(event) => { event.stopPropagation(); onCreateNewType() }}
/>
)}
</div>
</SidebarGroupHeader>
{showCustomize && (
<VisibilityPopover
sections={allSectionGroups}
isSectionVisible={isSectionVisible}
onToggle={toggleVisibility}
/>
)}
</div>
{!collapsed && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{visibleSections.map((group) => (
<SortableSection key={group.type} group={group} sectionProps={sectionProps} />
))}
</SortableContext>
</DndContext>
)}
</div>
)
}
const FAVORITE_TYPE_ICON_MAP: Record<string, string> = {
Project: 'wrench',
project: 'wrench',
Experiment: 'flask',
experiment: 'flask',
Responsibility: 'target',
responsibility: 'target',
Procedure: 'arrows-clockwise',
procedure: 'arrows-clockwise',
Person: 'users',
person: 'users',
Event: 'calendar-blank',
event: 'calendar-blank',
Topic: 'tag',
topic: 'tag',
Type: 'stack-simple',
type: 'stack-simple',
}
function getFavoriteIcon(entry: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
return entry.icon ?? typeEntry?.icon ?? FAVORITE_TYPE_ICON_MAP[entry.isA ?? ''] ?? 'file-text'
}
function SortableFavoriteItem({
entry,
isActive,
onSelect,
typeEntryMap,
}: {
entry: VaultEntry
isActive: boolean
onSelect: () => void
typeEntryMap: Record<string, VaultEntry>
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path })
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
const icon = getFavoriteIcon(entry, typeEntryMap)
const typeColor = getTypeColor(entry.isA ?? null, typeEntry?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? null, typeEntry?.color)
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }}
{...attributes}
{...listeners}
>
<div
className={`group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors ${isActive ? '' : 'hover:bg-accent'}`}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...(isActive ? { background: typeLightColor } : {}) }}
onClick={onSelect}
>
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<NoteTitleIcon icon={icon} size={16} color={typeColor} />
<span className="truncate text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? typeColor : undefined }}>
{entry.title}
</span>
</div>
</div>
</div>
)
}
function sortFavorites(entries: VaultEntry[]) {
return entries
.filter((entry) => entry.favorite && !entry.archived)
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity))
}
function reorderFavoriteIds(favoriteIds: string[], event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return null
const oldIndex = favoriteIds.indexOf(active.id as string)
const newIndex = favoriteIds.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return null
return arrayMove(favoriteIds, oldIndex, newIndex)
}
export function FavoritesSection({
entries,
selection,
onSelect,
onSelectNote,
onReorder,
collapsed,
onToggle,
}: {
entries: VaultEntry[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
onSelectNote?: (entry: VaultEntry) => void
onReorder?: (orderedPaths: string[]) => void
collapsed: boolean
onToggle: () => void
}) {
const favorites = useMemo(() => sortFavorites(entries), [entries])
const favoriteIds = useMemo(() => favorites.map((entry) => entry.path), [favorites])
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
const handleDragEnd = useCallback((event: DragEndEvent) => {
const reordered = reorderFavoriteIds(favoriteIds, event)
if (reordered) onReorder?.(reordered)
}, [favoriteIds, onReorder])
if (favorites.length === 0) return null
return (
<div style={{ padding: '0 6px' }}>
<SidebarGroupHeader label="FAVORITES" collapsed={collapsed} onToggle={onToggle} count={favorites.length} />
{!collapsed && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={favoriteIds} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: 4 }}>
{favorites.map((entry) => (
<SortableFavoriteItem
key={entry.path}
entry={entry}
isActive={isSelectionActive(selection, { kind: 'entity', entry })}
typeEntryMap={typeEntryMap}
onSelect={() => {
onSelect({ kind: 'filter', filter: 'favorites' })
onSelectNote?.(entry)
}}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
)
}
export function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
const { onMouseDown } = useDragRegion()
return (
<div
className="shrink-0 flex items-center justify-end border-b border-border"
style={{ height: 52, padding: '0 8px', paddingLeft: 80, cursor: 'default' }}
onMouseDown={onMouseDown}
>
{onCollapse && (
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ width: 24, height: 24 }}
onClick={onCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<CaretLeft size={14} weight="bold" />
</button>
)}
</div>
)
}
export function ContextMenuOverlay({
pos,
type,
innerRef,
onOpenCustomize,
onStartRename,
}: {
pos: { x: number; y: number } | null
type: string | null
innerRef: Ref<HTMLDivElement>
onOpenCustomize: (type: string) => void
onStartRename: (type: string) => void
}) {
if (!pos || !type) return null
const buttonClass = 'flex w-full items-center gap-2 rounded-sm border-none bg-transparent px-2 py-1.5 text-left text-sm cursor-default transition-colors hover:bg-accent hover:text-accent-foreground'
return (
<div
ref={innerRef}
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
style={{ left: pos.x, top: pos.y, minWidth: 180 }}
>
<button className={buttonClass} onClick={() => onStartRename(type)}>
Rename section
</button>
<button className={buttonClass} onClick={() => onOpenCustomize(type)}>
Customize icon &amp; color
</button>
</div>
)
}
export function CustomizeOverlay({
target,
typeEntryMap,
innerRef,
onCustomize,
onChangeTemplate,
onClose,
}: {
target: string | null
typeEntryMap: Record<string, VaultEntry>
innerRef: Ref<HTMLDivElement>
onCustomize: (prop: 'icon' | 'color', value: string) => void
onChangeTemplate: (template: string) => void
onClose: () => void
}) {
if (!target) return null
return (
<div ref={innerRef} className="fixed z-50" style={{ left: 20, top: 100 }}>
<TypeCustomizePopover
currentIcon={typeEntryMap[target]?.icon ?? null}
currentColor={typeEntryMap[target]?.color ?? null}
currentTemplate={typeEntryMap[target]?.template ?? null}
onChangeIcon={(icon) => onCustomize('icon', icon)}
onChangeColor={(color) => onCustomize('color', color)}
onChangeTemplate={onChangeTemplate}
onClose={onClose}
/>
</div>
)
}