cockpit-nav.tsx261 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import Link from 'next/link'; |
| 4 | import { usePathname } from 'next/navigation'; |
| 5 | import { useEffect, useRef, useState, type ReactNode } from 'react'; |
| 6 | |
| 7 | import { ActivityIcon } from '@/components/ui/activity'; |
| 8 | import { ArrowLeftRightIcon } from '@/components/ui/arrow-left-right'; |
| 9 | import { BotIcon } from '@/components/ui/bot'; |
| 10 | import { CogIcon } from '@/components/ui/cog'; |
| 11 | import { CreditCardIcon } from '@/components/ui/credit-card'; |
| 12 | import { DatabaseIcon } from '@/components/ui/database'; |
| 13 | import { FoldersIcon } from '@/components/ui/folders'; |
| 14 | import { GlobeIcon } from '@/components/ui/globe'; |
| 15 | import { LayoutGridIcon } from '@/components/ui/layout-grid'; |
| 16 | import { LifeBuoyIcon } from '@/components/ui/life-buoy'; |
| 17 | import { MailIcon } from '@/components/ui/mail'; |
| 18 | import { RocketIcon } from '@/components/ui/rocket'; |
| 19 | import { ShieldCheckIcon } from '@/components/ui/shield-check'; |
| 20 | import { TriangleAlertIcon } from '@/components/ui/triangle-alert'; |
| 21 | import { UsersIcon } from '@/components/ui/users'; |
| 22 | import { ZapIcon } from '@/components/ui/zap'; |
| 23 | |
| 24 | interface IconHandle { |
| 25 | startAnimation: () => void; |
| 26 | stopAnimation: () => void; |
| 27 | } |
| 28 | |
| 29 | interface NavItem { |
| 30 | href: string; |
| 31 | label: string; |
| 32 | Icon: (props: { |
| 33 | className?: string; |
| 34 | size?: number; |
| 35 | ref?: unknown; |
| 36 | onMouseEnter?: (e: React.MouseEvent) => void; |
| 37 | onMouseLeave?: (e: React.MouseEvent) => void; |
| 38 | }) => ReactNode; |
| 39 | // Exact match by default; prefix-match for sections with sub-routes. |
| 40 | match: (pathname: string) => boolean; |
| 41 | } |
| 42 | |
| 43 | interface NavGroup { |
| 44 | // Small uppercase header above the group; null for the top (overview) group. |
| 45 | title: string | null; |
| 46 | items: NavItem[]; |
| 47 | } |
| 48 | |
| 49 | // Grouped nav. Order matters — groups render top-to-bottom, items in-order. |
| 50 | const NAV_GROUPS: NavGroup[] = [ |
| 51 | { |
| 52 | title: null, |
| 53 | items: [ |
| 54 | { href: '/admin', label: 'overview', Icon: LayoutGridIcon as never, match: (p) => p === '/admin' }, |
| 55 | ], |
| 56 | }, |
| 57 | { |
| 58 | title: 'money', |
| 59 | items: [ |
| 60 | { |
| 61 | href: '/admin/billing', |
| 62 | label: 'subscribers & billing', |
| 63 | Icon: CreditCardIcon as never, |
| 64 | match: (p) => p.startsWith('/admin/billing'), |
| 65 | }, |
| 66 | { |
| 67 | href: '/admin/seo', |
| 68 | label: 'sign-ups · geo (seo)', |
| 69 | Icon: GlobeIcon as never, |
| 70 | match: (p) => p.startsWith('/admin/seo'), |
| 71 | }, |
| 72 | ], |
| 73 | }, |
| 74 | { |
| 75 | title: 'customers', |
| 76 | items: [ |
| 77 | { |
| 78 | href: '/admin/users', |
| 79 | label: 'users', |
| 80 | Icon: UsersIcon as never, |
| 81 | match: (p) => p.startsWith('/admin/users'), |
| 82 | }, |
| 83 | { |
| 84 | href: '/admin/projects', |
| 85 | label: 'projects', |
| 86 | Icon: FoldersIcon as never, |
| 87 | match: (p) => p.startsWith('/admin/projects'), |
| 88 | }, |
| 89 | ], |
| 90 | }, |
| 91 | { |
| 92 | title: 'agents', |
| 93 | items: [ |
| 94 | { |
| 95 | href: '/admin/mcp', |
| 96 | label: 'mcp / agent access', |
| 97 | Icon: BotIcon as never, |
| 98 | match: (p) => p.startsWith('/admin/mcp'), |
| 99 | }, |
| 100 | { |
| 101 | href: '/admin/agents', |
| 102 | label: 'ai agents', |
| 103 | Icon: BotIcon as never, |
| 104 | match: (p) => p.startsWith('/admin/agents'), |
| 105 | }, |
| 106 | ], |
| 107 | }, |
| 108 | { |
| 109 | title: 'platform', |
| 110 | items: [ |
| 111 | { |
| 112 | href: '/admin/health', |
| 113 | label: 'platform health', |
| 114 | Icon: ActivityIcon as never, |
| 115 | match: (p) => p.startsWith('/admin/health'), |
| 116 | }, |
| 117 | { |
| 118 | href: '/admin/realtime', |
| 119 | label: 'realtime', |
| 120 | Icon: ZapIcon as never, |
| 121 | match: (p) => p.startsWith('/admin/realtime'), |
| 122 | }, |
| 123 | { |
| 124 | href: '/admin/storage', |
| 125 | label: 'storage', |
| 126 | Icon: DatabaseIcon as never, |
| 127 | match: (p) => p.startsWith('/admin/storage'), |
| 128 | }, |
| 129 | { |
| 130 | href: '/admin/deploys', |
| 131 | label: 'deploys', |
| 132 | Icon: RocketIcon as never, |
| 133 | match: (p) => p.startsWith('/admin/deploys'), |
| 134 | }, |
| 135 | ], |
| 136 | }, |
| 137 | { |
| 138 | title: 'operations', |
| 139 | items: [ |
| 140 | { |
| 141 | href: '/admin/incidents', |
| 142 | label: 'incidents', |
| 143 | Icon: TriangleAlertIcon as never, |
| 144 | match: (p) => p.startsWith('/admin/incidents'), |
| 145 | }, |
| 146 | { |
| 147 | href: '/admin/abuse-reports', |
| 148 | label: 'abuse & allowlist', |
| 149 | Icon: ShieldCheckIcon as never, |
| 150 | match: (p) => p.startsWith('/admin/abuse-reports'), |
| 151 | }, |
| 152 | { |
| 153 | href: '/admin/migrations', |
| 154 | label: 'migrations', |
| 155 | Icon: ArrowLeftRightIcon as never, |
| 156 | match: (p) => p.startsWith('/admin/migrations'), |
| 157 | }, |
| 158 | { |
| 159 | href: '/admin/messages', |
| 160 | label: 'messages', |
| 161 | Icon: MailIcon as never, |
| 162 | match: (p) => p.startsWith('/admin/messages'), |
| 163 | }, |
| 164 | { |
| 165 | href: '/admin/tickets', |
| 166 | label: 'tickets', |
| 167 | Icon: LifeBuoyIcon as never, |
| 168 | match: (p) => p.startsWith('/admin/tickets'), |
| 169 | }, |
| 170 | { |
| 171 | href: '/admin/email-events', |
| 172 | label: 'email log', |
| 173 | Icon: MailIcon as never, |
| 174 | match: (p) => p.startsWith('/admin/email-events'), |
| 175 | }, |
| 176 | ], |
| 177 | }, |
| 178 | { |
| 179 | title: 'system', |
| 180 | items: [ |
| 181 | { |
| 182 | href: '/admin/launch', |
| 183 | label: 'launch controls', |
| 184 | Icon: ZapIcon as never, |
| 185 | match: (p) => p.startsWith('/admin/launch'), |
| 186 | }, |
| 187 | { |
| 188 | href: '/admin/settings', |
| 189 | label: 'settings', |
| 190 | Icon: CogIcon as never, |
| 191 | match: (p) => p.startsWith('/admin/settings'), |
| 192 | }, |
| 193 | ], |
| 194 | }, |
| 195 | ]; |
| 196 | |
| 197 | export function CockpitNav() { |
| 198 | const pathname = usePathname(); |
| 199 | |
| 200 | return ( |
| 201 | <aside |
| 202 | aria-label="admin sections" |
| 203 | className="hidden h-full w-[200px] shrink-0 overflow-y-auto border-r border-[var(--color-border-subtle)] px-3 py-4 md:block" |
| 204 | > |
| 205 | <ul className="flex flex-col gap-1"> |
| 206 | {NAV_GROUPS.map((group) => ( |
| 207 | <li key={group.title ?? 'overview'} className={group.title ? 'mt-4 first:mt-0' : undefined}> |
| 208 | {group.title ? ( |
| 209 | <span className="block px-3 pb-1 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 210 | {group.title} |
| 211 | </span> |
| 212 | ) : null} |
| 213 | <ul className="flex flex-col gap-1"> |
| 214 | {group.items.map((item) => ( |
| 215 | <NavLink key={item.href} item={item} active={item.match(pathname)} /> |
| 216 | ))} |
| 217 | </ul> |
| 218 | </li> |
| 219 | ))} |
| 220 | </ul> |
| 221 | </aside> |
| 222 | ); |
| 223 | } |
| 224 | |
| 225 | function NavLink({ item, active }: { item: NavItem; active: boolean }) { |
| 226 | const iconRef = useRef<IconHandle>(null); |
| 227 | const [hovering, setHovering] = useState(false); |
| 228 | const { Icon } = item; |
| 229 | |
| 230 | // Drive the icon's imperative animation from the Link's hover state so |
| 231 | // hovering anywhere in the row (not only the icon) triggers it — mirrors |
| 232 | // the dashboard sidebar pattern. |
| 233 | useEffect(() => { |
| 234 | if (!iconRef.current) return; |
| 235 | if (hovering) iconRef.current.startAnimation(); |
| 236 | else iconRef.current.stopAnimation(); |
| 237 | }, [hovering]); |
| 238 | |
| 239 | return ( |
| 240 | <li> |
| 241 | <Link |
| 242 | href={item.href as never} |
| 243 | aria-current={active ? 'page' : undefined} |
| 244 | onMouseEnter={() => setHovering(true)} |
| 245 | onMouseLeave={() => setHovering(false)} |
| 246 | onFocus={() => setHovering(true)} |
| 247 | onBlur={() => setHovering(false)} |
| 248 | className={`flex items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 font-mono text-sm transition-colors ${ |
| 249 | active |
| 250 | ? 'bg-[var(--color-surface)] text-[var(--color-primary)]' |
| 251 | : 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface)] hover:text-[var(--color-primary)]' |
| 252 | }`} |
| 253 | > |
| 254 | <span className="pointer-events-none shrink-0"> |
| 255 | <Icon ref={iconRef as never} size={20} /> |
| 256 | </span> |
| 257 | <span className="truncate">{item.label}</span> |
| 258 | </Link> |
| 259 | </li> |
| 260 | ); |
| 261 | } |