@djangocfg/layouts 2.1.249 → 2.1.252

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,48 +8,79 @@
8
8
 
9
9
  import { ChevronDown, Menu, X } from 'lucide-react';
10
10
  import Link from 'next/link';
11
- import React, { useEffect, useMemo, useRef, useState } from 'react';
11
+ import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
12
12
 
13
13
  import { useAuth } from '@djangocfg/api/auth';
14
14
  import { useAppT } from '@djangocfg/i18n';
15
15
  import {
16
16
  Button,
17
17
  } from '@djangocfg/ui-core/components';
18
- // useIsMobile is used for conditional rendering
19
- import { useIsMobile } from '@djangocfg/ui-core/hooks';
18
+ import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
20
19
  // cn is reserved for future conditional styling
21
20
  import { cn as _cn } from '@djangocfg/ui-core/lib';
21
+ import { usePathnameWithoutLocale } from '../../../hooks';
22
22
 
23
23
  import { UserMenu } from '../../_components/UserMenu';
24
24
  import { usePublicLayoutOptional } from '../context';
25
25
 
26
26
  import type { NavigationItem, UserMenuConfig } from '../../types';
27
27
 
28
+ export type PublicNavbarVariant = 'floating' | 'flush';
29
+ export type PublicNavbarPosition = 'sticky' | 'fixed' | 'static';
30
+
31
+ export interface PublicDesktopDropdownRenderProps {
32
+ item: NavigationItem;
33
+ isOpen: boolean;
34
+ isActive: boolean;
35
+ close: () => void;
36
+ defaultPopover: React.ReactNode;
37
+ defaultItems: React.ReactNode;
38
+ }
39
+
40
+ export type PublicDesktopDropdownRenderer = (props: PublicDesktopDropdownRenderProps) => React.ReactNode;
41
+
28
42
  interface PublicNavigationProps {
43
+ /** Custom brand node (full control over logo/text/link) */
44
+ brand?: ReactNode;
45
+ /** Brand link for default brand renderer */
46
+ brandHref?: string;
29
47
  logo?: string;
30
48
  siteName?: string;
31
49
  navigation?: NavigationItem[];
32
50
  userMenu?: UserMenuConfig;
33
51
  containerClassName?: string;
52
+ navbarVariant?: PublicNavbarVariant;
53
+ navbarPosition?: PublicNavbarPosition;
54
+ renderDesktopDropdown?: PublicDesktopDropdownRenderer;
55
+ /** Max visible top-level desktop items before collapsing into "More" */
56
+ desktopMaxPrimaryItems?: number;
34
57
  mobileMenuOpen?: boolean;
35
58
  onMobileMenuToggle?: () => void;
36
59
  }
37
60
 
38
61
  export function PublicNavigation(props: PublicNavigationProps = {}) {
39
62
  const context = usePublicLayoutOptional();
40
- const logo = props.logo ?? context?.logo;
41
- const siteName = props.siteName ?? context?.siteName ?? 'App';
42
- const navigation = props.navigation ?? context?.navigation ?? [];
43
- const userMenu = props.userMenu ?? context?.userMenu;
44
- const containerClassName = props.containerClassName ?? context?.containerClassName;
63
+ const brand = props.brand;
64
+ const brandHref = props.brandHref ?? '/';
65
+ const logo = props.logo;
66
+ const siteName = props.siteName ?? 'App';
67
+ const navigation = props.navigation ?? [];
68
+ const userMenu = props.userMenu;
69
+ const containerClassName = props.containerClassName;
70
+ const navbarVariant = props.navbarVariant ?? 'floating';
71
+ const navbarPosition = props.navbarPosition ?? 'sticky';
72
+ const renderDesktopDropdown = props.renderDesktopDropdown;
73
+ const desktopMaxPrimaryItems = Math.max(1, props.desktopMaxPrimaryItems ?? 7);
45
74
  const mobileMenuOpen = props.mobileMenuOpen ?? context?.mobileMenuOpen ?? false;
46
75
  const toggleMobileMenu = props.onMobileMenuToggle ?? context?.toggleMobileMenu ?? (() => {});
47
76
  const { isAuthenticated: _isAuthenticated } = useAuth();
48
- const isMobile = useIsMobile();
77
+ const isTabletOrBelow = useIsTabletOrBelow();
78
+ const pathname = usePathnameWithoutLocale();
49
79
  const t = useAppT();
50
80
  const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
51
81
  const openTimerRef = useRef<number | null>(null);
52
82
  const closeTimerRef = useRef<number | null>(null);
83
+ const navOuterRef = useRef<HTMLDivElement | null>(null);
53
84
 
54
85
  const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
55
86
  const desktopNavItemClass =
@@ -92,119 +123,250 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
92
123
  };
