diff --git a/src/layout/LayoutShell.tsx b/src/layout/LayoutShell.tsx new file mode 100644 index 0000000..812a970 --- /dev/null +++ b/src/layout/LayoutShell.tsx @@ -0,0 +1,451 @@ +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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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, +): 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: , + 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: , + 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: , + path: `/routes/${app.id}`, + type: 'routestat', + }) + } + + if (starredIds.has(`agent:${app.id}`)) { + items.push({ + starKey: `agent:${app.id}`, + label: app.name, + icon: , + 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 ( +
+
+ {label} +
+ {items.map((item) => ( +
onNavigate(item.path)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) + }} + > + {item.icon && ( + + {item.icon} + + )} + + {item.label} + {item.parentApp && ( + + {item.parentApp} + + )} + + +
+ ))} +
+ ) +} + +// ── 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 = ( + + ) + + return ( + setSidebarCollapsed((c) => !c)} + searchValue={filterQuery} + onSearchChange={setFilterQuery} + > + navigate('/apps')} + /> + + } + open={!appsCollapsed} + onToggle={toggleAppsCollapsed} + active={location.pathname.startsWith('/apps')} + > + + + + } + open={!agentsCollapsed} + onToggle={toggleAgentsCollapsed} + active={location.pathname.startsWith('/agents')} + > + + + + } + open={!routesCollapsed} + onToggle={toggleRoutesCollapsed} + active={location.pathname.startsWith('/routes')} + > + + + + {hasStarred && ( + } + open={true} + onToggle={() => {}} + active={false} + > + + + + + + )} + + + } + label="Admin" + onClick={() => navigate('/admin')} + active={location.pathname.startsWith('/admin')} + /> + } + label="API Docs" + onClick={() => navigate('/api-docs')} + active={location.pathname === '/api-docs'} + /> + + + } + > + + + ) +}