dashboard-sidebar.tsx304 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 { UserMenuButton } from '../../components/user-menu-button'; |
| 8 | import { ChevronRightIcon, type ChevronRightIconHandle } from '../../components/ui/chevron-right'; |
| 9 | import { CogIcon, type CogIconHandle } from '../../components/ui/cog'; |
| 10 | import { CreditCardIcon, type CreditCardIconHandle } from '../../components/ui/credit-card'; |
| 11 | import { DatabaseIcon, type DatabaseIconHandle } from '../../components/ui/database'; |
| 12 | import { FoldersIcon, type FoldersIconHandle } from '../../components/ui/folders'; |
| 13 | import { LayoutGridIcon, type LayoutGridIconHandle } from '../../components/ui/layout-grid'; |
| 14 | import { ShieldCheckIcon, type ShieldCheckIconHandle } from '../../components/ui/shield-check'; |
| 15 | import { UsersIcon, type UsersIconHandle } from '../../components/ui/users'; |
| 16 | |
| 17 | const STORAGE_KEY = 'briven.sidebar.collapsed'; |
| 18 | |
| 19 | /** Auth section accent — used only for icon + label when that row is active. */ |
| 20 | const AUTH_ACCENT = '#FFFD74'; |
| 21 | |
| 22 | interface 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 | |
| 41 | const 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 | |
| 92 | type IconHandle = |
| 93 | | FoldersIconHandle |
| 94 | | CogIconHandle |
| 95 | | ShieldCheckIconHandle |
| 96 | | CreditCardIconHandle |
| 97 | | DatabaseIconHandle |
| 98 | | UsersIconHandle |
| 99 | | LayoutGridIconHandle; |
| 100 | |
| 101 | interface SidebarUser { |
| 102 | name: string | null; |
| 103 | email: string; |
| 104 | image: string | null; |
| 105 | legalName: string | null; |
| 106 | } |
| 107 | |
| 108 | export 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 | |
| 235 | function 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 | } |