93
124
  }, []);
94
125
 
95
- const navClass = 'sticky top-3 z-50 px-4 sm:px-6 lg:px-8';
126
+ useEffect(() => {
127
+ if (isTabletOrBelow) {
128
+ setOpenDropdownKey(null);
129
+ clearOpenTimer();
130
+ clearCloseTimer();
131
+ }
132
+ }, [isTabletOrBelow]);
133
+
134
+ useEffect(() => {
135
+ const updateDrawerViewportVars = () => {
136
+ const root = document.documentElement;
137
+ const navEl = navOuterRef.current;
138
+ if (!navEl) return;
139
+
140
+ const rect = navEl.getBoundingClientRect();
141
+ const top = Math.max(0, Math.round(rect.bottom + 8));
142
+ const viewportHeight = window.innerHeight;
143
+ const maxHeight = Math.max(240, viewportHeight - top - 12);
144
+
145
+ root.style.setProperty('--public-navbar-mobile-drawer-top', `${top}px`);
146
+ root.style.setProperty('--public-navbar-mobile-drawer-max-height', `${maxHeight}px`);
147
+ };
148
+
149
+ updateDrawerViewportVars();
150
+ const navEl = navOuterRef.current;
151
+ const observer = navEl ? new ResizeObserver(updateDrawerViewportVars) : null;
152
+ if (navEl && observer) observer.observe(navEl);
153
+ window.addEventListener('resize', updateDrawerViewportVars);
154
+ window.addEventListener('scroll', updateDrawerViewportVars, { passive: true });
155
+
156
+ return () => {
157
+ if (navEl && observer) observer.unobserve(navEl);
158
+ observer?.disconnect();
159
+ window.removeEventListener('resize', updateDrawerViewportVars);
160
+ window.removeEventListener('scroll', updateDrawerViewportVars);
161
+ };
162
+ }, [navbarPosition, navbarVariant, containerClassName]);
163
+
164
+ const navOuterClassName = (() => {
165
+ const positionClass =
166
+ navbarPosition === 'fixed'
167
+ ? 'fixed'
168
+ : navbarPosition === 'static'
169
+ ? 'static'
170
+ : 'sticky';
171
+
172
+ const topClass = navbarVariant === 'floating' ? 'top-3' : 'top-0';
173
+ const insetPaddingClass = navbarVariant === 'floating' ? 'px-3 sm:px-4 lg:px-6' : '';
174
+
175
+ return `${positionClass} ${topClass} inset-x-0 z-50 ${insetPaddingClass}`.trim().replace(/\s+/g, ' ');
176
+ })();
177
+
178
+ const navInnerClassName = (() => {
179
+ const base =
180
+ 'mx-auto w-full border border-border/40 dark:border-border/70';
181
+
182
+ const visual =
183
+ navbarVariant === 'floating'
184
+ ? 'rounded-2xl shadow-[0_10px_28px_rgba(0,0,0,0.14)] dark:shadow-[0_10px_28px_rgba(0,0,0,0.22)]'
185
+ : 'rounded-none border-x-0 border-t-0 shadow-none';
186
+
187
+ return `${base} ${visual} ${containerClassName || ''}`.trim().replace(/\s+/g, ' ');
188
+ })();
189
+
190
+ const isActivePath = (href: string) => {
191
+ if (href === '/') return pathname === '/';
192
+ return pathname === href || pathname.startsWith(`${href}/`);
193
+ };
194
+
195
+ const isGroupActive = (item: NavigationItem) => {
196
+ if (isActivePath(item.href)) return true;
197
+ if (!item.items) return false;
198
+ return item.items.some((subItem) => isActivePath(subItem.href));
199
+ };
200
+
201
+ const closeDropdown = () => setOpenDropdownKey(null);
202
+ const desktopPrimaryNavigation = navigation.slice(0, desktopMaxPrimaryItems);
203
+ const desktopOverflowNavigation = navigation.slice(desktopMaxPrimaryItems);
204
+
205
+ const renderDesktopNavItem = (item: NavigationItem) => {
206
+ if (item.items && item.items.length > 0) {
207
+ const dropdownKey = `${item.label}-${item.href}`;
208
+ const defaultItems = (
209
+ <>
210
+ {item.items.map((subItem) => (
211
+ <div key={`${item.label}-${subItem.href}`} className="rounded-md">
212
+ {subItem.external ? (
213
+ <a
214
+ href={subItem.href}
215
+ target="_blank"
216
+ rel="noopener noreferrer"
217
+ className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
218
+ >
219
+ {subItem.label}
220
+ </a>
221
+ ) : (
222
+ <Link
223
+ href={subItem.href}
224
+ className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
225
+ >
226
+ {subItem.label}
227
+ </Link>
228
+ )}
229
+ </div>
230
+ ))}
231
+ </>
232
+ );
233
+
234
+ const defaultPopover = (
235
+ <div
236
+ className="absolute left-0 top-full mt-1 z-[1200] min-w-56 rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1 shadow-[0_10px_28px_rgba(0,0,0,0.22)]"
237
+ onMouseEnter={() => {
238
+ clearOpenTimer();
239
+ clearCloseTimer();
240
+ }}
241
+ onMouseLeave={() => scheduleClose(dropdownKey)}
242
+ >
243
+ {defaultItems}
244
+ </div>
245
+ );
246
+
247
+ const isOpen = openDropdownKey === dropdownKey;
248
+ const isActive = isGroupActive(item);
249
+ return (
250
+ <div
251
+ key={dropdownKey}
252
+ className="relative"
253
+ onMouseEnter={() => scheduleOpen(dropdownKey)}
254
+ onMouseLeave={() => scheduleClose(dropdownKey)}
255
+ >
256
+ <Button
257
+ variant="ghost"
258
+ size="sm"
259
+ className={`group ${desktopNavItemClass} ${isOpen || isActive ? 'bg-accent/50 text-foreground' : ''}`}
260
+ >
261
+ <span>{item.label}</span>
262
+ <ChevronDown
263
+ className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
264
+ />
265
+ </Button>
266
+
267
+ {isOpen && (
268
+ renderDesktopDropdown
269
+ ? renderDesktopDropdown({
270
+ item,
271
+ isOpen,
272
+ isActive,
273
+ close: closeDropdown,
274
+ defaultPopover,
275
+ defaultItems,
276
+ })
277
+ : defaultPopover
278
+ )}
279
+ </div>
280
+ );
281
+ }
282
+
283
+ return (
284
+ <Link
285
+ key={item.href}
286
+ href={item.href}
287
+ className={`${desktopNavItemClass} ${isActivePath(item.href) ? 'bg-accent/50 text-foreground' : ''}`}
288
+ >
289
+ {item.label}
290
+ </Link>
291
+ );
292
+ };
96
293
 
