feat(sidebar): rewrite Sidebar as composable compound component
Replace the monolithic Sidebar (560 lines of app-specific logic) with a composable shell exposing Sidebar.Header, Sidebar.Section, Sidebar.Footer, and Sidebar.FooterLink sub-components. Application logic (tree builders, starred items, domain types) is removed — those responsibilities move to the consuming app layer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,561 +1,239 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import styles from './Sidebar.module.css'
|
||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
||||
import { useStarred } from './useStarred'
|
||||
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
|
||||
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
// ── Sub-component props ─────────────────────────────────────────────────────
|
||||
|
||||
export interface SidebarApp {
|
||||
id: string
|
||||
name: string
|
||||
health: 'live' | 'stale' | 'dead'
|
||||
exchangeCount: number
|
||||
routes: SidebarRoute[]
|
||||
agents: SidebarAgent[]
|
||||
}
|
||||
|
||||
export interface SidebarRoute {
|
||||
id: string
|
||||
name: string
|
||||
exchangeCount: number
|
||||
}
|
||||
|
||||
export interface SidebarAgent {
|
||||
id: string
|
||||
name: string
|
||||
status: 'live' | 'stale' | 'dead'
|
||||
tps: number
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
apps: SidebarApp[]
|
||||
interface SidebarHeaderProps {
|
||||
logo: ReactNode
|
||||
title: string
|
||||
version?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
onNavigate?: (path: string) => void
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps.map((app) => ({
|
||||
id: `app:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
badge: formatCount(app.exchangeCount),
|
||||
path: `/apps/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: app.id,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `route:${app.id}:${route.id}`,
|
||||
starKey: `${app.id}:${route.id}`,
|
||||
label: route.name,
|
||||
icon: <ChevronRight size={12} />,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.routes.length > 0)
|
||||
.map((app) => ({
|
||||
id: `routes:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
badge: `${app.routes.length} routes`,
|
||||
path: `/routes/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `routes:${app.id}`,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `routestat:${app.id}:${route.id}`,
|
||||
starKey: `routes:${app.id}:${route.id}`,
|
||||
label: route.name,
|
||||
icon: <ChevronRight size={12} />,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.agents.length > 0)
|
||||
.map((app) => {
|
||||
const liveCount = app.agents.filter((a) => a.status === 'live').length
|
||||
return {
|
||||
id: `agents:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
badge: `${liveCount}/${app.agents.length} live`,
|
||||
path: `/agents/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `agents:${app.id}`,
|
||||
children: app.agents.map((agent) => ({
|
||||
id: `agent:${app.id}:${agent.id}`,
|
||||
starKey: `${app.id}:${agent.id}`,
|
||||
label: agent.name,
|
||||
badge: `${agent.tps.toFixed(1)}/s`,
|
||||
path: `/agents/${app.id}/${agent.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Starred section helpers ──────────────────────────────────────────────────
|
||||
|
||||
interface StarredItem {
|
||||
starKey: string
|
||||
interface SidebarSectionProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
path: string
|
||||
type: 'application' | 'route' | 'agent' | 'routestat'
|
||||
parentApp?: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
active?: boolean
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
|
||||
const items: StarredItem[] = []
|
||||
|
||||
for (const app of apps) {
|
||||
if (starredIds.has(app.id)) {
|
||||
items.push({
|
||||
starKey: app.id,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/apps/${app.id}`,
|
||||
type: 'application',
|
||||
})
|
||||
}
|
||||
for (const route of app.routes) {
|
||||
const key = `${app.id}:${route.id}`
|
||||
if (starredIds.has(key)) {
|
||||
items.push({
|
||||
starKey: key,
|
||||
label: route.name,
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
type: 'route',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
const agentsAppKey = `agents:${app.id}`
|
||||
if (starredIds.has(agentsAppKey)) {
|
||||
items.push({
|
||||
starKey: agentsAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/agents/${app.id}`,
|
||||
type: 'agent',
|
||||
})
|
||||
}
|
||||
for (const agent of app.agents) {
|
||||
const key = `${app.id}:${agent.id}`
|
||||
if (starredIds.has(key)) {
|
||||
items.push({
|
||||
starKey: key,
|
||||
label: agent.name,
|
||||
path: `/agents/${app.id}/${agent.id}`,
|
||||
type: 'agent',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Routes tree starred items
|
||||
const routesAppKey = `routes:${app.id}`
|
||||
if (starredIds.has(routesAppKey)) {
|
||||
items.push({
|
||||
starKey: routesAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/routes/${app.id}`,
|
||||
type: 'routestat',
|
||||
})
|
||||
}
|
||||
for (const route of app.routes) {
|
||||
const routeKey = `routes:${app.id}:${route.id}`
|
||||
if (starredIds.has(routeKey)) {
|
||||
items.push({
|
||||
starKey: routeKey,
|
||||
label: route.name,
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
type: 'routestat',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
interface SidebarFooterProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ── StarredGroup ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StarredGroup({
|
||||
label,
|
||||
items,
|
||||
onNavigate,
|
||||
onRemove,
|
||||
}: {
|
||||
interface SidebarFooterLinkProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
items: StarredItem[]
|
||||
onNavigate: (path: string) => void
|
||||
onRemove: (starKey: string) => void
|
||||
}) {
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface SidebarRootProps {
|
||||
collapsed?: boolean
|
||||
onCollapseToggle?: () => void
|
||||
searchValue?: string
|
||||
onSearchChange?: (query: string) => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function SidebarHeader({ logo, title, version, onClick, className }: SidebarHeaderProps) {
|
||||
const { collapsed } = useSidebarContext()
|
||||
|
||||
return (
|
||||
<div className={styles.starredGroup}>
|
||||
<div className={styles.starredGroupLabel}>{label}</div>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.starKey}
|
||||
className={styles.starredItem}
|
||||
onClick={() => onNavigate(item.path)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
|
||||
>
|
||||
{item.icon}
|
||||
<div className={styles.starredItemInfo}>
|
||||
<span className={styles.starredItemName}>{item.label}</span>
|
||||
{item.parentApp && (
|
||||
<span className={styles.starredItemContext}>{item.parentApp}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={styles.starredRemove}
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${item.label} from starred`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<div
|
||||
className={`${styles.logo} ${className ?? ''}`}
|
||||
onClick={onClick}
|
||||
style={onClick ? { cursor: 'pointer' } : undefined}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
||||
>
|
||||
{logo}
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<span className={styles.brand}>{title}</span>
|
||||
{version && <span className={styles.version}>{version}</span>}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
function SidebarSection({
|
||||
icon,
|
||||
label,
|
||||
open,
|
||||
onToggle,
|
||||
active,
|
||||
children,
|
||||
className,
|
||||
}: SidebarSectionProps) {
|
||||
const { collapsed, onCollapseToggle } = useSidebarContext()
|
||||
|
||||
export function Sidebar({ apps, className, onNavigate }: SidebarProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||||
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
|
||||
|
||||
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setAppsCollapsed((prev) => {
|
||||
const next = updater(prev)
|
||||
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setAgentsCollapsed((prev) => {
|
||||
const next = updater(prev)
|
||||
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setRoutesCollapsed((prev) => {
|
||||
const next = updater(prev)
|
||||
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
const routerNavigate = useNavigate()
|
||||
const nav = onNavigate ?? routerNavigate
|
||||
const location = useLocation()
|
||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||
|
||||
// Build tree data
|
||||
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
||||
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
||||
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
|
||||
|
||||
// Sidebar reveal from Cmd-K navigation (passed via location state)
|
||||
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarRevealPath) return
|
||||
|
||||
// Uncollapse Applications section if reveal path matches an apps tree node
|
||||
const matchesAppTree = appNodes.some((node) =>
|
||||
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||
// In icon-rail (collapsed) mode, render a centered icon with tooltip
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
// Expand sidebar and open the section
|
||||
onCollapseToggle?.()
|
||||
if (!open) onToggle()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onCollapseToggle?.()
|
||||
if (!open) onToggle()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.sectionIcon}>{icon}</span>
|
||||
</div>
|
||||
)
|
||||
if (matchesAppTree && appsCollapsed) {
|
||||
_setAppsCollapsed(false)
|
||||
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
|
||||
}
|
||||
|
||||
// Uncollapse Agents section if reveal path matches an agents tree node
|
||||
const matchesAgentTree = agentNodes.some((node) =>
|
||||
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||
)
|
||||
if (matchesAgentTree && agentsCollapsed) {
|
||||
_setAgentsCollapsed(false)
|
||||
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
|
||||
}
|
||||
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Build starred items
|
||||
const starredItems = useMemo(
|
||||
() => collectStarredItems(apps, starredIds),
|
||||
[apps, starredIds],
|
||||
)
|
||||
|
||||
const starredApps = starredItems.filter((i) => i.type === 'application')
|
||||
const starredRoutes = starredItems.filter((i) => i.type === 'route')
|
||||
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
||||
const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
|
||||
const hasStarred = starredItems.length > 0
|
||||
|
||||
// When a sidebar reveal path is provided (e.g. via Cmd-K navigation),
|
||||
// use it for sidebar selection so the correct item is highlighted
|
||||
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||
{/* Logo */}
|
||||
<div className={styles.logo} onClick={() => nav('/apps')} style={{ cursor: 'pointer' }}>
|
||||
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
||||
<div>
|
||||
<span className={styles.brand}>cameleer</span>
|
||||
<span className={styles.version}>v3.2.1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className={styles.searchWrap}>
|
||||
<div className={styles.searchInner}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<Search size={12} />
|
||||
</span>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchClear}
|
||||
onClick={() => setSearch('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation (scrollable) — includes starred section */}
|
||||
<div className={styles.navArea}>
|
||||
<div className={styles.section}>Navigation</div>
|
||||
|
||||
{/* Applications tree (collapsible, label navigates to /apps) */}
|
||||
<div className={styles.treeSection}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setAppsCollapsed((v) => !v)}
|
||||
aria-expanded={!appsCollapsed}
|
||||
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
|
||||
>
|
||||
{appsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => nav('/apps')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/apps') }}
|
||||
>
|
||||
Applications
|
||||
</span>
|
||||
</div>
|
||||
{!appsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={appNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:apps"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agents tree (collapsible, label navigates to /agents) */}
|
||||
<div className={styles.treeSection}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setAgentsCollapsed((v) => !v)}
|
||||
aria-expanded={!agentsCollapsed}
|
||||
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
||||
>
|
||||
{agentsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => nav('/agents')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/agents') }}
|
||||
>
|
||||
Agents
|
||||
</span>
|
||||
</div>
|
||||
{!agentsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={agentNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:agents"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Routes tree (collapsible, label navigates to /routes) */}
|
||||
<div className={styles.treeSection}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setRoutesCollapsed((v) => !v)}
|
||||
aria-expanded={!routesCollapsed}
|
||||
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
||||
>
|
||||
{routesCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => nav('/routes')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/routes') }}
|
||||
>
|
||||
Routes
|
||||
</span>
|
||||
</div>
|
||||
{!routesCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={routeNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:routes"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No results message */}
|
||||
{search && appNodes.length === 0 && agentNodes.length === 0 && (
|
||||
<div className={styles.noResults}>No results</div>
|
||||
)}
|
||||
|
||||
{/* Starred section (inside scrollable area, hidden when empty) */}
|
||||
{hasStarred && (
|
||||
<div className={styles.starredSection}>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.starredHeader}>★ Starred</span>
|
||||
</div>
|
||||
<div className={styles.starredList}>
|
||||
{starredApps.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Applications"
|
||||
items={starredApps}
|
||||
onNavigate={nav}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredRoutes.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Routes"
|
||||
items={starredRoutes}
|
||||
onNavigate={nav}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredAgents.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Agents"
|
||||
items={starredAgents}
|
||||
onNavigate={nav}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredRouteStats.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Routes"
|
||||
items={starredRouteStats}
|
||||
onNavigate={nav}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom links */}
|
||||
<div className={styles.bottom}>
|
||||
<div
|
||||
className={[
|
||||
styles.bottomItem,
|
||||
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => nav('/admin')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/admin') }}
|
||||
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
|
||||
>
|
||||
<span className={styles.bottomIcon}><Settings size={14} /></span>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>Admin</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
styles.bottomItem,
|
||||
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => nav('/api-docs')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/api-docs') }}
|
||||
>
|
||||
<span className={styles.bottomIcon}><FileText size={14} /></span>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>API Docs</div>
|
||||
</div>
|
||||
</div>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
<span className={styles.treeSectionLabel}>{label}</span>
|
||||
</div>
|
||||
</aside>
|
||||
{open && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ children, className }: SidebarFooterProps) {
|
||||
return (
|
||||
<div className={`${styles.bottom} ${className ?? ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooterLink({ icon, label, active, onClick, className }: SidebarFooterLinkProps) {
|
||||
const { collapsed } = useSidebarContext()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles.bottomItem,
|
||||
active ? styles.bottomItemActive : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={collapsed ? label : undefined}
|
||||
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
||||
>
|
||||
<span className={styles.bottomIcon}>{icon}</span>
|
||||
{!collapsed && (
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>{label}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Root component ──────────────────────────────────────────────────────────
|
||||
|
||||
function SidebarRoot({
|
||||
collapsed = false,
|
||||
onCollapseToggle,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
children,
|
||||
className,
|
||||
}: SidebarRootProps) {
|
||||
return (
|
||||
<SidebarContext.Provider value={{ collapsed, onCollapseToggle }}>
|
||||
<aside
|
||||
className={[
|
||||
styles.sidebar,
|
||||
collapsed ? styles.sidebarCollapsed : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{/* Collapse toggle */}
|
||||
{onCollapseToggle && (
|
||||
<button
|
||||
className={styles.collapseToggle}
|
||||
onClick={onCollapseToggle}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <ChevronsRight size={14} /> : <ChevronsLeft size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search (only when expanded and handler provided) */}
|
||||
{onSearchChange && !collapsed && (
|
||||
<div className={styles.searchWrap}>
|
||||
<div className={styles.searchInner}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<Search size={12} />
|
||||
</span>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={searchValue ?? ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchClear}
|
||||
onClick={() => onSearchChange('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</aside>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Compound export ─────────────────────────────────────────────────────────
|
||||
|
||||
export const Sidebar = Object.assign(SidebarRoot, {
|
||||
Header: SidebarHeader,
|
||||
Section: SidebarSection,
|
||||
Footer: SidebarFooter,
|
||||
FooterLink: SidebarFooterLink,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user