13.9 KB446 lines
Blame
1"use client";
2
3import { useState, useRef, useCallback, useEffect } from "react";
4import Link from "next/link";
5import { useAuth } from "@/lib/auth";
6
7function UserPill({
8 user,
9 logout,
10 menuItems,
11}: {
12 user: { username: string };
13 logout: () => void;
14 menuItems?: React.ReactNode;
15}) {
16 const [expanded, setExpanded] = useState(false);
17 const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
18
19 const handleEnter = useCallback(() => {
20 if (collapseTimer.current) clearTimeout(collapseTimer.current);
21 setExpanded(true);
22 }, []);
23
24 const handleLeave = useCallback(() => {
25 collapseTimer.current = setTimeout(() => setExpanded(false), 200);
26 }, []);
27
28 return (
29 <div
30 onMouseEnter={handleEnter}
31 onMouseLeave={handleLeave}
32 style={{ position: "relative", zIndex: 50 }}
33 >
34 {/* Invisible spacer — reserves pill size in navbar flow */}
35 <div
36 className="flex items-center gap-2"
37 style={{
38 padding: "5px 12px 5px 8px",
39 border: "1px solid transparent",
40 visibility: "hidden",
41 fontSize: "0.75rem",
42 fontWeight: 500,
43 letterSpacing: "0.02em",
44 lineHeight: 1,
45 }}
46 >
47 <span style={{ width: "1.35rem", height: "1.35rem", flexShrink: 0 }} />
48 {user.username}
49 </div>
50
51 {/* Expanding border container — positioned over the spacer */}
52 <div
53 style={{
54 position: "absolute",
55 top: 0,
56 right: 0,
57 minWidth: "100%",
58 border: "1px solid var(--accent)",
59 borderRadius: "16px",
60 backgroundColor: "var(--bg-page)",
61 overflow: "hidden",
62 }}
63 >
64 {/* Pill trigger row */}
65 <div
66 className="flex items-center gap-2"
67 onClick={() => setExpanded((v) => !v)}
68 style={{
69 padding: "5px 12px 5px 8px",
70 color: "var(--accent)",
71 fontSize: "0.75rem",
72 fontWeight: 500,
73 cursor: "pointer",
74 letterSpacing: "0.02em",
75 lineHeight: 1,
76 }}
77 >
78 <span
79 style={{
80 width: "1.35rem",
81 height: "1.35rem",
82 borderRadius: "9999px",
83 backgroundColor: "var(--accent)",
84 color: "var(--accent-text, #fff)",
85 display: "flex",
86 alignItems: "center",
87 justifyContent: "center",
88 fontSize: "0.65rem",
89 fontWeight: 600,
90 flexShrink: 0,
91 }}
92 >
93 {user.username.charAt(0).toUpperCase()}
94 </span>
95 {user.username}
96 </div>
97
98 {/* Menu items — expand below the pill row */}
99 <div
100 style={{
101 display: "grid",
102 gridTemplateRows: expanded ? "1fr" : "0fr",
103 transition: "grid-template-rows 0.2s ease",
104 }}
105 >
106 <div style={{ overflow: "hidden" }}>
107 <div
108 style={{
109 borderTop: "1px solid var(--accent)",
110 opacity: expanded ? 1 : 0,
111 transition: "opacity 0.15s ease",
112 }}
113 >
114 <div className="py-1">
115 {menuItems}
116 <button
117 className="w-full text-left text-sm px-3 py-1.5 hover-row"
118 style={{
119 background: "none",
120 border: "none",
121 color: "var(--text-primary)",
122 cursor: "pointer",
123 font: "inherit",
124 fontSize: "inherit",
125 }}
126 onClick={() => { logout(); window.location.href = "/"; }}
127 >
128 Sign out
129 </button>
130 </div>
131 </div>
132 </div>
133 </div>
134 </div>
135 </div>
136 );
137}
138
139export interface AppSwitcherItem {
140 name: string;
141 logo: React.ReactNode;
142 href: string;
143}
144
145function AppSwitcher({
146 logo,
147 productName,
148 showProductName,
149 items,
150}: {
151 logo: React.ReactNode;
152 productName?: string;
153 showProductName?: boolean;
154 items: AppSwitcherItem[];
155}) {
156 const [expanded, setExpanded] = useState(false);
157 const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
158
159 const handleEnter = useCallback(() => {
160 if (collapseTimer.current) clearTimeout(collapseTimer.current);
161 setExpanded(true);
162 }, []);
163
164 const handleLeave = useCallback(() => {
165 collapseTimer.current = setTimeout(() => setExpanded(false), 200);
166 }, []);
167
168 const rowClass = "flex items-center gap-2.5 p-[5px] sm:py-[5px] sm:pl-[5px] sm:pr-[12px]";
169
170 return (
171 <div
172 onMouseEnter={handleEnter}
173 onMouseLeave={handleLeave}
174 style={{ position: "relative", zIndex: 50 }}
175 className="shrink-0"
176 >
177 {/* Invisible spacer — reserves trigger size in navbar flow */}
178 <div
179 className={rowClass}
180 style={{
181 border: "1px solid transparent",
182 visibility: "hidden",
183 whiteSpace: "nowrap",
184 }}
185 >
186 <span style={{ width: 28, height: 28, flexShrink: 0 }} />
187 {productName && (
188 <span className="hidden sm:inline text-sm font-medium">{productName}</span>
189 )}
190 </div>
191
192 {/* Expanding border container — positioned over the spacer */}
193 <div
194 style={{
195 position: "absolute",
196 top: 0,
197 left: 0,
198 minWidth: "100%",
199 width: expanded ? "max-content" : undefined,
200 border: "1px solid var(--border)",
201 borderRadius: "16px",
202 backgroundColor: "var(--bg-page)",
203 overflow: "hidden",
204 transition: "width 0.2s ease",
205 }}
206 >
207 {/* Current app trigger row — links to app home */}
208 <a
209 href="/"
210 className={`${rowClass} hover-row`}
211 style={{ cursor: "pointer", whiteSpace: "nowrap", textDecoration: "none" }}
212 >
213 <span className="shrink-0" style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center" }}>
214 {logo}
215 </span>
216 {productName && (
217 <span
218 className="hidden sm:inline text-sm font-medium"
219 style={{ color: "var(--text-primary)" }}
220 >
221 {productName}
222 </span>
223 )}
224 </a>
225
226 {/* Other apps — expand below */}
227 <div
228 style={{
229 display: "grid",
230 gridTemplateRows: expanded ? "1fr" : "0fr",
231 transition: "grid-template-rows 0.2s ease",
232 }}
233 >
234 <div style={{ overflow: "hidden" }}>
235 <div
236 style={{
237 borderTop: "1px solid var(--border)",
238 opacity: expanded ? 1 : 0,
239 transition: "opacity 0.15s ease",
240 }}
241 >
242 {items.map((item) => (
243 <a
244 key={item.name}
245 href={item.href}
246 className={`${rowClass} hover-row`}
247 style={{
248 color: "var(--text-primary)",
249 textDecoration: "none",
250 fontSize: "0.8125rem",
251 whiteSpace: "nowrap",
252 }}
253 onClick={() => setExpanded(false)}
254 >
255 <span className="shrink-0" style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center" }}>
256 {item.logo}
257 </span>
258 <span className="hidden sm:inline">{item.name}</span>
259 </a>
260 ))}
261 </div>
262 </div>
263 </div>
264 </div>
265 </div>
266 );
267}
268
269export interface NavBarProps {
270 /** Logo element (e.g. <GroveLogo size={28} />) */
271 logo: React.ReactNode;
272 /** Product name shown next to logo when no breadcrumbs are present */
273 productName?: string;
274 /** Whether to show the product name (controls hiding when breadcrumbs are active) */
275 showProductName?: boolean;
276 /** Breadcrumb segments rendered after the logo */
277 breadcrumbs?: React.ReactNode;
278 /** Actions rendered to the left of the user menu (e.g. buttons, presence) */
279 actions?: React.ReactNode;
280 /** Content rendered below the main bar (e.g. tab bar) */
281 belowBar?: React.ReactNode;
282 /** Extra items for the user dropdown, rendered before Sign out */
283 menuItems?: React.ReactNode;
284 /** App switcher items shown when clicking the logo */
285 appSwitcherItems?: AppSwitcherItem[];
286 /** Extra items for the mobile side-panel user section (rendered as links, not DropdownItems) */
287 mobileMenuItems?: React.ReactNode;
288 /** Mobile menu content (if provided, shows hamburger on mobile and hides user pill on mobile) */
289 mobileMenu?: React.ReactNode;
290}
291
292export function NavBar({
293 logo,
294 productName,
295 showProductName,
296 breadcrumbs,
297 actions,
298 belowBar,
299 menuItems,
300 appSwitcherItems,
301 mobileMenuItems,
302 mobileMenu,
303}: NavBarProps) {
304 const { user, loading, logout } = useAuth();
305 const [menuOpen, setMenuOpen] = useState(false);
306
307 return (
308 <nav style={{ borderBottom: "1px solid var(--border)", backgroundColor: "var(--bg-page)", position: "relative", zIndex: 50 }}>
309 <div className="px-3 sm:px-6 h-14 flex items-center justify-between">
310 <div className="flex items-center gap-1.5 sm:gap-3 text-sm min-w-0">
311 {mobileMenu && (
312 <button
313 onClick={() => setMenuOpen(!menuOpen)}
314 className="sm:hidden btn-reset flex items-center justify-center shrink-0"
315 style={{ color: "var(--text-muted)", width: "2rem", height: "2rem" }}
316 aria-label="Menu"
317 >
318 {menuOpen ? <CloseIcon /> : <HamburgerIcon />}
319 </button>
320 )}
321 {appSwitcherItems && appSwitcherItems.length > 0 ? (
322 <AppSwitcher
323 logo={logo}
324 productName={productName}
325 showProductName={showProductName}
326 items={appSwitcherItems}
327 />
328 ) : (
329 <Link href="/" className="shrink-0 flex items-center gap-2">
330 {logo}
331 {productName && (showProductName ?? true) && (
332 <span
333 className="text-lg font-medium"
334 style={{ color: "var(--text-primary)", marginLeft: "0.25rem" }}
335 >
336 {productName}
337 </span>
338 )}
339 </Link>
340 )}
341 {breadcrumbs}
342 </div>
343 <div className={`${mobileMenu ? "hidden sm:flex" : "flex"} items-center gap-2 text-sm shrink-0`}>
344 {actions}
345 {!loading && (
346 user ? (
347 <UserPill user={user} logout={logout} menuItems={menuItems} />
348 ) : (
349 <a
350 href={`/login?redirect=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "/")}`}
351 className="hover:underline"
352 style={{ color: "var(--text-muted)" }}
353 >
354 Sign in
355 </a>
356 )
357 )}
358 </div>
359 </div>
360 {belowBar}
361
362 {/* Mobile side menu */}
363 {mobileMenu && menuOpen && (
364 <>
365 <div
366 className="sm:hidden fixed inset-0"
367 style={{ backgroundColor: "rgba(0,0,0,0.3)", zIndex: 49 }}
368 onClick={() => setMenuOpen(false)}
369 />
370 <div
371 className="sm:hidden fixed top-0 left-0 h-full flex flex-col"
372 style={{
373 width: "16rem",
374 backgroundColor: "var(--bg-page)",
375 borderRight: "1px solid var(--border)",
376 zIndex: 50,
377 }}
378 >
379 <div className="flex items-center justify-between px-3 h-14">
380 <Link href="/" className="flex items-center gap-2" onClick={() => setMenuOpen(false)}>
381 {logo}
382 </Link>
383 <button
384 onClick={() => setMenuOpen(false)}
385 className="btn-reset flex items-center justify-center"
386 style={{ color: "var(--text-muted)", width: "2rem", height: "2rem" }}
387 aria-label="Close menu"
388 >
389 <CloseIcon />
390 </button>
391 </div>
392 {mobileMenu}
393 <div className="flex flex-col py-2 text-sm">
394 {!loading && (
395 user ? (
396 <>
397 <div
398 className="px-4 py-2.5"
399 style={{ color: "var(--text-faint)", fontSize: "0.75rem", borderBottom: "1px solid var(--divide)" }}
400 >
401 {user.username}
402 </div>
403 {mobileMenuItems}
404 <a
405 href="#"
406 className="px-4 py-2.5 hover-row"
407 style={{ color: "var(--text-muted)", textDecoration: "none" }}
408 onClick={(e) => { e.preventDefault(); logout(); window.location.href = "/"; }}
409 >
410 Sign out
411 </a>
412 </>
413 ) : (
414 <a
415 href={`/login?redirect=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "/")}`}
416 className="px-4 py-2.5 hover-row"
417 style={{ color: "var(--text-muted)", textDecoration: "none" }}
418 >
419 Sign in
420 </a>
421 )
422 )}
423 </div>
424 </div>
425 </>
426 )}
427 </nav>
428 );
429}
430
431function HamburgerIcon() {
432 return (
433 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
434 <path d="M3 5h12M3 9h12M3 13h12" />
435 </svg>
436 );
437}
438
439function CloseIcon() {
440 return (
441 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
442 <path d="M4 4l10 10M14 4L4 14" />
443 </svg>
444 );
445}
446