Files
design-system/src/layout/LayoutShell.tsx

452 lines
14 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useMemo, type ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, X } from 'lucide-react'
import { AppShell } from '../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
import { StatusDot } from '../design-system/primitives/StatusDot/StatusDot'
import { SIDEBAR_APPS } from '../mocks/sidebar'
import type { SidebarApp } from '../mocks/sidebar'
import camelLogoUrl from '../assets/camel-logo.svg'
// ── Helpers ─────────────────────────────────────────────────────────────────
function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
// ── Tree node builders ──────────────────────────────────────────────────────
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps.map((app) => ({
id: app.id,
label: app.name,
icon: <StatusDot status={app.health} />,
badge: formatCount(app.exchangeCount),
path: `/apps/${app.id}`,
starrable: true,
starKey: `app:${app.id}`,
children: app.routes.map((route) => ({
id: `${app.id}/${route.id}`,
label: route.name,
icon: <ChevronRight size={12} />,
badge: formatCount(route.exchangeCount),
path: `/apps/${app.id}/${route.id}`,
})),
}))
}
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.routes.length > 0)
.map((app) => ({
id: `routes:${app.id}`,
label: app.name,
icon: <StatusDot status={app.health} />,
badge: `${app.routes.length} route${app.routes.length !== 1 ? 's' : ''}`,
path: `/routes/${app.id}`,
starrable: true,
starKey: `routestat:${app.id}`,
children: app.routes.map((route) => ({
id: `routes:${app.id}/${route.id}`,
label: route.name,
icon: <ChevronRight size={12} />,
badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`,
})),
}))
}
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 status={app.health} />,
badge: `${liveCount}/${app.agents.length} live`,
path: `/agents/${app.id}`,
starrable: true,
starKey: `agent:${app.id}`,
children: app.agents.map((agent) => ({
id: `agents:${app.id}/${agent.id}`,
label: agent.name,
icon: <StatusDot status={agent.status} />,
badge: `${agent.tps} tps`,
path: `/agents/${app.id}/${agent.id}`,
})),
}
})
}
// ── Starred items ───────────────────────────────────────────────────────────
interface StarredItem {
starKey: string
label: string
icon?: ReactNode
path: string
type: 'application' | 'route' | 'agent' | 'routestat'
parentApp?: string
}
function collectStarredItems(
apps: SidebarApp[],
starredIds: Set<string>,
): StarredItem[] {
const items: StarredItem[] = []
for (const app of apps) {
if (starredIds.has(`app:${app.id}`)) {
items.push({
starKey: `app:${app.id}`,
label: app.name,
icon: <Box size={12} />,
path: `/apps/${app.id}`,
type: 'application',
})
}
for (const route of app.routes) {
if (starredIds.has(`route:${app.id}/${route.id}`)) {
items.push({
starKey: `route:${app.id}/${route.id}`,
label: route.name,
icon: <ChevronRight size={12} />,
path: `/apps/${app.id}/${route.id}`,
type: 'route',
parentApp: app.name,
})
}
}
if (starredIds.has(`routestat:${app.id}`)) {
items.push({
starKey: `routestat:${app.id}`,
label: app.name,
icon: <GitBranch size={12} />,
path: `/routes/${app.id}`,
type: 'routestat',
})
}
if (starredIds.has(`agent:${app.id}`)) {
items.push({
starKey: `agent:${app.id}`,
label: app.name,
icon: <Cpu size={12} />,
path: `/agents/${app.id}`,
type: 'agent',
})
}
}
return items
}
// ── Starred group component ─────────────────────────────────────────────────
interface StarredGroupProps {
label: string
items: StarredItem[]
onRemove: (starKey: string) => void
onNavigate: (path: string) => void
}
function StarredGroup({ label, items, onRemove, onNavigate }: StarredGroupProps) {
if (items.length === 0) return null
return (
<div style={{ marginBottom: 8 }}>
<div
style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--text-tertiary)',
padding: '4px 12px',
}}
>
{label}
</div>
{items.map((item) => (
<div
key={item.starKey}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '4px 12px',
cursor: 'pointer',
fontSize: 12,
color: 'var(--text-secondary)',
borderRadius: 4,
}}
onClick={() => onNavigate(item.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path)
}}
>
{item.icon && (
<span style={{ display: 'flex', alignItems: 'center', color: 'var(--text-tertiary)' }}>
{item.icon}
</span>
)}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.label}
{item.parentApp && (
<span style={{ color: 'var(--text-tertiary)', marginLeft: 4, fontSize: 10 }}>
{item.parentApp}
</span>
)}
</span>
<button
style={{
background: 'none',
border: 'none',
padding: 2,
cursor: 'pointer',
color: 'var(--text-tertiary)',
display: 'flex',
alignItems: 'center',
opacity: 0.6,
}}
onClick={(e) => {
e.stopPropagation()
onRemove(item.starKey)
}}
aria-label={`Remove ${item.label} from starred`}
>
<X size={10} />
</button>
</div>
))}
</div>
)
}
// ── localStorage-backed section collapse ────────────────────────────────────
function usePersistedCollapse(key: string, defaultValue: boolean): [boolean, () => void] {
const [value, setValue] = useState(() => {
try {
const raw = localStorage.getItem(key)
if (raw !== null) return raw === 'true'
} catch { /* ignore */ }
return defaultValue
})
const toggle = () => {
setValue((prev) => {
const next = !prev
try {
localStorage.setItem(key, String(next))
} catch { /* ignore */ }
return next
})
}
return [value, toggle]
}
// ── LayoutShell ─────────────────────────────────────────────────────────────
export function LayoutShell() {
const navigate = useNavigate()
const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [filterQuery, setFilterQuery] = useState('')
// Section collapse state — persisted to localStorage
const [appsCollapsed, toggleAppsCollapsed] = usePersistedCollapse('cameleer:sidebar:apps-collapsed', false)
const [agentsCollapsed, toggleAgentsCollapsed] = usePersistedCollapse('cameleer:sidebar:agents-collapsed', false)
const [routesCollapsed, toggleRoutesCollapsed] = usePersistedCollapse('cameleer:sidebar:routes-collapsed', false)
// Tree data — static, so empty deps
const appNodes = useMemo(() => buildAppTreeNodes(SIDEBAR_APPS), [])
const agentNodes = useMemo(() => buildAgentTreeNodes(SIDEBAR_APPS), [])
const routeNodes = useMemo(() => buildRouteTreeNodes(SIDEBAR_APPS), [])
// Sidebar reveal from Cmd-K navigation
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
// Auto-uncollapse matching sections when sidebarRevealPath changes
useEffect(() => {
if (!sidebarRevealPath) return
if (sidebarRevealPath.startsWith('/apps') && appsCollapsed) {
toggleAppsCollapsed()
}
if (sidebarRevealPath.startsWith('/agents') && agentsCollapsed) {
toggleAgentsCollapsed()
}
if (sidebarRevealPath.startsWith('/routes') && routesCollapsed) {
toggleRoutesCollapsed()
}
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname
// Starred items — collected and grouped
const allStarred = useMemo(
() => collectStarredItems(SIDEBAR_APPS, starredIds),
[starredIds],
)
const starredApps = allStarred.filter((s) => s.type === 'application')
const starredRoutes = allStarred.filter((s) => s.type === 'route')
const starredAgents = allStarred.filter((s) => s.type === 'agent')
const starredRouteStats = allStarred.filter((s) => s.type === 'routestat')
const hasStarred = allStarred.length > 0
const camelLogo = (
<img
src={camelLogoUrl}
alt=""
aria-hidden="true"
style={{
width: 28,
height: 24,
filter:
'brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%)',
}}
/>
)
return (
<AppShell
sidebar={
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed((c) => !c)}
searchValue={filterQuery}
onSearchChange={setFilterQuery}
>
<Sidebar.Header
logo={camelLogo}
title="cameleer"
version="v3.2.1"
onClick={() => navigate('/apps')}
/>
<Sidebar.Section
label="Applications"
icon={<Box size={14} />}
open={!appsCollapsed}
onToggle={toggleAppsCollapsed}
active={location.pathname.startsWith('/apps')}
>
<SidebarTree
nodes={appNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
/>
</Sidebar.Section>
<Sidebar.Section
label="Agents"
icon={<Cpu size={14} />}
open={!agentsCollapsed}
onToggle={toggleAgentsCollapsed}
active={location.pathname.startsWith('/agents')}
>
<SidebarTree
nodes={agentNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
/>
</Sidebar.Section>
<Sidebar.Section
label="Routes"
icon={<GitBranch size={14} />}
open={!routesCollapsed}
onToggle={toggleRoutesCollapsed}
active={location.pathname.startsWith('/routes')}
>
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
/>
</Sidebar.Section>
{hasStarred && (
<Sidebar.Section
label="\u2605 Starred"
icon={<span />}
open={true}
onToggle={() => {}}
active={false}
>
<StarredGroup
label="Applications"
items={starredApps}
onRemove={toggleStar}
onNavigate={navigate}
/>
<StarredGroup
label="Routes"
items={starredRoutes}
onRemove={toggleStar}
onNavigate={navigate}
/>
<StarredGroup
label="Route Stats"
items={starredRouteStats}
onRemove={toggleStar}
onNavigate={navigate}
/>
<StarredGroup
label="Agents"
items={starredAgents}
onRemove={toggleStar}
onNavigate={navigate}
/>
</Sidebar.Section>
)}
<Sidebar.Footer>
<Sidebar.FooterLink
icon={<Settings size={14} />}
label="Admin"
onClick={() => navigate('/admin')}
active={location.pathname.startsWith('/admin')}
/>
<Sidebar.FooterLink
icon={<FileText size={14} />}
label="API Docs"
onClick={() => navigate('/api-docs')}
active={location.pathname === '/api-docs'}
/>
</Sidebar.Footer>
</Sidebar>
}
>
<Outlet />
</AppShell>
)
}