web/app/components/ui/navbar.tsxblame
View source
0b4b5821"use client";
0b4b5822
10621c53import { useState, useRef, useCallback, useEffect } from "react";
0b4b5824import Link from "next/link";
0b4b5825import { useAuth } from "@/lib/auth";
0b4b5826
0ed995b7function UserPill({
0ed995b8 user,
0ed995b9 logout,
0ed995b10 menuItems,
0ed995b11}: {
0ed995b12 user: { username: string };
0ed995b13 logout: () => void;
0ed995b14 menuItems?: React.ReactNode;
0ed995b15}) {
0ed995b16 const [expanded, setExpanded] = useState(false);
0ed995b17 const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
0ed995b18
0ed995b19 const handleEnter = useCallback(() => {
0ed995b20 if (collapseTimer.current) clearTimeout(collapseTimer.current);
0ed995b21 setExpanded(true);
0ed995b22 }, []);
0ed995b23
0ed995b24 const handleLeave = useCallback(() => {
0ed995b25 collapseTimer.current = setTimeout(() => setExpanded(false), 200);
0ed995b26 }, []);
0ed995b27
0ed995b28 return (
0ed995b29 <div
0ed995b30 onMouseEnter={handleEnter}
0ed995b31 onMouseLeave={handleLeave}
0ed995b32 style={{ position: "relative", zIndex: 50 }}
0ed995b33 >
0ed995b34 {/* Invisible spacer — reserves pill size in navbar flow */}
0ed995b35 <div
0ed995b36 className="flex items-center gap-2"
0ed995b37 style={{
0ed995b38 padding: "5px 12px 5px 8px",
0ed995b39 border: "1px solid transparent",
0ed995b40 visibility: "hidden",
0ed995b41 fontSize: "0.75rem",
0ed995b42 fontWeight: 500,
0ed995b43 letterSpacing: "0.02em",
0ed995b44 lineHeight: 1,
0ed995b45 }}
0ed995b46 >
0ed995b47 <span style={{ width: "1.35rem", height: "1.35rem", flexShrink: 0 }} />
0ed995b48 {user.username}
0ed995b49 </div>
0ed995b50
0ed995b51 {/* Expanding border container — positioned over the spacer */}
0ed995b52 <div
0ed995b53 style={{
0ed995b54 position: "absolute",
0ed995b55 top: 0,
0ed995b56 right: 0,
0ed995b57 minWidth: "100%",
0ed995b58 border: "1px solid var(--accent)",
0ed995b59 borderRadius: "16px",
0ed995b60 backgroundColor: "var(--bg-page)",
0ed995b61 overflow: "hidden",
0ed995b62 }}
0ed995b63 >
0ed995b64 {/* Pill trigger row */}
0ed995b65 <div
0ed995b66 className="flex items-center gap-2"
10621c567 onClick={() => setExpanded((v) => !v)}
0ed995b68 style={{
0ed995b69 padding: "5px 12px 5px 8px",
0ed995b70 color: "var(--accent)",
0ed995b71 fontSize: "0.75rem",
0ed995b72 fontWeight: 500,
0ed995b73 cursor: "pointer",
0ed995b74 letterSpacing: "0.02em",
0ed995b75 lineHeight: 1,
0ed995b76 }}
0ed995b77 >
0ed995b78 <span
0ed995b79 style={{
0ed995b80 width: "1.35rem",
0ed995b81 height: "1.35rem",
0ed995b82 borderRadius: "9999px",
0ed995b83 backgroundColor: "var(--accent)",
0ed995b84 color: "var(--accent-text, #fff)",
0ed995b85 display: "flex",
0ed995b86 alignItems: "center",
0ed995b87 justifyContent: "center",
0ed995b88 fontSize: "0.65rem",
0ed995b89 fontWeight: 600,
0ed995b90 flexShrink: 0,
0ed995b91 }}
0ed995b92 >
0ed995b93 {user.username.charAt(0).toUpperCase()}
0ed995b94 </span>
0ed995b95 {user.username}
0ed995b96 </div>
0ed995b97
0ed995b98 {/* Menu items — expand below the pill row */}
0ed995b99 <div
0ed995b100 style={{
0ed995b101 display: "grid",
0ed995b102 gridTemplateRows: expanded ? "1fr" : "0fr",
0ed995b103 transition: "grid-template-rows 0.2s ease",
0ed995b104 }}
0ed995b105 >
0ed995b106 <div style={{ overflow: "hidden" }}>
0ed995b107 <div
0ed995b108 style={{
0ed995b109 borderTop: "1px solid var(--accent)",
0ed995b110 opacity: expanded ? 1 : 0,
0ed995b111 transition: "opacity 0.15s ease",
0ed995b112 }}
0ed995b113 >
0ed995b114 <div className="py-1">
0ed995b115 {menuItems}
0ed995b116 <button
0ed995b117 className="w-full text-left text-sm px-3 py-1.5 hover-row"
0ed995b118 style={{
0ed995b119 background: "none",
0ed995b120 border: "none",
0ed995b121 color: "var(--text-primary)",
0ed995b122 cursor: "pointer",
0ed995b123 font: "inherit",
0ed995b124 fontSize: "inherit",
0ed995b125 }}
0ed995b126 onClick={() => { logout(); window.location.href = "/"; }}
0ed995b127 >
0ed995b128 Sign out
0ed995b129 </button>
0ed995b130 </div>
0ed995b131 </div>
0ed995b132 </div>
0ed995b133 </div>
0ed995b134 </div>
0ed995b135 </div>
0ed995b136 );
0ed995b137}
0ed995b138
10621c5139export interface AppSwitcherItem {
10621c5140 name: string;
10621c5141 logo: React.ReactNode;
10621c5142 href: string;
10621c5143}
10621c5144
10621c5145function AppSwitcher({
10621c5146 logo,
10621c5147 productName,
10621c5148 showProductName,
10621c5149 items,
10621c5150}: {
10621c5151 logo: React.ReactNode;
10621c5152 productName?: string;
10621c5153 showProductName?: boolean;
10621c5154 items: AppSwitcherItem[];
10621c5155}) {
10621c5156 const [expanded, setExpanded] = useState(false);
10621c5157 const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
10621c5158
10621c5159 const handleEnter = useCallback(() => {
10621c5160 if (collapseTimer.current) clearTimeout(collapseTimer.current);
10621c5161 setExpanded(true);
10621c5162 }, []);
10621c5163
10621c5164 const handleLeave = useCallback(() => {
10621c5165 collapseTimer.current = setTimeout(() => setExpanded(false), 200);
10621c5166 }, []);
10621c5167
10621c5168 const rowClass = "flex items-center gap-2.5 p-[5px] sm:py-[5px] sm:pl-[5px] sm:pr-[12px]";
10621c5169
10621c5170 return (
10621c5171 <div
10621c5172 onMouseEnter={handleEnter}
10621c5173 onMouseLeave={handleLeave}
10621c5174 style={{ position: "relative", zIndex: 50 }}
10621c5175 className="shrink-0"
10621c5176 >
10621c5177 {/* Invisible spacer — reserves trigger size in navbar flow */}
10621c5178 <div
10621c5179 className={rowClass}
10621c5180 style={{
10621c5181 border: "1px solid transparent",
10621c5182 visibility: "hidden",
10621c5183 whiteSpace: "nowrap",
10621c5184 }}
10621c5185 >
10621c5186 <span style={{ width: 28, height: 28, flexShrink: 0 }} />
10621c5187 {productName && (
10621c5188 <span className="hidden sm:inline text-sm font-medium">{productName}</span>
10621c5189 )}
10621c5190 </div>
10621c5191
10621c5192 {/* Expanding border container — positioned over the spacer */}
10621c5193 <div
10621c5194 style={{
10621c5195 position: "absolute",
10621c5196 top: 0,
10621c5197 left: 0,
10621c5198 minWidth: "100%",
10621c5199 width: expanded ? "max-content" : undefined,
10621c5200 border: "1px solid var(--border)",
10621c5201 borderRadius: "16px",
10621c5202 backgroundColor: "var(--bg-page)",
10621c5203 overflow: "hidden",
10621c5204 transition: "width 0.2s ease",
10621c5205 }}
10621c5206 >
953e989207 {/* Current app trigger row — links to app home */}
953e989208 <a
953e989209 href="/"
c7b7f92210 className={`${rowClass} hover-row`}
953e989211 style={{ cursor: "pointer", whiteSpace: "nowrap", textDecoration: "none" }}
10621c5212 >
10621c5213 <span className="shrink-0" style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center" }}>
10621c5214 {logo}
10621c5215 </span>
10621c5216 {productName && (
10621c5217 <span
10621c5218 className="hidden sm:inline text-sm font-medium"
10621c5219 style={{ color: "var(--text-primary)" }}
10621c5220 >
10621c5221 {productName}
10621c5222 </span>
10621c5223 )}
953e989224 </a>
10621c5225
10621c5226 {/* Other apps — expand below */}
10621c5227 <div
10621c5228 style={{
10621c5229 display: "grid",
10621c5230 gridTemplateRows: expanded ? "1fr" : "0fr",
10621c5231 transition: "grid-template-rows 0.2s ease",
10621c5232 }}
10621c5233 >
10621c5234 <div style={{ overflow: "hidden" }}>
10621c5235 <div
10621c5236 style={{
10621c5237 borderTop: "1px solid var(--border)",
10621c5238 opacity: expanded ? 1 : 0,
10621c5239 transition: "opacity 0.15s ease",
10621c5240 }}
10621c5241 >
10621c5242 {items.map((item) => (
10621c5243 <a
10621c5244 key={item.name}
10621c5245 href={item.href}
10621c5246 className={`${rowClass} hover-row`}
10621c5247 style={{
10621c5248 color: "var(--text-primary)",
10621c5249 textDecoration: "none",
10621c5250 fontSize: "0.8125rem",
10621c5251 whiteSpace: "nowrap",
10621c5252 }}
10621c5253 onClick={() => setExpanded(false)}
10621c5254 >
10621c5255 <span className="shrink-0" style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center" }}>
10621c5256 {item.logo}
10621c5257 </span>
10621c5258 <span className="hidden sm:inline">{item.name}</span>
10621c5259 </a>
10621c5260 ))}
10621c5261 </div>
10621c5262 </div>
10621c5263 </div>
10621c5264 </div>
10621c5265 </div>
10621c5266 );
10621c5267}
10621c5268
0b4b582269export interface NavBarProps {
0b4b582270 /** Logo element (e.g. <GroveLogo size={28} />) */
0b4b582271 logo: React.ReactNode;
0b4b582272 /** Product name shown next to logo when no breadcrumbs are present */
0b4b582273 productName?: string;
0b4b582274 /** Whether to show the product name (controls hiding when breadcrumbs are active) */
0b4b582275 showProductName?: boolean;
0b4b582276 /** Breadcrumb segments rendered after the logo */
0b4b582277 breadcrumbs?: React.ReactNode;
0b4b582278 /** Actions rendered to the left of the user menu (e.g. buttons, presence) */
0b4b582279 actions?: React.ReactNode;
0b4b582280 /** Content rendered below the main bar (e.g. tab bar) */
0b4b582281 belowBar?: React.ReactNode;
0b4b582282 /** Extra items for the user dropdown, rendered before Sign out */
0b4b582283 menuItems?: React.ReactNode;
10621c5284 /** App switcher items shown when clicking the logo */
10621c5285 appSwitcherItems?: AppSwitcherItem[];
0b4b582286 /** Extra items for the mobile side-panel user section (rendered as links, not DropdownItems) */
0b4b582287 mobileMenuItems?: React.ReactNode;
0b4b582288 /** Mobile menu content (if provided, shows hamburger on mobile and hides user pill on mobile) */
0b4b582289 mobileMenu?: React.ReactNode;
0b4b582290}
0b4b582291
0b4b582292export function NavBar({
0b4b582293 logo,
0b4b582294 productName,
0b4b582295 showProductName,
0b4b582296 breadcrumbs,
0b4b582297 actions,
0b4b582298 belowBar,
0b4b582299 menuItems,
10621c5300 appSwitcherItems,
0b4b582301 mobileMenuItems,
0b4b582302 mobileMenu,
0b4b582303}: NavBarProps) {
0b4b582304 const { user, loading, logout } = useAuth();
0b4b582305 const [menuOpen, setMenuOpen] = useState(false);
0b4b582306
0b4b582307 return (
10621c5308 <nav style={{ borderBottom: "1px solid var(--border)", backgroundColor: "var(--bg-page)", position: "relative", zIndex: 50 }}>
0b4b582309 <div className="px-3 sm:px-6 h-14 flex items-center justify-between">
0b4b582310 <div className="flex items-center gap-1.5 sm:gap-3 text-sm min-w-0">
0b4b582311 {mobileMenu && (
0b4b582312 <button
0b4b582313 onClick={() => setMenuOpen(!menuOpen)}
0b4b582314 className="sm:hidden btn-reset flex items-center justify-center shrink-0"
0b4b582315 style={{ color: "var(--text-muted)", width: "2rem", height: "2rem" }}
0b4b582316 aria-label="Menu"
0b4b582317 >
0b4b582318 {menuOpen ? <CloseIcon /> : <HamburgerIcon />}
0b4b582319 </button>
0b4b582320 )}
10621c5321 {appSwitcherItems && appSwitcherItems.length > 0 ? (
10621c5322 <AppSwitcher
10621c5323 logo={logo}
10621c5324 productName={productName}
10621c5325 showProductName={showProductName}
10621c5326 items={appSwitcherItems}
10621c5327 />
10621c5328 ) : (
10621c5329 <Link href="/" className="shrink-0 flex items-center gap-2">
10621c5330 {logo}
10621c5331 {productName && (showProductName ?? true) && (
10621c5332 <span
10621c5333 className="text-lg font-medium"
10621c5334 style={{ color: "var(--text-primary)", marginLeft: "0.25rem" }}
10621c5335 >
10621c5336 {productName}
10621c5337 </span>
10621c5338 )}
10621c5339 </Link>
10621c5340 )}
0b4b582341 {breadcrumbs}
0b4b582342 </div>
0b4b582343 <div className={`${mobileMenu ? "hidden sm:flex" : "flex"} items-center gap-2 text-sm shrink-0`}>
0b4b582344 {actions}
0b4b582345 {!loading && (
0b4b582346 user ? (
0ed995b347 <UserPill user={user} logout={logout} menuItems={menuItems} />
0b4b582348 ) : (
0b4b582349 <a
6dd74de350 href={`/login?redirect=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "/")}`}
0b4b582351 className="hover:underline"
0b4b582352 style={{ color: "var(--text-muted)" }}
0b4b582353 >
0b4b582354 Sign in
0b4b582355 </a>
0b4b582356 )
0b4b582357 )}
0b4b582358 </div>
0b4b582359 </div>
0b4b582360 {belowBar}
0b4b582361
0b4b582362 {/* Mobile side menu */}
0b4b582363 {mobileMenu && menuOpen && (
0b4b582364 <>
0b4b582365 <div
0b4b582366 className="sm:hidden fixed inset-0"
0b4b582367 style={{ backgroundColor: "rgba(0,0,0,0.3)", zIndex: 49 }}
0b4b582368 onClick={() => setMenuOpen(false)}
0b4b582369 />
0b4b582370 <div
0b4b582371 className="sm:hidden fixed top-0 left-0 h-full flex flex-col"
0b4b582372 style={{
0b4b582373 width: "16rem",
0b4b582374 backgroundColor: "var(--bg-page)",
0b4b582375 borderRight: "1px solid var(--border)",
0b4b582376 zIndex: 50,
0b4b582377 }}
0b4b582378 >
0b4b582379 <div className="flex items-center justify-between px-3 h-14">
0b4b582380 <Link href="/" className="flex items-center gap-2" onClick={() => setMenuOpen(false)}>
0b4b582381 {logo}
0b4b582382 </Link>
0b4b582383 <button
0b4b582384 onClick={() => setMenuOpen(false)}
0b4b582385 className="btn-reset flex items-center justify-center"
0b4b582386 style={{ color: "var(--text-muted)", width: "2rem", height: "2rem" }}
0b4b582387 aria-label="Close menu"
0b4b582388 >
0b4b582389 <CloseIcon />
0b4b582390 </button>
0b4b582391 </div>
0b4b582392 {mobileMenu}
0b4b582393 <div className="flex flex-col py-2 text-sm">
0b4b582394 {!loading && (
0b4b582395 user ? (
0b4b582396 <>
0b4b582397 <div
0b4b582398 className="px-4 py-2.5"
0b4b582399 style={{ color: "var(--text-faint)", fontSize: "0.75rem", borderBottom: "1px solid var(--divide)" }}
0b4b582400 >
0b4b582401 {user.username}
0b4b582402 </div>
0b4b582403 {mobileMenuItems}
0b4b582404 <a
0b4b582405 href="#"
0b4b582406 className="px-4 py-2.5 hover-row"
0b4b582407 style={{ color: "var(--text-muted)", textDecoration: "none" }}
0b4b582408 onClick={(e) => { e.preventDefault(); logout(); window.location.href = "/"; }}
0b4b582409 >
0b4b582410 Sign out
0b4b582411 </a>
0b4b582412 </>
0b4b582413 ) : (
0b4b582414 <a
6dd74de415 href={`/login?redirect=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "/")}`}
0b4b582416 className="px-4 py-2.5 hover-row"
0b4b582417 style={{ color: "var(--text-muted)", textDecoration: "none" }}
0b4b582418 >
0b4b582419 Sign in
0b4b582420 </a>
0b4b582421 )
0b4b582422 )}
0b4b582423 </div>
0b4b582424 </div>
0b4b582425 </>
0b4b582426 )}
0b4b582427 </nav>
0b4b582428 );
0b4b582429}
10621c5430
10621c5431function HamburgerIcon() {
10621c5432 return (
10621c5433 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
10621c5434 <path d="M3 5h12M3 9h12M3 13h12" />
10621c5435 </svg>
10621c5436 );
10621c5437}
10621c5438
10621c5439function CloseIcon() {
10621c5440 return (
10621c5441 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
10621c5442 <path d="M4 4l10 10M14 4L4 14" />
10621c5443 </svg>
10621c5444 );
10621c5445}