feat: improve sidebar

This commit is contained in:
2026-03-20 10:20:51 +01:00
parent 2635b45973
commit 166c7b0b7f
3 changed files with 284 additions and 147 deletions

View File

@@ -52,7 +52,7 @@ export function RecollectionSidebar() {
> >
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent"> <SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
<SidebarGroup> <SidebarGroup>
<div className="flex items-center justify-between gap-2 px-2 py-1.5"> <div className="flex items-center justify-between gap-2 py-1.5">
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel> <SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
<Button <Button
type="button" type="button"

View File

@@ -14,9 +14,29 @@
*/ */
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Tree, NodeApi, RowRendererProps, TreeApi } from 'react-arborist' import {
Tree,
NodeApi,
NodeRendererProps,
RowRendererProps,
TreeApi,
type CursorProps,
} from 'react-arborist'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { FileText, ChevronRight, ChevronDown, Plus, MoreHorizontal, Trash2, Pencil, Search, ChevronsDownUp, ChevronsUpDown, X, GripVertical, Folder } from 'lucide-react' import {
FileText,
ChevronRight,
ChevronDown,
Plus,
MoreHorizontal,
Trash2,
Pencil,
Search,
ChevronsDownUp,
ChevronsUpDown,
X,
GripVertical,
} from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@@ -28,13 +48,11 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useRecollectionSidebar } from './RecollectionSidebarContext' import { useRecollectionSidebar } from './RecollectionSidebarContext'
import { useParams, useNavigate, useLocation } from 'react-router-dom' import { useParams, useNavigate, useLocation } from 'react-router-dom'
import type { LogosPageMeta, LogosPageId } from '../state/recollectionStore' import type { LogosPageMeta } from '../state/recollectionStore'
import { removeLogosPageContent } from '../state/recollectionStore' import { removeLogosPageContent } from '../state/recollectionStore'
// Tree node data structure // Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object).
type TreeNode = { type TreeNode = LogosPageMeta & {
id: string
data: LogosPageMeta
children?: TreeNode[] children?: TreeNode[]
isFolder: boolean isFolder: boolean
} }
@@ -47,8 +65,7 @@ function buildTree(pages: LogosPageMeta[]): TreeNode[] {
// First pass: create all nodes // First pass: create all nodes
pages.forEach((page) => { pages.forEach((page) => {
pageMap.set(page.id, { pageMap.set(page.id, {
id: page.id, ...page,
data: page,
children: [], children: [],
isFolder: false, isFolder: false,
}) })
@@ -70,7 +87,7 @@ function buildTree(pages: LogosPageMeta[]): TreeNode[] {
// Sort children by position // Sort children by position
function sortNodes(nodes: TreeNode[]) { function sortNodes(nodes: TreeNode[]) {
nodes.sort((a, b) => a.data.position - b.data.position) nodes.sort((a, b) => a.position - b.position)
nodes.forEach((node) => sortNodes(node.children || [])) nodes.forEach((node) => sortNodes(node.children || []))
} }
sortNodes(rootNodes) sortNodes(rootNodes)
@@ -121,59 +138,91 @@ function EditableTitle({
) )
} }
// Tree row renderer const ROW_INDENT_PX = 20
function TreeRow({ const ROW_GUTTER_PX = 8
node,
innerRef, /**
attrs, * Line cursor = “insert as sibling at this indent” (not “drop into folder”).
children, * Triangle + dashed rail read as a slot between rows; matches sidebar theme.
}: RowRendererProps<TreeNode>) { */
const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) {
return (
<div
role="presentation"
aria-hidden
className="pointer-events-none absolute z-20 flex items-center gap-1.5"
style={{
top: top - 5,
left: left + ROW_GUTTER_PX,
right: Math.max(indent, ROW_GUTTER_PX),
}}
>
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm">
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden>
<path d="M0 5 L9 1.5 L9 8.5 Z" />
</svg>
</span>
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
<div
className={cn(
'h-0 w-full border-t-2 border-dashed border-primary',
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
)}
/>
</div>
</div>
)
})
/**
* Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover
* (always visible on coarse pointers). Title/icon strip is click-to-navigate only.
*/
function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
const { recollectionId } = useParams<{ recollectionId: string }>() const { recollectionId } = useParams<{ recollectionId: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { pathname } = useLocation() const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
const { tree, activePageId, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
const base = recollectionId ? `/recollections/${recollectionId}` : '' const base = recollectionId ? `/recollections/${recollectionId}` : ''
const baseLogos = `${base}/logos` const baseLogos = `${base}/logos`
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
const handleToggle = useCallback((e: React.MouseEvent) => { const handleToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
node.toggle() node.toggle()
}, [node]) },
[node]
)
const handleSelect = useCallback((e: React.MouseEvent) => { const handleSelect = useCallback(
(e: React.MouseEvent) => {
if (!node.state.isEditing && !node.state.isDragging) { if (!node.state.isEditing && !node.state.isDragging) {
handleSelectPage(node.data.id) handleSelectPage(node.data.id)
navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`) navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`)
} }
}, [handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging]) },
[handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging]
)
const handleRename = useCallback(() => { const handleRename = useCallback(() => {
node.edit() node.edit()
}, [node]) }, [node])
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
// Get all descendants to delete
const toDelete = new Set<string>() const toDelete = new Set<string>()
function collectDescendants(id: string) { function collectDescendants(id: string) {
toDelete.add(id) toDelete.add(id)
tree.forEach((p) => { tree.forEach((p) => {
if (p.parentId === id) { if (p.parentId === id) collectDescendants(p.id)
collectDescendants(p.id)
}
}) })
} }
collectDescendants(node.data.id) collectDescendants(node.data.id)
// Remove content from storage for all deleted pages
if (recollectionId) { if (recollectionId) {
toDelete.forEach((pageId) => { toDelete.forEach((pageId) => {
removeLogosPageContent(recollectionId, pageId) removeLogosPageContent(recollectionId, pageId)
}) })
} }
// Remove from tree
handleTreeChange(tree.filter((p) => !toDelete.has(p.id))) handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
}, [tree, handleTreeChange, node.data.id, recollectionId]) }, [tree, handleTreeChange, node.data.id, recollectionId])
@@ -190,41 +239,57 @@ function TreeRow({
position: maxPos + 1, position: maxPos + 1,
} }
handleTreeChange([...tree, newPage]) handleTreeChange([...tree, newPage])
// Expand parent if collapsed if (!node.isOpen) node.open()
if (!node.isOpen) {
node.open()
}
}, [tree, handleTreeChange, node]) }, [tree, handleTreeChange, node])
const hasChildren = (node.children?.length || 0) > 0 const hasChildren = (node.children?.length || 0) > 0
const level = node.level const pageTitle = node.data.title || 'Untitled'
return ( return (
<div className="flex min-w-0 flex-1 items-center gap-1">
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
<div <div
ref={innerRef}
{...attrs}
style={{
...attrs.style,
paddingLeft: `${level * 20 + 8}px`,
}}
className={cn( className={cn(
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md', 'flex shrink-0 items-center justify-center overflow-hidden',
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', 'transition-[width,opacity] duration-200 ease-out',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground', 'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100',
node.state.isDragging && 'opacity-50', '[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100',
node.state.willReceiveDrop && 'bg-sidebar-accent/50' node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5',
node.state.isEditing && 'pointer-events-none w-0 opacity-0'
)} )}
onClick={handleSelect}
> >
{/* Visual hierarchy indicator for nested items */}
{level > 0 && (
<div <div
className="absolute left-0 top-0 bottom-0 border-l-2 border-sidebar-border/50" ref={dragHandle}
style={{ left: `${(level - 1) * 20 + 16}px` }} style={{ ...style, paddingLeft: 0 }}
/> title={node.state.isEditing ? undefined : 'Drag to reorder'}
className={cn(
'logos-tree-drag-handle group/drag',
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
'border border-sidebar-border/50 bg-sidebar-accent/40',
'text-sidebar-foreground/70',
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
'hover:shadow-sm',
'active:scale-[0.97] active:cursor-grabbing',
'cursor-grab outline-none',
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
node.state.isDragging &&
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
)} )}
onClick={(e) => e.stopPropagation()}
>
<GripVertical
className={cn(
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
node.state.isDragging && 'opacity-100'
)}
strokeWidth={2}
/>
</div>
</div>
{/* Expand/Collapse toggle for folders */}
{hasChildren ? ( {hasChildren ? (
<button <button
type="button" type="button"
@@ -241,41 +306,39 @@ function TreeRow({
<div className="w-4 shrink-0" /> <div className="w-4 shrink-0" />
)} )}
{/* Icon */} <div
{node.data.isFolder ? ( className={cn(
<Folder className="size-4 shrink-0 text-sidebar-foreground/70" /> 'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
) : ( node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40'
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
)} )}
onClick={handleSelect}
>
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
{/* Title - Editable */} <div className="min-w-0 flex-1">
<div className="flex-1 min-w-0">
{node.state.isEditing ? ( {node.state.isEditing ? (
<EditableTitle <EditableTitle
title={node.data.data.title} title={node.data.title}
onSave={(newTitle) => { onSave={(newTitle) => {
if (newTitle.trim()) { if (newTitle.trim()) {
handleTreeChange( handleTreeChange(
tree.map((p) => tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
p.id === node.data.id ? { ...p, title: newTitle.trim() } : p
)
) )
} }
node.submit(newTitle) node.submit(newTitle)
}} }}
onCancel={() => { onCancel={() => node.reset()}
node.reset()
}}
/> />
) : ( ) : (
<span className="truncate block select-none"> <span className="block truncate select-none">{pageTitle}</span>
{node.data.data.title || 'Untitled'}
</span>
)} )}
</div> </div>
</div>
{/* Actions - Only visible on hover */} <div
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
onPointerDown={(e) => e.stopPropagation()}
>
<button <button
type="button" type="button"
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors" className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
@@ -303,10 +366,7 @@ function TreeRow({
Rename Rename
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
onClick={handleDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-3.5" /> <Trash2 className="mr-2 size-3.5" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@@ -317,6 +377,47 @@ function TreeRow({
) )
} }
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
const { pathname } = useLocation()
const { activePageId } = useRecollectionSidebar()
const { recollectionId } = useParams<{ recollectionId: string }>()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const baseLogos = `${base}/logos`
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
const level = node.level
const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX
return (
<div
ref={innerRef}
{...attrs}
style={{
...attrs.style,
paddingLeft: `${indentPx}px`,
}}
className={cn(
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150',
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
node.state.isDragging && 'cursor-grabbing opacity-45',
// Folder drop target: index === null in library — reads as “open container”, not a line between rows
node.state.willReceiveDrop &&
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]'
)}
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
>
{level > 0 && (
<div
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45"
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }}
/>
)}
{children}
</div>
)
}
export function TreeBrowser() { export function TreeBrowser() {
const { tree, handleTreeChange } = useRecollectionSidebar() const { tree, handleTreeChange } = useRecollectionSidebar()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -334,7 +435,7 @@ export function TreeBrowser() {
function filterNodes(nodes: TreeNode[]): TreeNode[] { function filterNodes(nodes: TreeNode[]): TreeNode[] {
const result: TreeNode[] = [] const result: TreeNode[] = []
for (const node of nodes) { for (const node of nodes) {
const matches = node.data.title.toLowerCase().includes(searchTerm) const matches = node.title.toLowerCase().includes(searchTerm)
const children = filterNodes(node.children || []) const children = filterNodes(node.children || [])
if (matches || children.length > 0) { if (matches || children.length > 0) {
@@ -365,40 +466,46 @@ export function TreeBrowser() {
return folderIds return folderIds
}, [treeNodes]) }, [treeNodes])
// Handle drag and drop reordering // Handle drag and drop reordering (react-arborist onMove)
const handleMove = useCallback( const handleMove = useCallback(
({ dragIds, parentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => { ({
dragIds,
parentId: newParentId,
index,
}: {
dragIds: string[]
parentId: string | null
index: number
}) => {
const dragId = dragIds[0] const dragId = dragIds[0]
if (!dragId) return if (!dragId) return
// Find the dragged page const dragged = tree.find((p) => p.id === dragId)
const draggedPage = tree.find((p) => p.id === dragId) if (!dragged) return
if (!draggedPage) return
// Get all siblings at the new location (excluding the dragged item) const oldParentId = dragged.parentId
const siblings = tree.filter((p) => p.parentId === parentId && p.id !== dragId) const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
// Sort siblings by current position const newSiblings = tree
siblings.sort((a, b) => a.position - b.position) .filter((p) => p.parentId === newParentId && p.id !== dragId)
.sort((a, b) => a.position - b.position)
newSiblings.splice(index, 0, moved)
// Insert dragged item at the new index const updates = new Map<string, LogosPageMeta>()
siblings.splice(index, 0, { ...draggedPage, parentId, position: index }) newSiblings.forEach((p, i) => {
updates.set(p.id, { ...p, parentId: newParentId, position: i })
// 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) if (oldParentId !== newParentId) {
tree
.filter((p) => p.parentId === oldParentId && p.id !== dragId)
.sort((a, b) => a.position - b.position)
.forEach((p, i) => {
updates.set(p.id, { ...p, position: i })
})
}
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p))
}, },
[tree, handleTreeChange] [tree, handleTreeChange]
) )
@@ -418,10 +525,33 @@ export function TreeBrowser() {
setSearchQuery('') setSearchQuery('')
}, []) }, [])
// Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps;
// varying key counts on the props object trigger "dependency array changed size" warnings.
const arboristTreeProps = useMemo(
() => ({
data: filteredTree,
idAccessor: 'id' as const,
childrenAccessor: 'children' as const,
width: '100%' as const,
height: 600,
rowHeight: 32,
indent: ROW_INDENT_PX,
renderRow: TreeRow,
initialOpenState,
onMove: handleMove,
disableDrag: Boolean(searchQuery),
disableDrop: Boolean(searchQuery),
className: 'react-arborist-tree',
renderCursor: LogosDropCursor,
children: LogosTreeNode,
}),
[filteredTree, initialOpenState, handleMove, searchQuery]
)
return ( return (
<div className="flex flex-col gap-2 h-full"> <div className="flex flex-col gap-2 h-full">
{/* Search Toolbar */} {/* Search Toolbar */}
<div className="flex items-center gap-1.5 px-2"> <div className="flex items-center gap-1.5">
<div className="relative flex-1"> <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" /> <Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
<Input <Input
@@ -469,23 +599,8 @@ export function TreeBrowser() {
</div> </div>
{/* Tree */} {/* Tree */}
<div className="flex-1 min-h-0 overflow-hidden px-2"> <div className="flex-1 min-h-0 overflow-hidden">
<Tree <Tree ref={treeRef} {...arboristTreeProps} />
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>
</div> </div>
) )

View File

@@ -401,6 +401,19 @@ pre {
background: none; background: none;
} }
/* Logos tree drop line (LogosDropCursor in TreeBrowser.tsx) */
@keyframes tree-drop-line-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* React Arborist tree styling for sidebar integration */ /* React Arborist tree styling for sidebar integration */
.react-arborist-tree { .react-arborist-tree {
background: transparent !important; background: transparent !important;
@@ -443,6 +456,15 @@ pre {
cursor: grabbing; cursor: grabbing;
} }
/* Drag handle: restore focus ring (tree uses outline:none on all descendants) */
.react-arborist-tree .logos-tree-drag-handle:focus-visible {
outline: 2px solid hsl(var(--sidebar-ring)) !important;
outline-offset: 2px;
box-shadow:
0 0 0 2px hsl(var(--sidebar-background)),
0 2px 8px hsl(var(--sidebar-ring) / 0.25);
}
/* Hide default react-arborist backgrounds */ /* Hide default react-arborist backgrounds */
.react-arborist-tree [data-react-arborist-tree] { .react-arborist-tree [data-react-arborist-tree] {
background: transparent !important; background: transparent !important;