97
294
  return (
98
- <div className={navClass}>
295
+ <div ref={navOuterRef} className={navOuterClassName}>
99
296
  <nav
100
- className={`mx-auto w-full rounded-2xl border border-border/40 dark:border-border/70 shadow-[0_10px_28px_rgba(0,0,0,0.14)] dark:shadow-[0_10px_28px_rgba(0,0,0,0.22)] ${containerClassName || ''}`}
297
+ className={navInnerClassName}
101
298
  style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
102
299
  >
103
- <div className="w-full px-4 sm:px-6 lg:px-8">
104
- <div
105
- className="flex items-center justify-between py-3.5"
106
- onClick={isMobile ? toggleMobileMenu : undefined}
107
- onKeyDown={isMobile ? (event) => {
108
- if (event.key === 'Enter' || event.key === ' ') {
109
- event.preventDefault();
110
- toggleMobileMenu();
111
- }
112
- } : undefined}
113
- role={isMobile ? 'button' : undefined}
114
- tabIndex={isMobile ? 0 : undefined}
115
- aria-label={isMobile ? toggleMobileLabel : undefined}
116
- >
300
+ <div className="w-full px-3 sm:px-4 lg:px-6">
301
+ <div className="flex items-center justify-between py-3.5">
117
302
  {/* Logo */}
118
- {isMobile ? (
119
- <div className="flex items-center gap-2">
120
- {logo && (
121
- <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
122
- )}
123
- <span className="font-bold text-base">{siteName}</span>
124
- </div>
303
+ {brand ? (
304
+ brand
125
305
  ) : (
126
- <Link href="/" className="flex items-center gap-2">
306
+ <Link href={brandHref} className="flex items-center gap-1.5">
127
307
  {logo && (
128
- <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
308
+ <img src={logo} alt={siteName} className="h-5.5 w-auto object-contain" />
129
309
  )}
130
- <span className="font-bold text-base">{siteName}</span>
310
+ <span className="font-bold text-[15px]">{siteName}</span>
131
311
  </Link>
132
312
  )}
133
313
 
134
314
  {/* Desktop Navigation */}
135
- <div className="hidden md:flex items-center gap-3">
136
- {navigation.map((item) => {
137
- if (item.items && item.items.length > 0) {
138
- const dropdownKey = `${item.label}-${item.href}`;
139
- return (
315
+ <div className="hidden lg:flex items-center gap-3">
316
+ {desktopPrimaryNavigation.map(renderDesktopNavItem)}
317
+ {desktopOverflowNavigation.length > 0 && (
318
+ <div
319
+ className="relative"
320
+ onMouseEnter={() => scheduleOpen('__overflow-more')}
321
+ onMouseLeave={() => scheduleClose('__overflow-more')}
322
+ >
323
+ <Button
324
+ variant="ghost"
325
+ size="sm"
326
+ className={`group ${desktopNavItemClass} ${
327
+ openDropdownKey === '__overflow-more' || desktopOverflowNavigation.some((item) => isGroupActive(item))
328
+ ? 'bg-accent/50 text-foreground'
329
+ : ''
330
+ }`}
331
+ >
332
+ <span>More</span>
333
+ <ChevronDown
334
+ className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
335
+ openDropdownKey === '__overflow-more' ? 'rotate-180' : ''
336
+ }`}
337
+ />
338
+ </Button>
339
+
340
+ {openDropdownKey === '__overflow-more' && (
140
341
  <div
141
- key={dropdownKey}
142
- className="relative"
143
- onMouseEnter={() => scheduleOpen(dropdownKey)}
144
- onMouseLeave={() => scheduleClose(dropdownKey)}
342
+ className="absolute right-0 top-full mt-1 z-[1200] min-w-56 rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1 shadow-[0_10px_28px_rgba(0,0,0,0.22)]"
343
+ onMouseEnter={() => {
344
+ clearOpenTimer();
345
+ clearCloseTimer();
346
+ }}
347
+ onMouseLeave={() => scheduleClose('__overflow-more')}
145
348
  >
146
- <Button
147
- variant="ghost"
148
- size="sm"
149
- className={`group ${desktopNavItemClass} ${openDropdownKey === dropdownKey ? 'bg-accent/50 text-foreground' : ''}`}
150
- >
151
- <span>{item.label}</span>
152
- <ChevronDown
153
- className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${openDropdownKey === dropdownKey ? 'rotate-180' : ''}`}
154
- />
155
- </Button>
156
-
157
- {openDropdownKey === dropdownKey && (
158
- <div
159
- className="absolute left-0 top-full mt-1 z-[1200] min-w-56 rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1 shadow-[0_10px_28px_rgba(0,0,0,0.22)]"
160
- onMouseEnter={() => {
161
- clearOpenTimer();
162
- clearCloseTimer();
163
- }}
164
- onMouseLeave={() => scheduleClose(dropdownKey)}
165
- >
166
- {item.items.map((subItem) => (
167
- <div key={`${item.label}-${subItem.href}`} className="rounded-md">
168
- {subItem.external ? (
169
- <a
170
- href={subItem.href}
171
- target="_blank"
172
- rel="noopener noreferrer"
173
- className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
174
- >
175
- {subItem.label}
176
- </a>
177
- ) : (
178
- <Link
179
- href={subItem.href}
180
- className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
181
- >
182
- {subItem.label}
183
- </Link>
184
- )}
185
- </div>
186
- ))}
349
+ {desktopOverflowNavigation.map((item) => (
350
+ <div key={`overflow-${item.href}`} className="rounded-md">
351
+ <Link
352
+ href={item.href}
353
+ className={`block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40 ${
354
+ isGroupActive(item) ? 'bg-accent/45 text-foreground' : ''
355
+ }`}
356
+ >
357
+ {item.label}
358
+ </Link>
187
359
  </div>
188
- )}
360
+ ))}
189
361
  </div>
190
- );
191
- }
192
-
193
- return (
194
- <Link
195
- key={item.href}
196
- href={item.href}
197
- className={desktopNavItemClass}
198
- >
199
- {item.label}
200
- </Link>
201
- );
202
- })}
362
+ )}
363
+ </div>
364
+ )}
203
365
  </div>
