feat: sidebar tree

This commit is contained in:
2026-03-20 08:49:00 +01:00
parent c3cbe35883
commit 2635b45973
5 changed files with 703 additions and 289 deletions

View File

@@ -0,0 +1,492 @@
/**
* TreeBrowser: A tree browser component using react-arborist for managing
* Logos pages in a hierarchical folder structure.
*
* Features:
* - Drag and drop reordering
* - Folder expansion/collapse
* - Context menu for actions
* - Shadcn theme integration
* - Inline editing for page titles
* - Smooth animations
* - Search functionality
* - Expand/collapse all
*/
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Tree, NodeApi, RowRendererProps, TreeApi } from 'react-arborist'
import { cn } from '@/lib/utils'
import { FileText, ChevronRight, ChevronDown, Plus, MoreHorizontal, Trash2, Pencil, Search, ChevronsDownUp, ChevronsUpDown, X, GripVertical, Folder } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { useRecollectionSidebar } from './RecollectionSidebarContext'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import type { LogosPageMeta, LogosPageId } from '../state/recollectionStore'
import { removeLogosPageContent } from '../state/recollectionStore'
// Tree node data structure
type TreeNode = {
id: string
data: LogosPageMeta
children?: TreeNode[]
isFolder: boolean
}
// Convert flat tree to hierarchical structure
function buildTree(pages: LogosPageMeta[]): TreeNode[] {
const pageMap = new Map<string, TreeNode>()
const rootNodes: TreeNode[] = []
// First pass: create all nodes
pages.forEach((page) => {
pageMap.set(page.id, {
id: page.id,
data: page,
children: [],
isFolder: false,
})
})
// Second pass: build parent-child relationships
pages.forEach((page) => {
const node = pageMap.get(page.id)!
if (page.parentId === null) {
rootNodes.push(node)
} else {
const parent = pageMap.get(page.parentId)
if (parent) {
parent.children?.push(node)
parent.isFolder = true
}
}
})
// Sort children by position
function sortNodes(nodes: TreeNode[]) {
nodes.sort((a, b) => a.data.position - b.data.position)
nodes.forEach((node) => sortNodes(node.children || []))
}
sortNodes(rootNodes)
return rootNodes
}
// Memoize the buildTree result to avoid unnecessary re-creation
function useTreeNodes(tree: LogosPageMeta[]) {
return useMemo(() => buildTree(tree), [tree])
}
// Inline editable title component
function EditableTitle({
title,
onSave,
onCancel,
}: {
title: string
onSave: (newTitle: string) => void
onCancel: () => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
React.useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onSave(inputRef.current?.value || '')
} else if (e.key === 'Escape') {
onCancel()
}
}
return (
<input
ref={inputRef}
type="text"
defaultValue={title}
onBlur={(e) => onSave(e.currentTarget.value)}
onKeyDown={handleKeyDown}
className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background"
onClick={(e) => e.stopPropagation()}
/>
)
}
// Tree row renderer
function TreeRow({
node,
innerRef,
attrs,
children,
}: RowRendererProps<TreeNode>) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const navigate = useNavigate()
const { pathname } = useLocation()
const { tree, activePageId, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const baseLogos = `${base}/logos`
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
const handleToggle = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
node.toggle()
}, [node])
const handleSelect = useCallback((e: React.MouseEvent) => {
if (!node.state.isEditing && !node.state.isDragging) {
handleSelectPage(node.data.id)
navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`)
}
}, [handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging])
const handleRename = useCallback(() => {
node.edit()
}, [node])
const handleDelete = useCallback(() => {
// Get all descendants to delete
const toDelete = new Set<string>()
function collectDescendants(id: string) {
toDelete.add(id)
tree.forEach((p) => {
if (p.parentId === id) {
collectDescendants(p.id)
}
})
}
collectDescendants(node.data.id)
// Remove content from storage for all deleted pages
if (recollectionId) {
toDelete.forEach((pageId) => {
removeLogosPageContent(recollectionId, pageId)
})
}
// Remove from tree
handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
}, [tree, handleTreeChange, node.data.id, recollectionId])
const handleAddChild = useCallback(() => {
const maxPos = Math.max(
0,
...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position),
-1
)
const newPage: LogosPageMeta = {
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
title: 'Untitled',
parentId: node.data.id,
position: maxPos + 1,
}
handleTreeChange([...tree, newPage])
// Expand parent if collapsed
if (!node.isOpen) {
node.open()
}
}, [tree, handleTreeChange, node])
const hasChildren = (node.children?.length || 0) > 0
const level = node.level
return (
<div
ref={innerRef}
{...attrs}
style={{
...attrs.style,
paddingLeft: `${level * 20 + 8}px`,
}}
className={cn(
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md',
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
node.state.isDragging && 'opacity-50',
node.state.willReceiveDrop && 'bg-sidebar-accent/50'
)}
onClick={handleSelect}
>
{/* Visual hierarchy indicator for nested items */}
{level > 0 && (
<div
className="absolute left-0 top-0 bottom-0 border-l-2 border-sidebar-border/50"
style={{ left: `${(level - 1) * 20 + 16}px` }}
/>
)}
{/* Expand/Collapse toggle for folders */}
{hasChildren ? (
<button
type="button"
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={handleToggle}
>
{node.isOpen ? (
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
) : (
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
)}
</button>
) : (
<div className="w-4 shrink-0" />
)}
{/* Icon */}
{node.data.isFolder ? (
<Folder className="size-4 shrink-0 text-sidebar-foreground/70" />
) : (
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
)}
{/* Title - Editable */}
<div className="flex-1 min-w-0">
{node.state.isEditing ? (
<EditableTitle
title={node.data.data.title}
onSave={(newTitle) => {
if (newTitle.trim()) {
handleTreeChange(
tree.map((p) =>
p.id === node.data.id ? { ...p, title: newTitle.trim() } : p
)
)
}
node.submit(newTitle)
}}
onCancel={() => {
node.reset()
}}
/>
) : (
<span className="truncate block select-none">
{node.data.data.title || 'Untitled'}
</span>
)}
</div>
{/* Actions - Only visible on hover */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={(e) => {
e.stopPropagation()
handleAddChild()
}}
title="Add child page"
>
<Plus className="size-3.5 text-sidebar-foreground/70" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={handleRename}>
<Pencil className="mr-2 size-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
export function TreeBrowser() {
const { tree, handleTreeChange } = useRecollectionSidebar()
const [searchQuery, setSearchQuery] = useState('')
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
// Build tree from flat structure
const treeNodes = useTreeNodes(tree)
// Filter tree based on search query
const filteredTree = useMemo(() => {
if (!searchQuery.trim()) return treeNodes
const searchTerm = searchQuery.toLowerCase()
function filterNodes(nodes: TreeNode[]): TreeNode[] {
const result: TreeNode[] = []
for (const node of nodes) {
const matches = node.data.title.toLowerCase().includes(searchTerm)
const children = filterNodes(node.children || [])
if (matches || children.length > 0) {
result.push({
...node,
children: children.length > 0 ? children : undefined,
})
}
}
return result
}
return filterNodes(treeNodes)
}, [treeNodes, searchQuery])
// Get all folder IDs for initial open state
const initialOpenState = useMemo(() => {
const folderIds: Record<string, boolean> = {}
function collectIds(nodes: TreeNode[]) {
nodes.forEach((node) => {
if (node.isFolder) {
folderIds[node.id] = true
collectIds(node.children || [])
}
})
}
collectIds(treeNodes)
return folderIds
}, [treeNodes])
// Handle drag and drop reordering
const handleMove = useCallback(
({ dragIds, parentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => {
const dragId = dragIds[0]
if (!dragId) return
// Find the dragged page
const draggedPage = tree.find((p) => p.id === dragId)
if (!draggedPage) return
// Get all siblings at the new location (excluding the dragged item)
const siblings = tree.filter((p) => p.parentId === parentId && p.id !== dragId)
// Sort siblings by current position
siblings.sort((a, b) => a.position - b.position)
// Insert dragged item at the new index
siblings.splice(index, 0, { ...draggedPage, parentId, position: index })
// Create updated tree with new positions
const updatedTree = tree.map((p) => {
// Find the item in the siblings array
const siblingIndex = siblings.findIndex((s) => s.id === p.id)
if (siblingIndex >= 0) {
// This item is in the affected parent - update its position
return { ...p, parentId: siblings[siblingIndex].parentId, position: siblingIndex }
}
// Item is not affected by the move
return p
})
handleTreeChange(updatedTree)
},
[tree, handleTreeChange]
)
// Expand all folders
const handleExpandAll = useCallback(() => {
treeRef.current?.openAll()
}, [])
// Collapse all folders
const handleCollapseAll = useCallback(() => {
treeRef.current?.closeAll()
}, [])
// Clear search
const handleClearSearch = useCallback(() => {
setSearchQuery('')
}, [])
return (
<div className="flex flex-col gap-2 h-full">
{/* Search Toolbar */}
<div className="flex items-center gap-1.5 px-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
<Input
type="text"
placeholder="Search pages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
/>
{searchQuery && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={handleClearSearch}
>
<X className="size-3" />
</Button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title="Expand/Collapse options"
>
<ChevronsUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExpandAll}>
<ChevronsDownUp className="mr-2 size-3.5" />
Expand all
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCollapseAll}>
<ChevronsUpDown className="mr-2 size-3.5" />
Collapse all
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tree */}
<div className="flex-1 min-h-0 overflow-hidden px-2">
<Tree
ref={treeRef}
data={filteredTree}
idAccessor="id"
childrenAccessor="children"
width="100%"
height={600}
rowHeight={32}
indent={0}
renderRow={TreeRow}
initialOpenState={initialOpenState}
onMove={handleMove}
disableDrag={!!searchQuery}
disableDrop={!!searchQuery}
className="react-arborist-tree"
/>
</div>
</div>
)
}