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 { type ReactNode } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import {
|
||||||
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react'
|
Search,
|
||||||
|
X,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
} from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
||||||
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
|
||||||
import { useStarred } from './useStarred'
|
|
||||||
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Sub-component props ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SidebarApp {
|
interface SidebarHeaderProps {
|
||||||
id: string
|
logo: ReactNode
|
||||||
name: string
|
title: string
|
||||||
health: 'live' | 'stale' | 'dead'
|
version?: string
|
||||||
exchangeCount: number
|
onClick?: () => void
|
||||||
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[]
|
|
||||||
className?: string
|
className?: string
|
||||||
onNavigate?: (path: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
interface SidebarSectionProps {
|
||||||
|
icon: ReactNode
|
||||||
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
|
|
||||||
label: string
|
label: string
|
||||||
icon?: React.ReactNode
|
open: boolean
|
||||||
path: string
|
onToggle: () => void
|
||||||
type: 'application' | 'route' | 'agent' | 'routestat'
|
active?: boolean
|
||||||
parentApp?: string
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
|
interface SidebarFooterProps {
|
||||||
const items: StarredItem[] = []
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── StarredGroup ─────────────────────────────────────────────────────────────
|
interface SidebarFooterLinkProps {
|
||||||
|
icon: ReactNode
|
||||||
function StarredGroup({
|
|
||||||
label,
|
|
||||||
items,
|
|
||||||
onNavigate,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
label: string
|
label: string
|
||||||
items: StarredItem[]
|
active?: boolean
|
||||||
onNavigate: (path: string) => void
|
onClick?: () => void
|
||||||
onRemove: (starKey: string) => 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 (
|
return (
|
||||||
<div className={styles.starredGroup}>
|
<div
|
||||||
<div className={styles.starredGroupLabel}>{label}</div>
|
className={`${styles.logo} ${className ?? ''}`}
|
||||||
{items.map((item) => (
|
onClick={onClick}
|
||||||
<div
|
style={onClick ? { cursor: 'pointer' } : undefined}
|
||||||
key={item.starKey}
|
role={onClick ? 'button' : undefined}
|
||||||
className={styles.starredItem}
|
tabIndex={onClick ? 0 : undefined}
|
||||||
onClick={() => onNavigate(item.path)}
|
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
||||||
role="button"
|
>
|
||||||
tabIndex={0}
|
{logo}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
|
{!collapsed && (
|
||||||
>
|
<div>
|
||||||
{item.icon}
|
<span className={styles.brand}>{title}</span>
|
||||||
<div className={styles.starredItemInfo}>
|
{version && <span className={styles.version}>{version}</span>}
|
||||||
<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>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
function SidebarSection({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: SidebarSectionProps) {
|
||||||
|
const { collapsed, onCollapseToggle } = useSidebarContext()
|
||||||
|
|
||||||
export function Sidebar({ apps, className, onNavigate }: SidebarProps) {
|
// In icon-rail (collapsed) mode, render a centered icon with tooltip
|
||||||
const [search, setSearch] = useState('')
|
if (collapsed) {
|
||||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
return (
|
||||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
<div
|
||||||
const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')
|
className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
|
||||||
|
title={label}
|
||||||
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
|
onClick={() => {
|
||||||
_setAppsCollapsed((prev) => {
|
// Expand sidebar and open the section
|
||||||
const next = updater(prev)
|
onCollapseToggle?.()
|
||||||
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
|
if (!open) onToggle()
|
||||||
return next
|
}}
|
||||||
})
|
role="button"
|
||||||
}
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
_setAgentsCollapsed((prev) => {
|
onCollapseToggle?.()
|
||||||
const next = updater(prev)
|
if (!open) onToggle()
|
||||||
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
|
}
|
||||||
return next
|
}}
|
||||||
})
|
>
|
||||||
}
|
<span className={styles.sectionIcon}>{icon}</span>
|
||||||
|
</div>
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
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 (
|
return (
|
||||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
|
||||||
{/* Logo */}
|
<div className={styles.treeSectionToggle}>
|
||||||
<div className={styles.logo} onClick={() => nav('/apps')} style={{ cursor: 'pointer' }}>
|
<button
|
||||||
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
className={styles.treeSectionChevronBtn}
|
||||||
<div>
|
onClick={onToggle}
|
||||||
<span className={styles.brand}>cameleer</span>
|
aria-expanded={open}
|
||||||
<span className={styles.version}>v3.2.1</span>
|
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
|
||||||
</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') }}
|
|
||||||
>
|
>
|
||||||
<span className={styles.bottomIcon}><Settings size={14} /></span>
|
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
<div className={styles.itemInfo}>
|
</button>
|
||||||
<div className={styles.itemName}>Admin</div>
|
<span className={styles.treeSectionLabel}>{label}</span>
|
||||||
</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>
|
|
||||||
</div>
|
</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