cockpit-nav.tsx261 lines · main
1'use client';
2
3import Link from 'next/link';
4import { usePathname } from 'next/navigation';
5import { useEffect, useRef, useState, type ReactNode } from 'react';
6
7import { ActivityIcon } from '@/components/ui/activity';
8import { ArrowLeftRightIcon } from '@/components/ui/arrow-left-right';
9import { BotIcon } from '@/components/ui/bot';
10import { CogIcon } from '@/components/ui/cog';
11import { CreditCardIcon } from '@/components/ui/credit-card';
12import { DatabaseIcon } from '@/components/ui/database';
13import { FoldersIcon } from '@/components/ui/folders';
14import { GlobeIcon } from '@/components/ui/globe';
15import { LayoutGridIcon } from '@/components/ui/layout-grid';
16import { LifeBuoyIcon } from '@/components/ui/life-buoy';
17import { MailIcon } from '@/components/ui/mail';
18import { RocketIcon } from '@/components/ui/rocket';
19import { ShieldCheckIcon } from '@/components/ui/shield-check';
20import { TriangleAlertIcon } from '@/components/ui/triangle-alert';
21import { UsersIcon } from '@/components/ui/users';
22import { ZapIcon } from '@/components/ui/zap';
23
24interface IconHandle {
25 startAnimation: () => void;
26 stopAnimation: () => void;
27}
28
29interface 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
43interface 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.
50const 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
197export 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
225function 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}