dashboard-sidebar.tsx304 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 { UserMenuButton } from '../../components/user-menu-button';
8import { ChevronRightIcon, type ChevronRightIconHandle } from '../../components/ui/chevron-right';
9import { CogIcon, type CogIconHandle } from '../../components/ui/cog';
10import { CreditCardIcon, type CreditCardIconHandle } from '../../components/ui/credit-card';
11import { DatabaseIcon, type DatabaseIconHandle } from '../../components/ui/database';
12import { FoldersIcon, type FoldersIconHandle } from '../../components/ui/folders';
13import { LayoutGridIcon, type LayoutGridIconHandle } from '../../components/ui/layout-grid';
14import { ShieldCheckIcon, type ShieldCheckIconHandle } from '../../components/ui/shield-check';
15import { UsersIcon, type UsersIconHandle } from '../../components/ui/users';
16
17const STORAGE_KEY = 'briven.sidebar.collapsed';
18
19/** Auth section accent — used only for icon + label when that row is active. */
20const AUTH_ACCENT = '#FFFD74';
21
22interface NavItem {
23 href: string;
24 label: string;
25 Icon: (props: {
26 className?: string;
27 size?: number;
28 ref?: unknown;
29 onMouseEnter?: (e: React.MouseEvent) => void;
30 onMouseLeave?: (e: React.MouseEvent) => void;
31 }) => ReactNode;
32 match: (pathname: string) => boolean;
33 adminOnly?: boolean;
34 /**
35 * When active, icon + label use the Auth yellow (same shape as other
36 * rows — no solid pill, no side bar). Idle state matches every other link.
37 */
38 authProduct?: boolean;
39}
40
41const NAV: NavItem[] = [
42 {
43 href: '/dashboard',
44 label: 'overview',
45 Icon: LayoutGridIcon as never,
46 // Exact match — broader prefix would steal the active state from
47 // every nested dashboard route.
48 match: (p) => p === '/dashboard',
49 },
50 {
51 href: '/dashboard/projects',
52 label: 'projects',
53 Icon: FoldersIcon as never,
54 match: (p) => p.startsWith('/dashboard/projects'),
55 },
56 {
57 href: '/dashboard/auth',
58 label: 'Auth',
59 Icon: ShieldCheckIcon as never,
60 match: (p) => p.startsWith('/dashboard/auth'),
61 authProduct: true,
62 },
63 {
64 href: '/dashboard/s3',
65 label: 'S3 bucket',
66 Icon: DatabaseIcon as never,
67 match: (p) => p.startsWith('/dashboard/s3'),
68 },
69 {
70 href: '/dashboard/teams',
71 label: 'teams',
72 Icon: UsersIcon as never,
73 match: (p) => p.startsWith('/dashboard/teams'),
74 },
75 {
76 href: '/dashboard/billing',
77 label: 'billing',
78 Icon: CreditCardIcon as never,
79 match: (p) => p.startsWith('/dashboard/billing'),
80 },
81 {
82 href: '/dashboard/settings',
83 label: 'settings',
84 Icon: CogIcon as never,
85 match: (p) => p.startsWith('/dashboard/settings'),
86 },
87 // No 'admin' nav item here on purpose: the ONE admin entry lives in the
88 // avatar dropdown (UserMenuButton) and points to admin.briven.tech. The
89 // old /dashboard/admin area redirects there too (see proxy.ts).
90];
91
92type IconHandle =
93 | FoldersIconHandle
94 | CogIconHandle
95 | ShieldCheckIconHandle
96 | CreditCardIconHandle
97 | DatabaseIconHandle
98 | UsersIconHandle
99 | LayoutGridIconHandle;
100
101interface SidebarUser {
102 name: string | null;
103 email: string;
104 image: string | null;
105 legalName: string | null;
106}
107
108export function DashboardSidebar({ isAdmin, user }: { isAdmin: boolean; user: SidebarUser }) {
109 const pathname = usePathname();
110 const [collapsed, setCollapsed] = useState(false);
111 const [hydrated, setHydrated] = useState(false);
112
113 useEffect(() => {
114 try {
115 const saved = localStorage.getItem(STORAGE_KEY);
116 if (saved === '1') setCollapsed(true);
117 } catch {
118 // storage blocked — default open
119 }
120 setHydrated(true);
121 }, []);
122
123 function toggle() {
124 setCollapsed((prev) => {
125 const next = !prev;
126 try {
127 localStorage.setItem(STORAGE_KEY, next ? '1' : '0');
128 } catch {
129 // ignore
130 }
131 return next;
132 });
133 }
134
135 const items = NAV.filter((i) => !i.adminOnly || isAdmin);
136 const isCollapsed = hydrated && collapsed;
137 // Collapsed rail is the denser state — icons shrink instead of growing.
138 const iconPixels = isCollapsed ? 22 : 28;
139
140 const toggleRef = useRef<ChevronRightIconHandle>(null);
141 const [toggleHover, setToggleHover] = useState(false);
142
143 useEffect(() => {
144 if (!toggleRef.current) return;
145 if (toggleHover) toggleRef.current.startAnimation();
146 else toggleRef.current.stopAnimation();
147 }, [toggleHover]);
148
149 return (
150 <aside
151 aria-label="dashboard sections"
152 data-collapsed={isCollapsed ? 'true' : 'false'}
153 // hidden on small screens — the rail eats too much of a phone
154 // viewport. mobile nav lives in the top-of-content tabs row + the
155 // user menu in the header. surfaces md (768px) and up.
156 className={`relative hidden h-full shrink-0 flex-col transition-[width] duration-200 ease-out md:flex ${
157 isCollapsed ? 'md:w-[72px]' : 'md:w-[180px]'
158 }`}
159 >
160 <ul className="flex flex-col gap-1">
161 {items.map((item) => {
162 const isAuth = item.authProduct === true;
163 return (
164 <li key={item.href} className="list-none">
165 {/* Separators frame the Auth product row (above + below). */}
166 {isAuth ? (
167 <div
168 aria-hidden
169 className={`my-1 border-t border-[var(--color-border-subtle)] ${
170 isCollapsed ? 'mx-2' : 'mx-3'
171 }`}
172 />
173 ) : null}
174 <SidebarLink
175 item={item}
176 active={item.match(pathname)}
177 iconPixels={iconPixels}
178 collapsed={isCollapsed}
179 authProduct={isAuth}
180 />
181 {isAuth ? (
182 <div
183 aria-hidden
184 className={`my-1 border-t border-[var(--color-border-subtle)] ${
185 isCollapsed ? 'mx-2' : 'mx-3'
186 }`}
187 />
188 ) : null}
189 </li>
190 );
191 })}
192 </ul>
193
194 {/*
195 Bottom stack: when expanded, user button + collapse toggle sit
196 side-by-side so the user button's avatar lines up with the nav
197 icons above (both at px-3 from the sidebar edge). When collapsed,
198 they stack vertically inside the 72px rail.
199 */}
200 <div
201 className={`absolute bottom-3 left-0 right-0 flex ${
202 isCollapsed
203 ? 'flex-col items-center gap-2'
204 : 'flex-row items-center gap-1'
205 }`}
206 >
207 <UserMenuButton user={user} collapsed={isCollapsed} isAdmin={isAdmin} />
208 <button
209 type="button"
210 onClick={toggle}
211 onMouseEnter={() => setToggleHover(true)}
212 onMouseLeave={() => setToggleHover(false)}
213 onFocus={() => setToggleHover(true)}
214 onBlur={() => setToggleHover(false)}
215 aria-label={isCollapsed ? 'expand sidebar' : 'collapse sidebar'}
216 className={`flex shrink-0 items-center justify-center rounded-md border border-[var(--color-border-subtle)] text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border)] hover:text-[var(--color-primary)] ${
217 isCollapsed ? 'size-8' : 'size-6'
218 }`}
219 >
220 <span
221 className="pointer-events-none inline-block"
222 style={{
223 transform: `rotate(${isCollapsed ? 0 : 180}deg)`,
224 transition: 'transform 200ms',
225 }}
226 >
227 <ChevronRightIcon ref={toggleRef} size={isCollapsed ? 18 : 14} />
228 </span>
229 </button>
230 </div>
231 </aside>
232 );
233}
234
235function SidebarLink({
236 item,
237 active,
238 iconPixels,
239 collapsed,
240 authProduct,
241}: {
242 item: NavItem;
243 active: boolean;
244 iconPixels: number;
245 collapsed: boolean;
246 authProduct: boolean;
247}) {
248 const iconRef = useRef<IconHandle>(null);
249 const [hovering, setHovering] = useState(false);
250 const { Icon } = item;
251
252 // Drive the icon's imperative animation from the Link's hover state —
253 // this way hovering ANYWHERE in the Link's box (padding, label, or
254 // icon) triggers the animation, not only when the cursor is on the
255 // icon's inner div.
256 useEffect(() => {
257 if (!iconRef.current) return;
258 if (hovering) iconRef.current.startAnimation();
259 else iconRef.current.stopAnimation();
260 }, [hovering]);
261
262 // Auth product: yellow when active OR hovered (same #FFFD74).
263 // Other rows: green primary when active; green-ish hover via CSS vars.
264 const showAuthYellow = authProduct && (active || hovering);
265
266 return (
267 <Link
268 href={item.href}
269 aria-current={active ? 'page' : undefined}
270 title={collapsed ? item.label : undefined}
271 onMouseEnter={() => setHovering(true)}
272 onMouseLeave={() => setHovering(false)}
273 onFocus={() => setHovering(true)}
274 onBlur={() => setHovering(false)}
275 className={`flex items-center gap-3 rounded-md px-3 py-2 font-mono text-sm transition-colors ${
276 active
277 ? 'bg-[var(--color-surface)]'
278 : authProduct
279 ? 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface)]'
280 : 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface)] hover:text-[var(--color-primary)]'
281 } ${collapsed ? 'justify-center px-0' : ''}`}
282 style={
283 showAuthYellow
284 ? { color: AUTH_ACCENT }
285 : active
286 ? { color: 'var(--color-primary)' }
287 : undefined
288 }
289 >
290 {/* pointer-events-none on the icon so Link hover drives animation. */}
291 <span
292 className="pointer-events-none"
293 style={showAuthYellow ? { color: AUTH_ACCENT } : undefined}
294 >
295 <Icon ref={iconRef as never} size={iconPixels} />
296 </span>
297 {collapsed ? (
298 <span className="sr-only">{item.label}</span>
299 ) : (
300 <span className="truncate">{item.label}</span>
301 )}
302 </Link>
303 );
304}