feat: redesign Sidebar with hierarchical trees, starring, and collapsible sections

Replace flat app/route/agent lists with expandable tree navigation.
Apps contain their routes and agents hierarchically. Add localStorage-
backed starring with composite keys for uniqueness. Persist expand
state to sessionStorage across page navigations. Add collapsible
section headers, remove button on starred items, and parent app
context labels. Create stub pages for /apps/:id, /agents/:id,
/admin, /api-docs. Consolidate duplicated sidebar data into
shared mock. Widen sidebar from 220px to 260px.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 17:50:41 +01:00
parent 4aeb5be6ab
commit e69e5ab5fe
23 changed files with 1809 additions and 484 deletions

View File

@@ -0,0 +1,429 @@
import {
useState,
useRef,
useCallback,
useMemo,
type ReactNode,
type KeyboardEvent,
type MouseEvent,
} from 'react'
import { useNavigate } from 'react-router-dom'
import styles from './Sidebar.module.css'
// ── Types ────────────────────────────────────────────────────────────────────
export interface SidebarTreeNode {
id: string
label: string
icon?: ReactNode
badge?: string
path?: string
starrable?: boolean
starKey?: string // unique key for starring (defaults to id)
children?: SidebarTreeNode[]
}
export interface SidebarTreeProps {
nodes: SidebarTreeNode[]
selectedPath?: string // current URL path — matches against node.path
isStarred: (id: string) => boolean
onToggleStar: (id: string) => void
className?: string
filterQuery?: string
persistKey?: string // sessionStorage key to persist expand state across remounts
}
// ── Star icon SVGs ───────────────────────────────────────────────────────────
function StarOutline() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
}
function StarFilled() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
}
// ── Persistent expand state ──────────────────────────────────────────────────
function readExpandState(key: string): Set<string> {
try {
const raw = sessionStorage.getItem(key)
if (raw) {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) return new Set(arr)
}
} catch { /* ignore */ }
return new Set()
}
function writeExpandState(key: string, ids: Set<string>): void {
try {
sessionStorage.setItem(key, JSON.stringify([...ids]))
} catch { /* ignore */ }
}
// ── Flat node for keyboard nav ───────────────────────────────────────────────
interface FlatNode {
node: SidebarTreeNode
depth: number
parentId: string | null
}
function flattenVisible(
nodes: SidebarTreeNode[],
expandedIds: Set<string>,
depth = 0,
parentId: string | null = null,
): FlatNode[] {
const result: FlatNode[] = []
for (const node of nodes) {
result.push({ node, depth, parentId })
if (node.children && node.children.length > 0 && expandedIds.has(node.id)) {
result.push(...flattenVisible(node.children, expandedIds, depth + 1, node.id))
}
}
return result
}
// ── Filter logic ─────────────────────────────────────────────────────────────
function filterNodes(
nodes: SidebarTreeNode[],
query: string,
): { filtered: SidebarTreeNode[]; matchedParentIds: Set<string> } {
if (!query) return { filtered: nodes, matchedParentIds: new Set() }
const q = query.toLowerCase()
const matchedParentIds = new Set<string>()
function walk(nodeList: SidebarTreeNode[]): SidebarTreeNode[] {
const result: SidebarTreeNode[] = []
for (const node of nodeList) {
const childResults = node.children ? walk(node.children) : []
const selfMatches = node.label.toLowerCase().includes(q)
if (selfMatches || childResults.length > 0) {
if (childResults.length > 0) {
matchedParentIds.add(node.id)
}
result.push({
...node,
children: childResults.length > 0
? childResults
: node.children?.filter((c) => c.label.toLowerCase().includes(q)),
})
}
}
return result
}
return { filtered: walk(nodes), matchedParentIds }
}
// ── SidebarTree ──────────────────────────────────────────────────────────────
export function SidebarTree({
nodes,
selectedPath,
isStarred,
onToggleStar,
className,
filterQuery,
persistKey,
}: SidebarTreeProps) {
const navigate = useNavigate()
// Expand/collapse state — optionally persisted to sessionStorage
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
() => persistKey ? readExpandState(persistKey) : new Set(),
)
// Filter
const { filtered, matchedParentIds } = useMemo(
() => filterNodes(nodes, filterQuery ?? ''),
[nodes, filterQuery],
)
// Effective expanded set: user toggles + auto-expanded from search
const expandedSet = useMemo(() => {
if (filterQuery) {
return new Set([...userExpandedIds, ...matchedParentIds])
}
return userExpandedIds
}, [userExpandedIds, matchedParentIds, filterQuery])
function handleToggle(id: string) {
setUserExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
if (persistKey) writeExpandState(persistKey, next)
return next
})
}
// Keyboard navigation
const [focusedId, setFocusedId] = useState<string | null>(null)
const treeRef = useRef<HTMLUListElement>(null)
const visibleNodes = useMemo(
() => flattenVisible(filtered, expandedSet),
[filtered, expandedSet],
)
const getFocusedIndex = useCallback(() => {
if (focusedId === null) return -1
return visibleNodes.findIndex((fn) => fn.node.id === focusedId)
}, [focusedId, visibleNodes])
function focusNode(id: string) {
const el = treeRef.current?.querySelector(`[data-nodeid="${CSS.escape(id)}"]`) as HTMLElement | null
if (el) {
el.focus()
} else {
setFocusedId(id)
}
}
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLUListElement>) => {
const currentIndex = getFocusedIndex()
const current = visibleNodes[currentIndex]
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
break
}
case 'ArrowRight': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren) {
if (!expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
break
}
case 'ArrowLeft': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else if (current.parentId !== null) {
focusNode(current.parentId)
}
break
}
case 'Enter': {
e.preventDefault()
if (current?.node.path) {
navigate(current.node.path)
}
break
}
case 'Home': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[0].node.id)
}
break
}
case 'End': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
}
break
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleNodes, expandedSet, focusedId],
)
return (
<ul
ref={treeRef}
role="tree"
className={`${styles.tree} ${className ?? ''}`}
onKeyDown={handleKeyDown}
>
{filtered.map((node) => (
<SidebarTreeRow
key={node.id}
node={node}
depth={0}
expandedSet={expandedSet}
selectedPath={selectedPath}
focusedId={focusedId}
isStarred={isStarred}
onToggle={handleToggle}
onToggleStar={onToggleStar}
onFocus={setFocusedId}
navigate={navigate}
/>
))}
</ul>
)
}
// ── Row ──────────────────────────────────────────────────────────────────────
interface SidebarTreeRowProps {
node: SidebarTreeNode
depth: number
expandedSet: Set<string>
selectedPath?: string
focusedId: string | null
isStarred: (id: string) => boolean
onToggle: (id: string) => void
onToggleStar: (id: string) => void
onFocus: (id: string) => void
navigate: (path: string) => void
}
function SidebarTreeRow({
node,
depth,
expandedSet,
selectedPath,
focusedId,
isStarred,
onToggle,
onToggleStar,
onFocus,
navigate,
}: SidebarTreeRowProps) {
const hasChildren = node.children && node.children.length > 0
const isExpanded = expandedSet.has(node.id)
const isSelected = Boolean(node.path && selectedPath === node.path)
const isFocused = focusedId === node.id
const effectiveStarKey = node.starKey ?? node.id
const starred = isStarred(effectiveStarKey)
function handleRowClick() {
if (node.path) {
navigate(node.path)
}
}
function handleChevronClick(e: MouseEvent) {
e.stopPropagation()
onToggle(node.id)
}
function handleStarClick(e: MouseEvent) {
e.stopPropagation()
onToggleStar(effectiveStarKey)
}
const rowClass = [
styles.treeRow,
isSelected ? styles.treeRowActive : '',
]
.filter(Boolean)
.join(' ')
return (
<li role="none">
<div
role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
tabIndex={isFocused ? 0 : -1}
data-nodeid={node.id}
className={rowClass}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleRowClick}
onFocus={() => onFocus(node.id)}
>
{/* Chevron */}
<span className={styles.treeChevronSlot}>
{hasChildren ? (
<button
className={styles.treeChevron}
onClick={handleChevronClick}
tabIndex={-1}
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? '▾' : '▸'}
</button>
) : null}
</span>
{/* Icon (health dot, arrow, etc.) */}
{node.icon && (
<span className={styles.treeIcon} aria-hidden="true">
{node.icon}
</span>
)}
{/* Label */}
<span className={styles.treeLabel}>{node.label}</span>
{/* Badge */}
{node.badge && (
<span className={styles.treeBadge}>{node.badge}</span>
)}
{/* Star */}
{node.starrable && (
<button
className={`${styles.treeStar} ${starred ? styles.treeStarActive : ''}`}
onClick={handleStarClick}
tabIndex={-1}
aria-label={starred ? 'Remove from starred' : 'Add to starred'}
>
{starred ? <StarFilled /> : <StarOutline />}
</button>
)}
</div>
{/* Children */}
{hasChildren && isExpanded && (
<ul role="group" className={styles.treeGroup}>
{node.children!.map((child) => (
<SidebarTreeRow
key={child.id}
node={child}
depth={depth + 1}
expandedSet={expandedSet}
selectedPath={selectedPath}
focusedId={focusedId}
isStarred={isStarred}
onToggle={onToggle}
onToggleStar={onToggleStar}
onFocus={onFocus}
navigate={navigate}
/>
))}
</ul>
)}
</li>
)
}