204
366
 
205
367
  {/* User Menu / Actions */}
206
368
  <div className="flex items-center gap-4">
207
- {!isMobile && (
369
+ <div className="hidden lg:flex">
208
370
  <>
209
371
  {/* User Menu */}
210
372
  <UserMenu
@@ -213,20 +375,19 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
213
375
  authPath={userMenu?.authPath}
214
376
  />
215
377
  </>
216
- )}
378
+ </div>
217
379
 
218
380
  {/* Mobile Menu Button */}
219
- {isMobile && (
220
- <Button
221
- variant="ghost"
222
- size="icon"
223
- aria-label={toggleMobileLabel}
224
- data-mobile-menu-trigger="true"
225
- className="pointer-events-none"
226
- >
227
- {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
228
- </Button>
229
- )}
381
+ <Button
382
+ variant="ghost"
383
+ size="icon"
384
+ aria-label={toggleMobileLabel}
385
+ data-mobile-menu-trigger="true"
386
+ className="lg:hidden"
387
+ onClick={toggleMobileMenu}
388
+ >
389
+ {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
390
+ </Button>
230
391
  </div>
231
392
  </div>
232
393
  </div>
@@ -4,5 +4,6 @@
4
4
 
5
5
  export { PublicNavigation } from './PublicNavigation';
6
6
  export { PublicMobileDrawer } from './PublicMobileDrawer';
7
+ export { PublicNavbar } from './PublicNavbar';
7
8
  export { PublicFooter } from './PublicFooter';
8
9
 
@@ -2,16 +2,7 @@
2
2
 
3
3
  import React, { createContext, useContext } from 'react';
4
4
 
5
- import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
6
- import type { NavigationItem, UserMenuConfig } from '../types';
7
-
8
5
  export interface PublicLayoutContextValue {
9
- logo?: string;
10
- siteName: string;
11
- navigation: NavigationItem[];
12
- userMenu?: UserMenuConfig;
13
- i18n?: I18nLayoutConfig;
14
- containerClassName?: string;
15
6
  mobileMenuOpen: boolean;
16
7
  toggleMobileMenu: () => void;
17
8
  closeMobileMenu: () => void;
@@ -4,7 +4,14 @@
4
4
 
5
5
  export { PublicLayout } from './PublicLayout';
6
6
  export type { PublicLayoutProps } from './PublicLayout';
7
- export { PublicNavigation, PublicMobileDrawer } from './components';
7
+ export { PublicNavigation, PublicMobileDrawer, PublicNavbar } from './components';
8
+ export type {
9
+ PublicNavbarVariant,
10
+ PublicNavbarPosition,
11
+ PublicDesktopDropdownRenderer,
12
+ PublicDesktopDropdownRenderProps,
13
+ } from './components/PublicNavigation';
14
+ export type { PublicNavbarConfig, PublicNavbarProps } from './components/PublicNavbar';
8
15
  export {
9
16
  PublicFooter,
10
17
  FooterProjectInfo,
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Account block: collapsible trigger + expanded card (email, links, footer row:
3
+ * sign out on the left, locale + theme toggles on the right).
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import { ChevronDown, LogOut } from 'lucide-react';
9
+ import Link from 'next/link';
10
+ import React from 'react';
11
+
12
+ import { useAuth } from '@djangocfg/api/auth';
13
+ import { useAppT } from '@djangocfg/i18n';
14
+ import {
15
+ Avatar,
16
+ AvatarFallback,
17
+ AvatarImage,
18
+ Button,
19
+ Collapsible,
20
+ CollapsibleContent,
21
+ CollapsibleTrigger,
22
+ } from '@djangocfg/ui-core/components';
23
+ import { cn } from '@djangocfg/ui-core/lib';
24
+ import { useSidebar } from '@djangocfg/ui-nextjs/components';
25
+ import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
26
+
27
+ import { useLogout } from '../../hooks';
28
+ import { LocaleSwitcher } from './LocaleSwitcher';
29
+
30
+ import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
31
+ import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
32
+
33
+ interface PrivateSidebarAccountProps {
34
+ header?: HeaderConfig;
35
+ i18n?: I18nLayoutConfig;
36
+ }
37
+
38
+ export function PrivateSidebarAccount({ header, i18n }: PrivateSidebarAccountProps) {
39
+ const { user } = useAuth();
40
+ const handleLogout = useLogout();
41
+ const t = useAppT();
42
+ const { state } = useSidebar();
43
+ const [open, setOpen] = React.useState(false);
44
+
45
+ const signOutLabel = t('layouts.profile.signOut');
46
+
47
+ const accountLinks = React.useMemo(() => {
48
+ if (!header?.groups?.length) return [];
49
+ return header.groups.flatMap((g) => g.items.filter((i) => i.href));
50
+ }, [header?.groups]);
51
+
52
+ if (!user) {
53
+ return null;
54
+ }
55
+
56
+ const displayName = user.display_username || user.email || 'User';
57
+ const userInitial = displayName.charAt(0).toUpperCase();
58
+ const userAvatar = user.avatar || '';
59
+ const narrow = state === 'collapsed';
60
+ const hasEmailOrLinks = Boolean(user.email) || accountLinks.length > 0;
61
+
62
+ return (
63
+ <Collapsible open={open} onOpenChange={setOpen} className="w-full min-w-0 border-t border-sidebar-border/45 pt-2">
64
+ <CollapsibleTrigger asChild>
65
+ <Button
66
+ type="button"
67
+ variant="ghost"
68
+ aria-expanded={open}
69
+ aria-label={narrow ? 'Account' : undefined}
70
+ className={cn(
71
+ 'h-auto min-h-10 w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-sidebar-accent',
72
+ narrow && 'justify-center px-0',
73
+ )}
74
+ >
75
+ <Avatar className="h-8 w-8 shrink-0">
76
+ <AvatarImage src={userAvatar} alt={displayName} />
77
+ <AvatarFallback className="text-xs">{userInitial}</AvatarFallback>
78
+ </Avatar>
79
+ {!narrow && (
80
+ <>
81
+ <span className="min-w-0 flex-1 truncate text-left text-sm font-medium leading-tight">
82
+ {displayName}
83
+ </span>
84
+ <ChevronDown
85
+ className={cn(
86
+ 'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',
87
+ open && 'rotate-180',
88
+ )}
89
+ aria-hidden
90
+ />
91
+ </>
92
+ )}
93
+ </Button>
94
+ </CollapsibleTrigger>
95
+
96
+ <CollapsibleContent className="overflow-hidden data-[state=closed]:hidden">
97
+ <div
98
+ className={cn(
99
+ 'mt-2 flex flex-col gap-3 rounded-lg border border-zinc-200/90 bg-zinc-50/80 p-3 shadow-sm',
100
+ 'dark:border-sidebar-border dark:bg-sidebar-accent/15',
101
+ )}
102
+ >
103
+ {user.email && (
104
+ <p className="truncate text-xs leading-snug text-muted-foreground">{user.email}</p>
105
+ )}
106
+
107
+ {accountLinks.length > 0 && (
108
+ <nav className="flex max-h-40 flex-col gap-0.5 overflow-y-auto" aria-label="Account">
109
+ {accountLinks.map((item) => {
110
+ const Icon = item.icon;
111
+ return (
112
+ <Link
113
+ key={item.href}
114
+ href={item.href!}
115
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm leading-snug text-zinc-800 transition-colors hover:bg-zinc-200/70 dark:text-foreground dark:hover:bg-sidebar-accent"
116
+ >
117
+ {Icon && (
118
+ <Icon className="h-4 w-4 shrink-0 text-zinc-500 dark:text-muted-foreground" />
119
+ )}
120
+ <span className="truncate">{item.label}</span>
121
+ </Link>
122
+ );
123
+ })}
124
+ </nav>
125
+ )}
126
+
127
+ <div
128
+ className={cn(
129
+ 'flex min-h-10 items-center gap-2',
130
+ hasEmailOrLinks && 'border-t border-border/50 pt-3 dark:border-sidebar-border/40',
131
+ )}
132
+ >
133
+ <button
134
+ type="button"
135
+ onClick={handleLogout}
136
+ className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-destructive hover:bg-destructive/10"
137
+ >
138
+ <LogOut className="h-4 w-4 shrink-0" />
139
+ <span className="truncate">{signOutLabel}</span>
140
+ </button>
141
+ <div
142
+ className="flex shrink-0 items-center gap-0.5 border-l border-border/50 pl-2 dark:border-sidebar-border/40"
143
+ role="group"
144
+ aria-label="Language and theme"
145
+ >
146
+ {i18n && (
147
+ <LocaleSwitcher
148
+ locale={i18n.locale}
149
+ locales={i18n.locales}
150
+ onChange={i18n.onLocaleChange}
151
+ variant="ghost"
152
+ size="icon"
153
+ showTriggerLabel={false}
154
+ showIcon={false}
155
+ className="h-8 w-8 shrink-0 text-base leading-none"
156
+ />
157
+ )}
158
+ <ThemeToggle
159
+ size="compact"
160
+ className="h-8 w-8 shrink-0 focus-visible:ring-1 focus-visible:ring-sidebar-ring focus-visible:ring-offset-0"
161
+ />
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </CollapsibleContent>
166
+ </Collapsible>
167
+ );
168
+ }