2026-05-07 20:15:30 -04:00
|
|
|
import { useState } from 'react'
|
|
|
|
|
import { NavLink } from 'react-router-dom'
|
2026-05-13 18:10:38 -04:00
|
|
|
import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X, Wifi, WifiOff, Loader } from 'lucide-react'
|
|
|
|
|
import { useSSEContext } from '../contexts/SSEContext'
|
|
|
|
|
import type { SSEStatus } from '../hooks/useSSE'
|
2026-05-07 20:15:30 -04:00
|
|
|
|
|
|
|
|
const navItems = [
|
|
|
|
|
{ to: '/', icon: Command, label: 'Hub' },
|
|
|
|
|
{ to: '/logs', icon: Activity, label: 'Logs' },
|
|
|
|
|
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
|
|
|
|
{ to: '/sessions', icon: Monitor, label: 'Sessions' },
|
|
|
|
|
{ to: '/settings', icon: Settings, label: 'Settings' },
|
|
|
|
|
]
|
|
|
|
|
|
2026-05-13 18:10:38 -04:00
|
|
|
/** Small status pill shown in the sidebar footer and mobile header. */
|
|
|
|
|
function SSEStatusBadge({ status, showLabel = false }: { status: SSEStatus; showLabel?: boolean }) {
|
|
|
|
|
const cfg = {
|
|
|
|
|
connected: { icon: Wifi, color: 'text-green-500', label: 'Live' },
|
|
|
|
|
connecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Connecting' },
|
|
|
|
|
reconnecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Reconnecting' },
|
|
|
|
|
error: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' },
|
|
|
|
|
}[status]
|
|
|
|
|
|
|
|
|
|
const Icon = cfg.icon
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-1.5" title={cfg.label}>
|
|
|
|
|
<Icon size={14} className={cfg.color} />
|
|
|
|
|
{showLabel && <span className={`text-xs ${cfg.color}`}>{cfg.label}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 20:15:30 -04:00
|
|
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
|
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
|
const [mobileOpen, setMobileOpen] = useState(false)
|
2026-05-13 18:10:38 -04:00
|
|
|
const { sseStatus } = useSSEContext()
|
2026-05-07 20:15:30 -04:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex min-h-screen bg-surface-darkest text-on-surface">
|
|
|
|
|
{/* Desktop Nav Rail */}
|
|
|
|
|
<aside
|
|
|
|
|
className={`hidden md:flex flex-col border-r border-surface-light transition-all duration-200 ${
|
|
|
|
|
expanded ? 'w-64' : 'w-18'
|
|
|
|
|
}`}
|
|
|
|
|
onMouseEnter={() => setExpanded(true)}
|
|
|
|
|
onMouseLeave={() => setExpanded(false)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-3 px-4 h-16 border-b border-surface-light">
|
|
|
|
|
<Command size={24} className="text-primary shrink-0" />
|
|
|
|
|
{expanded && <span className="font-bold text-lg whitespace-nowrap">Control Center</span>}
|
|
|
|
|
</div>
|
|
|
|
|
<nav className="flex-1 py-4 space-y-1">
|
|
|
|
|
{navItems.map((item) => (
|
|
|
|
|
<NavLink
|
|
|
|
|
key={item.to}
|
|
|
|
|
to={item.to}
|
|
|
|
|
className={({ isActive }) =>
|
|
|
|
|
`flex items-center gap-3 px-4 py-3 mx-2 rounded-lg transition-colors ${
|
|
|
|
|
isActive
|
|
|
|
|
? 'bg-primary/10 text-primary'
|
|
|
|
|
: 'text-on-surface-variant hover:bg-surface-light hover:text-on-surface'
|
|
|
|
|
}`
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<item.icon size={20} className="shrink-0" />
|
|
|
|
|
{expanded && <span className="whitespace-nowrap">{item.label}</span>}
|
|
|
|
|
</NavLink>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
2026-05-13 18:10:38 -04:00
|
|
|
{/* SSE connection status — footer of sidebar */}
|
|
|
|
|
<div className="px-4 py-3 border-t border-surface-light flex items-center gap-2">
|
|
|
|
|
<SSEStatusBadge status={sseStatus} />
|
|
|
|
|
{expanded && (
|
|
|
|
|
<span className="text-xs text-on-surface-muted whitespace-nowrap">
|
|
|
|
|
{sseStatus === 'connected' ? 'Live updates on' : sseStatus}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-07 20:15:30 -04:00
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
{/* Mobile Header + Bottom Nav */}
|
|
|
|
|
<div className="flex-1 flex flex-col md:ml-0">
|
|
|
|
|
<header className="md:hidden flex items-center justify-between h-16 px-4 border-b border-surface-light bg-surface-dark">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Command size={22} className="text-primary" />
|
|
|
|
|
<span className="font-bold">Control Center</span>
|
2026-05-13 18:10:38 -04:00
|
|
|
<SSEStatusBadge status={sseStatus} />
|
2026-05-07 20:15:30 -04:00
|
|
|
</div>
|
|
|
|
|
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2">
|
|
|
|
|
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
|
|
|
|
|
</button>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{/* Mobile drawer */}
|
|
|
|
|
{mobileOpen && (
|
|
|
|
|
<div className="md:hidden fixed inset-0 z-50 bg-surface-dark/95 backdrop-blur">
|
|
|
|
|
<div className="flex flex-col p-4 space-y-2">
|
|
|
|
|
{navItems.map((item) => (
|
|
|
|
|
<NavLink
|
|
|
|
|
key={item.to}
|
|
|
|
|
to={item.to}
|
|
|
|
|
onClick={() => setMobileOpen(false)}
|
|
|
|
|
className={({ isActive }) =>
|
|
|
|
|
`flex items-center gap-3 px-4 py-3 rounded-lg ${
|
|
|
|
|
isActive
|
|
|
|
|
? 'bg-primary/10 text-primary'
|
|
|
|
|
: 'text-on-surface-variant'
|
|
|
|
|
}`
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<item.icon size={20} />
|
|
|
|
|
{item.label}
|
|
|
|
|
</NavLink>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<main className="flex-1 p-4 md:p-6 overflow-auto">{children}</main>
|
|
|
|
|
|
|
|
|
|
{/* Mobile Bottom Nav */}
|
|
|
|
|
<nav className="md:hidden flex items-center justify-around h-16 border-t border-surface-light bg-surface-dark">
|
|
|
|
|
{navItems.slice(0, 5).map((item) => (
|
|
|
|
|
<NavLink
|
|
|
|
|
key={item.to}
|
|
|
|
|
to={item.to}
|
|
|
|
|
className={({ isActive }) =>
|
|
|
|
|
`flex flex-col items-center gap-1 p-2 text-xs ${
|
|
|
|
|
isActive ? 'text-primary' : 'text-on-surface-variant'
|
|
|
|
|
}`
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<item.icon size={20} />
|
|
|
|
|
<span>{item.label}</span>
|
|
|
|
|
</NavLink>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|