@djangocfg/layouts 2.1.247 → 2.1.249

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.247",
3
+ "version": "2.1.249",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -74,14 +74,14 @@
74
74
  "check": "tsc --noEmit"
75
75
  },
76
76
  "peerDependencies": {
77
- "@djangocfg/api": "^2.1.247",
78
- "@djangocfg/centrifugo": "^2.1.247",
79
- "@djangocfg/i18n": "^2.1.247",
80
- "@djangocfg/monitor": "^2.1.247",
81
- "@djangocfg/debuger": "^2.1.247",
82
- "@djangocfg/ui-core": "^2.1.247",
83
- "@djangocfg/ui-nextjs": "^2.1.247",
84
- "@djangocfg/ui-tools": "^2.1.247",
77
+ "@djangocfg/api": "^2.1.249",
78
+ "@djangocfg/centrifugo": "^2.1.249",
79
+ "@djangocfg/i18n": "^2.1.249",
80
+ "@djangocfg/monitor": "^2.1.249",
81
+ "@djangocfg/debuger": "^2.1.249",
82
+ "@djangocfg/ui-core": "^2.1.249",
83
+ "@djangocfg/ui-nextjs": "^2.1.249",
84
+ "@djangocfg/ui-tools": "^2.1.249",
85
85
  "@hookform/resolvers": "^5.2.2",
86
86
  "consola": "^3.4.2",
87
87
  "lucide-react": "^0.545.0",
@@ -109,15 +109,15 @@
109
109
  "uuid": "^11.1.0"
110
110
  },
111
111
  "devDependencies": {
112
- "@djangocfg/api": "^2.1.247",
113
- "@djangocfg/i18n": "^2.1.247",
114
- "@djangocfg/centrifugo": "^2.1.247",
115
- "@djangocfg/monitor": "^2.1.247",
116
- "@djangocfg/debuger": "^2.1.247",
117
- "@djangocfg/typescript-config": "^2.1.247",
118
- "@djangocfg/ui-core": "^2.1.247",
119
- "@djangocfg/ui-nextjs": "^2.1.247",
120
- "@djangocfg/ui-tools": "^2.1.247",
112
+ "@djangocfg/api": "^2.1.249",
113
+ "@djangocfg/i18n": "^2.1.249",
114
+ "@djangocfg/centrifugo": "^2.1.249",
115
+ "@djangocfg/monitor": "^2.1.249",
116
+ "@djangocfg/debuger": "^2.1.249",
117
+ "@djangocfg/typescript-config": "^2.1.249",
118
+ "@djangocfg/ui-core": "^2.1.249",
119
+ "@djangocfg/ui-nextjs": "^2.1.249",
120
+ "@djangocfg/ui-tools": "^2.1.249",
121
121
  "@types/node": "^24.7.2",
122
122
  "@types/react": "^19.1.0",
123
123
  "@types/react-dom": "^19.1.0",
@@ -29,9 +29,10 @@
29
29
  'use client';
30
30
 
31
31
  import { usePathname } from 'next/navigation';
32
- import { ReactNode, useEffect, useState } from 'react';
32
+ import { ReactNode, useEffect, useMemo, useState } from 'react';
33
33
 
34
34
  import { PublicMobileDrawer, PublicNavigation } from './components';
35
+ import { PublicLayoutProvider } from './context';
35
36
 
36
37
  import type { NavigationItem, UserMenuConfig } from '../types';
37
38
  import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
@@ -48,6 +49,8 @@ export interface PublicLayoutProps {
48
49
  userMenu?: UserMenuConfig;
49
50
  /** i18n configuration for locale switching */
50
51
  i18n?: I18nLayoutConfig;
52
+ /** Custom className for navbar container (e.g. "max-w-7xl mx-auto") */
53
+ navbarContainerClassName?: string;
51
54
  }
52
55
 
53
56
  export function PublicLayout({
@@ -57,6 +60,7 @@ export function PublicLayout({
57
60
  navigation = [],
58
61
  userMenu,
59
62
  i18n,
63
+ navbarContainerClassName,
60
64
  }: PublicLayoutProps) {
61
65
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
62
66
  const pathname = usePathname();
@@ -66,33 +70,33 @@ export function PublicLayout({
66
70
  setMobileMenuOpen(false);
67
71
  }, [pathname]);
68
72
 
73
+ const contextValue = useMemo(() => ({
74
+ logo,
75
+ siteName,
76
+ navigation,
77
+ userMenu,
78
+ i18n,
79
+ containerClassName: navbarContainerClassName,
80
+ mobileMenuOpen,
81
+ toggleMobileMenu: () => setMobileMenuOpen((prev) => !prev),
82
+ closeMobileMenu: () => setMobileMenuOpen(false),
83
+ }), [logo, siteName, navigation, userMenu, i18n, navbarContainerClassName, mobileMenuOpen]);
84
+
69
85
  return (
70
- <div className="min-h-screen flex flex-col">
71
- {/* Navigation */}
72
- <PublicNavigation
73
- logo={logo}
74
- siteName={siteName}
75
- navigation={navigation}
76
- userMenu={userMenu}
77
- i18n={i18n}
78
- onMobileMenuClick={() => setMobileMenuOpen(true)}
79
- />
86
+ <PublicLayoutProvider value={contextValue}>
87
+ <div className="min-h-screen flex flex-col">
88
+ {/* Navigation */}
89
+ <PublicNavigation />
80
90
 
81
- {/* Mobile Drawer */}
82
- <PublicMobileDrawer
83
- isOpen={mobileMenuOpen}
84
- onClose={() => setMobileMenuOpen(false)}
85
- logo={logo}
86
- siteName={siteName}
87
- navigation={navigation}
88
- userMenu={userMenu}
89
- />
91
+ {/* Mobile Drawer */}
92
+ <PublicMobileDrawer />
90
93
 
91
- {/* Main Content */}
92
- <main className="flex-1">{children}</main>
94
+ {/* Main Content */}
95
+ <main className="flex-1">{children}</main>
93
96
 
94
- {/* Footer - Add your own custom footer component here if needed */}
95
- </div>
97
+ {/* Footer - Add your own custom footer component here if needed */}
98
+ </div>
99
+ </PublicLayoutProvider>
96
100
  );
97
101
  }
98
102
 
@@ -16,23 +16,11 @@ export interface FooterMenuSectionsProps {
16
16
  export function FooterMenuSections({ menuSections }: FooterMenuSectionsProps) {
17
17
  if (menuSections.length === 0) return null;
18
18
 
19
- // Gap in pixels (lg:gap-x-12 = 3rem = 48px)
20
- const gapPx = 48;
21
- const sectionCount = menuSections.length;
22
- const totalGap = (sectionCount - 1) * gapPx;
23
-
24
- // Each section = 25% of full width, minus proportional part of gap
25
- const sectionWidth = `calc(25% - ${totalGap / sectionCount}px)`;
26
-
27
19
  return (
28
- <div className="flex flex-1 gap-8 lg:gap-x-12 justify-end">
20
+ <div className="w-full grid grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-x-12">
29
21
  {menuSections.map((section) => (
30
- <div
31
- key={section.title}
32
- className="flex-shrink-0 min-w-0"
33
- style={{ width: sectionWidth }}
34
- >
35
- <h3 className="text-base font-semibold text-foreground mb-3">
22
+ <div key={section.title} className="min-w-0">
23
+ <h3 className="text-xs font-medium text-muted-foreground mb-3">
36
24
  {section.title}
37
25
  </h3>
38
26
  <ul className="space-y-2">
@@ -40,7 +28,7 @@ export function FooterMenuSections({ menuSections }: FooterMenuSectionsProps) {
40
28
  <li key={item.path}>
41
29
  <Link
42
30
  href={item.path}
43
- className="text-muted-foreground hover:text-primary text-sm transition-colors"
31
+ className="text-sm text-foreground/90 hover:text-foreground transition-colors"
44
32
  >
45
33
  {item.label}
46
34
  </Link>
@@ -48,13 +48,13 @@ export function FooterProjectInfo({
48
48
  ) : (
49
49
  <DjangoCFGLogo size={isMobile ? 24 : 32} className="text-foreground" />
50
50
  )}
51
- <span className={isMobile ? 'text-lg font-bold text-foreground' : 'text-xl font-bold text-foreground'}>
51
+ <span className={isMobile ? 'text-lg font-bold text-foreground' : 'text-lg font-semibold text-foreground'}>
52
52
  {siteName}
53
53
  </span>
54
54
  </div>
55
55
 
56
56
  {description && (
57
- <p className={isMobile ? 'text-muted-foreground text-sm leading-relaxed max-w-md mx-auto' : 'text-muted-foreground text-sm leading-relaxed'}>
57
+ <p className={isMobile ? 'text-muted-foreground text-sm leading-relaxed max-w-md mx-auto' : 'text-muted-foreground text-xs leading-relaxed max-w-xs'}>
58
58
  {description}
59
59
  </p>
60
60
  )}
@@ -8,16 +8,68 @@
8
8
  'use client';
9
9
 
10
10
  import Link from 'next/link';
11
- import React from 'react';
11
+ import React, { useEffect, useState } from 'react';
12
+ import { Laptop, Moon, Sun } from 'lucide-react';
12
13
 
14
+ import { Button } from '@djangocfg/ui-core/components';
13
15
  import { useIsMobile } from '@djangocfg/ui-core/hooks';
16
+ import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
14
17
 
15
- import { FooterBottom } from './FooterBottom';
18
+ import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
16
19
  import { FooterMenuSections } from './FooterMenuSections';
17
20
  import { FooterProjectInfo } from './FooterProjectInfo';
18
21
 
19
22
  import type { PublicFooterProps } from './types';
20
23
 
24
+ function ThemeModeControl() {
25
+ const { theme, setTheme } = useThemeContext();
26
+ const [mounted, setMounted] = useState(false);
27
+
28
+ useEffect(() => {
29
+ setMounted(true);
30
+ }, []);
31
+
32
+ const currentTheme = mounted ? (theme || 'system') : 'system';
33
+ const isActive = (value: 'system' | 'light' | 'dark') => currentTheme === value;
34
+ const baseItemClass = 'h-8 w-8 rounded-full p-0 text-muted-foreground hover:text-foreground';
35
+ const activeItemClass = 'bg-background/80 text-foreground shadow-sm';
36
+
37
+ return (
38
+ <div className="inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/30 p-1">
39
+ <Button
40
+ type="button"
41
+ variant="ghost"
42
+ size="icon"
43
+ className={`${baseItemClass} ${isActive('system') ? activeItemClass : ''}`}
44
+ onClick={() => setTheme('system')}
45
+ aria-label="Use system theme"
46
+ >
47
+ <Laptop className="h-4 w-4" />
48
+ </Button>
49
+ <Button
50
+ type="button"
51
+ variant="ghost"
52
+ size="icon"
53
+ className={`${baseItemClass} ${isActive('light') ? activeItemClass : ''}`}
54
+ onClick={() => setTheme('light')}
55
+ aria-label="Use light theme"
56
+ >
57
+ <Sun className="h-4 w-4" />
58
+ </Button>
59
+ <Button
60
+ type="button"
61
+ variant="ghost"
62
+ size="icon"
63
+ className={`${baseItemClass} ${isActive('dark') ? activeItemClass : ''}`}
64
+ onClick={() => setTheme('dark')}
65
+ aria-label="Use dark theme"
66
+ >
67
+ <Moon className="h-4 w-4" />
68
+ </Button>
69
+ </div>
70
+ );
71
+ }
72
+
21
73
  export function PublicFooter({
22
74
  siteName,
23
75
  description,
@@ -30,6 +82,7 @@ export function PublicFooter({
30
82
  credits: creditsProp,
31
83
  variant = 'full',
32
84
  containerClassName,
85
+ i18n,
33
86
  }: PublicFooterProps) {
34
87
  const isMobile = useIsMobile();
35
88
 
@@ -122,7 +175,7 @@ export function PublicFooter({
122
175
  if (isMobile) {
123
176
  return (
124
177
  <footer className="lg:hidden bg-background border-t border-border mt-auto">
125
- <div className="w-full px-4 py-8">
178
+ <div className={`mx-auto px-4 py-8 ${containerClassName || 'w-full'}`}>
126
179
  <FooterProjectInfo
127
180
  siteName={siteName}
128
181
  description={description}
@@ -158,11 +211,32 @@ export function PublicFooter({
158
211
  </div>
159
212
  )}
160
213
 
161
- <FooterBottom
162
- copyright={copyright}
163
- credits={credits}
164
- variant="mobile"
165
- />
214
+ <div className="border-t border-border mt-6 pt-4 space-y-3">
215
+ <div className="text-xs text-muted-foreground text-center">{copyright}</div>
216
+ <div className="text-xs text-muted-foreground text-center">
217
+ {credits.url ? (
218
+ <a href={credits.url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
219
+ {credits.text}
220
+ </a>
221
+ ) : (
222
+ credits.text
223
+ )}
224
+ </div>
225
+ </div>
226
+
227
+ <div className="mt-5 pt-4 border-t border-border/60 flex items-center justify-center gap-2">
228
+ <ThemeModeControl />
229
+ {i18n && (
230
+ <LocaleSwitcher
231
+ locale={i18n.locale}
232
+ locales={i18n.locales}
233
+ onChange={i18n.onLocaleChange}
234
+ variant="outline"
235
+ size="default"
236
+ className="h-10 rounded-full border-border/60 bg-muted/30 text-sm hover:bg-muted/40"
237
+ />
238
+ )}
239
+ </div>
166
240
  </div>
167
241
  </footer>
168
242
  );
@@ -170,9 +244,13 @@ export function PublicFooter({
170
244
 
171
245
  // Desktop Footer
172
246
  return (
173
- <footer className="max-lg:hidden bg-background border-t border-border mt-auto">
174
- <div className="w-full px-4 sm:px-6 lg:px-8 py-12">
175
- <div className="flex flex-col lg:flex-row gap-8 lg:gap-12">
247
+ <footer
248
+ className="max-lg:hidden border-t border-border/50 mt-auto"
249
+ style={{ backgroundColor: 'hsl(var(--background) / 0.94)' }}
250
+ >
251
+ <div className={`mx-auto px-6 sm:px-8 lg:px-10 py-14 ${containerClassName || 'w-full'}`}>
252
+ <div className="grid grid-cols-12 gap-10 lg:gap-14">
253
+ <div className="col-span-12 lg:col-span-4">
176
254
  <FooterProjectInfo
177
255
  siteName={siteName}
178
256
  description={description}
@@ -181,16 +259,63 @@ export function PublicFooter({
181
259
  socialLinks={socialLinks}
182
260
  variant="desktop"
183
261
  />
262
+ </div>
184
263
 
264
+ <div className="col-span-12 lg:col-span-8 lg:pl-8">
185
265
  <FooterMenuSections menuSections={menuSections} />
266
+ </div>
186
267
  </div>
187
268
 
188
- <FooterBottom
189
- copyright={copyright}
190
- credits={credits}
191
- links={links}
192
- variant="desktop"
193
- />
269
+ <div className="mt-12 pt-5 border-t border-border/60 flex items-center justify-between gap-8">
270
+ <div className="text-xs text-muted-foreground shrink-0">{copyright}</div>
271
+
272
+ <div className="flex-1 flex items-center justify-center min-w-0">
273
+ {credits.url ? (
274
+ <a href={credits.url} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground hover:text-foreground transition-colors whitespace-nowrap">
275
+ {credits.text}
276
+ </a>
277
+ ) : (
278
+ <span className="text-xs text-muted-foreground whitespace-nowrap">{credits.text}</span>
279
+ )}
280
+ </div>
281
+
282
+ <div className="flex flex-col items-end gap-2 shrink-0">
283
+ {links.length > 0 && (
284
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
285
+ {links.map((link) =>
286
+ link.external ? (
287
+ <a
288
+ key={link.path}
289
+ href={link.path}
290
+ target="_blank"
291
+ rel="noopener noreferrer"
292
+ className="hover:text-foreground transition-colors whitespace-nowrap"
293
+ >
294
+ {link.label}
295
+ </a>
296
+ ) : (
297
+ <Link key={link.path} href={link.path} className="hover:text-foreground transition-colors whitespace-nowrap">
298
+ {link.label}
299
+ </Link>
300
+ )
301
+ )}
302
+ </div>
303
+ )}
304
+ <div className="flex items-center gap-2">
305
+ <ThemeModeControl />
306
+ {i18n && (
307
+ <LocaleSwitcher
308
+ locale={i18n.locale}
309
+ locales={i18n.locales}
310
+ onChange={i18n.onLocaleChange}
311
+ variant="outline"
312
+ size="default"
313
+ className="h-10 rounded-full border-border/60 bg-muted/30 text-sm hover:bg-muted/40"
314
+ />
315
+ )}
316
+ </div>
317
+ </div>
318
+ </div>
194
319
  </div>
195
320
  </footer>
196
321
  );
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { LucideIcon } from 'lucide-react';
6
+ import type { I18nLayoutConfig } from '../../../AppLayout/AppLayout';
6
7
 
7
8
  export interface FooterLink {
8
9
  label: string;
@@ -56,4 +57,6 @@ export interface PublicFooterProps {
56
57
  variant?: 'full' | 'compact' | 'simple';
57
58
  /** Custom className for content container (e.g. "max-w-4xl" or "container") */
58
59
  containerClassName?: string;
60
+ /** i18n configuration for language switcher */
61
+ i18n?: I18nLayoutConfig;
59
62
  }
@@ -6,109 +6,138 @@
6
6
 
7
7
  'use client';
8
8
 
9
- import { X } from 'lucide-react';
9
+ import { ArrowRight } from 'lucide-react';
10
10
  import Link from 'next/link';
11
11
  import React, { useMemo } from 'react';
12
12
 
13
13
  import { useAuth } from '@djangocfg/api/auth';
14
14
  import { useAppT } from '@djangocfg/i18n';
15
- import {
16
- Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle
17
- } from '@djangocfg/ui-core/components';
18
- import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
15
+ import { Button } from '@djangocfg/ui-core/components';
19
16
 
20
17
  import { UserMenu } from '../../_components/UserMenu';
18
+ import { usePublicLayoutOptional } from '../context';
19
+ import { useFloatingPanel } from '../hooks';
21
20
 
22
21
  import type { NavigationItem, UserMenuConfig } from '../../types';
23
22
 
24
23
  interface PublicMobileDrawerProps {
25
- isOpen: boolean;
26
- onClose: () => void;
27
- logo?: string;
28
- siteName: string;
29
- navigation: NavigationItem[];
24
+ isOpen?: boolean;
25
+ onClose?: () => void;
26
+ navigation?: NavigationItem[];
30
27
  userMenu?: UserMenuConfig;
28
+ containerClassName?: string;
31
29
  }
32
30
 
33
- export function PublicMobileDrawer({
34
- isOpen,
35
- onClose,
36
- logo,
37
- siteName,
38
- navigation,
39
- userMenu,
40
- }: PublicMobileDrawerProps) {
41
- const { isAuthenticated: _isAuthenticated } = useAuth();
31
+ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
32
+ const context = usePublicLayoutOptional();
33
+ const mobileMenuOpen = props.isOpen ?? context?.mobileMenuOpen ?? false;
34
+ const closeMobileMenu = props.onClose ?? context?.closeMobileMenu ?? (() => {});
35
+ const navigation = props.navigation ?? context?.navigation ?? [];
36
+ const userMenu = props.userMenu ?? context?.userMenu;
37
+ const containerClassName = props.containerClassName ?? context?.containerClassName;
38
+ const { isAuthenticated } = useAuth();
42
39
  const t = useAppT();
40
+ const { isRendered, isActive, onTransitionEnd } = useFloatingPanel({
41
+ isOpen: mobileMenuOpen,
42
+ onClose: closeMobileMenu,
43
+ });
43
44
 
44
45
  const labels = useMemo(() => ({
45
- closeMenu: t('layouts.mobile.closeMenu'),
46
46
  menu: t('layouts.navigation.menu'),
47
- theme: t('layouts.theme.toggle'),
47
+ quickActions: 'Actions',
48
+ signIn: t('layouts.profile.login'),
48
49
  }), [t]);
49
50
 
51
+ if (!isRendered) return null;
52
+
50
53
  return (
51
- <Drawer open={isOpen} onOpenChange={(open) => !open && onClose()} direction="right">
52
- <DrawerContent direction="right" className="w-80 lg:hidden">
53
- {/* Header */}
54
- <DrawerHeader className="flex flex-row items-center justify-between p-4 border-b border-border/30">
55
- <div className="flex items-center gap-3">
56
- {logo && (
57
- <img
58
- src={logo}
59
- alt={`${siteName} Logo`}
60
- className="h-6 w-auto object-contain"
61
- />
62
- )}
63
- <DrawerTitle className="text-lg font-bold text-foreground">
64
- {siteName}
65
- </DrawerTitle>
54
+ <>
55
+ {mobileMenuOpen && (
56
+ <button
57
+ type="button"
58
+ aria-label={t('layouts.mobile.closeMenu')}
59
+ className="fixed inset-0 z-[998] lg:hidden bg-black/35 transition-opacity duration-200"
60
+ onClick={closeMobileMenu}
61
+ />
62
+ )}
63
+ <div className="fixed inset-x-0 top-20 z-1000 lg:hidden px-4 sm:px-6 lg:px-8">
64
+ <div
65
+ onTransitionEnd={onTransitionEnd}
66
+ className={`mx-auto w-full max-h-[80vh] rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl shadow-2xl overflow-hidden flex flex-col transform-gpu will-change-transform transition-[transform,opacity] duration-[220ms] ease-out ${containerClassName || ''} ${
67
+ isActive
68
+ ? 'opacity-100 translate-y-0 scale-100'
69
+ : 'opacity-0 -translate-y-2 scale-[0.985] pointer-events-none'
70
+ }`}
71
+ style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
72
+ >
73
+ {/* Scrollable content */}
74
+ <div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5">
75
+ {isAuthenticated && (
76
+ <div className="px-2">
77
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
78
+ {labels.quickActions}
79
+ </h3>
66
80
  </div>
67
- <DrawerClose className="p-2 rounded-sm transition-colors hover:bg-accent/50">
68
- <X className="size-5" />
69
- <span className="sr-only">{labels.closeMenu}</span>
70
- </DrawerClose>
71
- </DrawerHeader>
81
+ )}
72
82
 
73
- {/* Scrollable Content */}
74
- <div className="flex-1 overflow-y-auto p-4 space-y-6">
75
- {/* User Menu */}
76
- <UserMenu
77
- variant="mobile"
78
- groups={userMenu?.groups}
79
- authPath={userMenu?.authPath}
80
- />
83
+ {isAuthenticated && (
84
+ <UserMenu variant="mobile" groups={userMenu?.groups} authPath={userMenu?.authPath} />
85
+ )}
81
86
 
82
- {/* Navigation Items */}
87
+ {/* Navigation Items */}
88
+ <div className="space-y-2">
89
+ <div className="px-2">
90
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
91
+ {labels.menu}
92
+ </h3>
93
+ </div>
83
94
  <div className="space-y-1">
84
- <div className="px-4 py-2">
85
- <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
86
- {labels.menu}
87
- </h3>
88
- </div>
89
- <div className="space-y-1">
90
- {navigation.map((item) => (
95
+ {navigation.map((item) => (
96
+ <div key={item.href}>
91
97
  <Link
92
- key={item.href}
93
98
  href={item.href}
94
- className="block px-4 py-3 rounded-sm text-sm font-medium transition-colors text-foreground hover:bg-accent hover:text-accent-foreground"
99
+ onClick={closeMobileMenu}
100
+ className="block px-3 py-2.5 rounded-lg text-[15px] font-medium transition-colors text-foreground hover:bg-accent/70 hover:text-accent-foreground"
95
101
  >
96
102
  {item.label}
97
103
  </Link>
98
- ))}
99
- </div>
104
+ {item.items && item.items.length > 0 && (
105
+ <div className="ml-3 mt-1 space-y-1 border-l border-border/40 pl-3">
106
+ {item.items.map((subItem) => (
107
+ <Link
108
+ key={`${item.href}-${subItem.href}`}
109
+ href={subItem.href}
110
+ onClick={closeMobileMenu}
111
+ className="block px-2 py-2 rounded-md text-sm text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
112
+ >
113
+ {subItem.label}
114
+ </Link>
115
+ ))}
116
+ </div>
117
+ )}
118
+ </div>
119
+ ))}
100
120
  </div>
101
121
  </div>
102
-
103
- {/* Theme Toggle - Fixed at bottom */}
104
- <div className="border-t border-border/30 p-4">
105
- <div className="flex items-center justify-between px-4 py-3">
106
- <span className="text-sm font-medium text-foreground">{labels.theme}</span>
107
- <ThemeToggle />
108
122
  </div>
123
+
124
+ {!isAuthenticated && (
125
+ <div className="shrink-0 border-t border-border/50 p-4">
126
+ <Link
127
+ href={userMenu?.authPath || '/auth'}
128
+ onClick={closeMobileMenu}
129
+ className="block"
130
+ >
131
+ <Button className="w-full justify-between rounded-lg h-11">
132
+ <span>{labels.signIn}</span>
133
+ <ArrowRight className="h-4 w-4" />
134
+ </Button>
135
+ </Link>
136
+ </div>
137
+ )}
109
138
  </div>
110
- </DrawerContent>
111
- </Drawer>
139
+ </div>
140
+ </>
112
141
  );
113
142
  }
114
143
 
@@ -6,94 +6,206 @@
6
6
 
7
7
  'use client';
8
8
 
9
- import { Menu } from 'lucide-react';
9
+ import { ChevronDown, Menu, X } from 'lucide-react';
10
10
  import Link from 'next/link';
11
- import React, { useMemo } from 'react';
11
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
12
12
 
13
13
  import { useAuth } from '@djangocfg/api/auth';
14
14
  import { useAppT } from '@djangocfg/i18n';
15
- import { Button } from '@djangocfg/ui-core/components';
15
+ import {
16
+ Button,
17
+ } from '@djangocfg/ui-core/components';
16
18
  // useIsMobile is used for conditional rendering
17
19
  import { useIsMobile } from '@djangocfg/ui-core/hooks';
18
20
  // cn is reserved for future conditional styling
19
21
  import { cn as _cn } from '@djangocfg/ui-core/lib';
20
- import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
21
22
 
22
- import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
23
23
  import { UserMenu } from '../../_components/UserMenu';
24
+ import { usePublicLayoutOptional } from '../context';
24
25
 
25
26
  import type { NavigationItem, UserMenuConfig } from '../../types';
26
- import type { I18nLayoutConfig } from '../../AppLayout/AppLayout';
27
27
 
28
28
  interface PublicNavigationProps {
29
29
  logo?: string;
30
- siteName: string;
31
- navigation: NavigationItem[];
30
+ siteName?: string;
31
+ navigation?: NavigationItem[];
32
32
  userMenu?: UserMenuConfig;
33
- onMobileMenuClick: () => void;
34
- /** i18n configuration for locale switching */
35
- i18n?: I18nLayoutConfig;
33
+ containerClassName?: string;
34
+ mobileMenuOpen?: boolean;
35
+ onMobileMenuToggle?: () => void;
36
36
  }
37
37
 
38
- export function PublicNavigation({
39
- logo,
40
- siteName,
41
- navigation,
42
- userMenu,
43
- onMobileMenuClick,
44
- i18n,
45
- }: PublicNavigationProps) {
38
+ export function PublicNavigation(props: PublicNavigationProps = {}) {
39
+ 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;
45
+ const mobileMenuOpen = props.mobileMenuOpen ?? context?.mobileMenuOpen ?? false;
46
+ const toggleMobileMenu = props.onMobileMenuToggle ?? context?.toggleMobileMenu ?? (() => {});
46
47
  const { isAuthenticated: _isAuthenticated } = useAuth();
47
48
  const isMobile = useIsMobile();
48
49
  const t = useAppT();
50
+ const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
51
+ const openTimerRef = useRef<number | null>(null);
52
+ const closeTimerRef = useRef<number | null>(null);
49
53
 
50
54
  const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
51
-
52
- // Nav class (background blur with opacity 0.8)
53
- // bg-background/80 - doesnt work in tailwind4
54
- const navClass = 'sticky top-0 w-full z-50 border-b bg-background';
55
+ const desktopNavItemClass =
56
+ 'inline-flex h-8 items-center rounded-md px-2 text-sm font-medium text-foreground/90 transition-colors hover:text-foreground hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50';
57
+
58
+ const clearOpenTimer = () => {
59
+ if (openTimerRef.current) {
60
+ window.clearTimeout(openTimerRef.current);
61
+ openTimerRef.current = null;
62
+ }
63
+ };
64
+
65
+ const clearCloseTimer = () => {
66
+ if (closeTimerRef.current) {
67
+ window.clearTimeout(closeTimerRef.current);
68
+ closeTimerRef.current = null;
69
+ }
70
+ };
71
+
72
+ const scheduleOpen = (key: string) => {
73
+ clearOpenTimer();
74
+ clearCloseTimer();
75
+ openTimerRef.current = window.setTimeout(() => {
76
+ setOpenDropdownKey(key);
77
+ }, 80);
78
+ };
79
+
80
+ const scheduleClose = (key: string) => {
81
+ clearOpenTimer();
82
+ clearCloseTimer();
83
+ closeTimerRef.current = window.setTimeout(() => {
84
+ setOpenDropdownKey((prev) => (prev === key ? null : prev));
85
+ }, 120);
86
+ };
87
+
88
+ useEffect(() => {
89
+ return () => {
90
+ clearOpenTimer();
91
+ clearCloseTimer();
92
+ };
93
+ }, []);
94
+
95
+ const navClass = 'sticky top-3 z-50 px-4 sm:px-6 lg:px-8';
55
96
 
56
97
  return (
57
- <nav className={navClass} style={{ backgroundColor: 'hsl(var(--background) / 0.6)', backdropFilter: 'blur(10px)' }}>
98
+ <div className={navClass}>
99
+ <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 || ''}`}
101
+ style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
102
+ >
58
103
  <div className="w-full px-4 sm:px-6 lg:px-8">
59
- <div className="flex items-center justify-between py-4">
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
+ >
60
117
  {/* Logo */}
61
- <Link href="/" className="flex items-center gap-2">
62
- {logo && (
63
- <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
64
- )}
65
- <span className="font-bold text-lg">{siteName}</span>
66
- </Link>
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>
125
+ ) : (
126
+ <Link href="/" className="flex items-center gap-2">
127
+ {logo && (
128
+ <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
129
+ )}
130
+ <span className="font-bold text-base">{siteName}</span>
131
+ </Link>
132
+ )}
67
133
 
68
134
  {/* Desktop Navigation */}
69
- <div className="hidden md:flex items-center gap-6">
70
- {navigation.map((item) => (
71
- <Link
72
- key={item.href}
73
- href={item.href}
74
- className="text-sm font-medium hover:text-primary transition-colors"
75
- >
76
- {item.label}
77
- </Link>
78
- ))}
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 (
140
+ <div
141
+ key={dropdownKey}
142
+ className="relative"
143
+ onMouseEnter={() => scheduleOpen(dropdownKey)}
144
+ onMouseLeave={() => scheduleClose(dropdownKey)}
145
+ >
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
+ ))}
187
+ </div>
188
+ )}
189
+ </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
+ })}
79
203
  </div>
80
204
 
81
205
  {/* User Menu / Actions */}
82
206
  <div className="flex items-center gap-4">
83
207
  {!isMobile && (
84
208
  <>
85
- {/* Locale Switcher */}
86
- {i18n && (
87
- <LocaleSwitcher
88
- locale={i18n.locale}
89
- locales={i18n.locales}
90
- onChange={i18n.onLocaleChange}
91
- />
92
- )}
93
-
94
- {/* Theme Toggle */}
95
- <ThemeToggle />
96
-
97
209
  {/* User Menu */}
98
210
  <UserMenu
99
211
  variant="desktop"
@@ -108,16 +220,18 @@ export function PublicNavigation({
108
220
  <Button
109
221
  variant="ghost"
110
222
  size="icon"
111
- onClick={onMobileMenuClick}
112
223
  aria-label={toggleMobileLabel}
224
+ data-mobile-menu-trigger="true"
225
+ className="pointer-events-none"
113
226
  >
114
- <Menu className="h-5 w-5" />
227
+ {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
115
228
  </Button>
116
229
  )}
117
230
  </div>
118
231
  </div>
119
232
  </div>
120
- </nav>
233
+ </nav>
234
+ </div>
121
235
  );
122
236
  }
123
237
 
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext } from 'react';
4
+
5
+ import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
6
+ import type { NavigationItem, UserMenuConfig } from '../types';
7
+
8
+ export interface PublicLayoutContextValue {
9
+ logo?: string;
10
+ siteName: string;
11
+ navigation: NavigationItem[];
12
+ userMenu?: UserMenuConfig;
13
+ i18n?: I18nLayoutConfig;
14
+ containerClassName?: string;
15
+ mobileMenuOpen: boolean;
16
+ toggleMobileMenu: () => void;
17
+ closeMobileMenu: () => void;
18
+ }
19
+
20
+ const PublicLayoutContext = createContext<PublicLayoutContextValue | null>(null);
21
+
22
+ export function PublicLayoutProvider({
23
+ value,
24
+ children,
25
+ }: {
26
+ value: PublicLayoutContextValue;
27
+ children: React.ReactNode;
28
+ }) {
29
+ return <PublicLayoutContext.Provider value={value}>{children}</PublicLayoutContext.Provider>;
30
+ }
31
+
32
+ export function usePublicLayout() {
33
+ const context = useContext(PublicLayoutContext);
34
+ if (!context) {
35
+ throw new Error('usePublicLayout must be used within PublicLayoutProvider');
36
+ }
37
+ return context;
38
+ }
39
+
40
+ export function usePublicLayoutOptional() {
41
+ return useContext(PublicLayoutContext);
42
+ }
43
+
@@ -0,0 +1,2 @@
1
+ export { useFloatingPanel } from './useFloatingPanel';
2
+
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ interface UseFloatingPanelOptions {
6
+ isOpen: boolean;
7
+ onClose: () => void;
8
+ }
9
+
10
+ export function useFloatingPanel({
11
+ isOpen,
12
+ onClose,
13
+ }: UseFloatingPanelOptions) {
14
+ const [isRendered, setIsRendered] = useState(isOpen);
15
+ const [isActive, setIsActive] = useState(isOpen);
16
+ const rafRef = useRef<number | null>(null);
17
+
18
+ useEffect(() => {
19
+ if (isOpen) {
20
+ setIsRendered(true);
21
+ if (rafRef.current) window.cancelAnimationFrame(rafRef.current);
22
+ rafRef.current = window.requestAnimationFrame(() => {
23
+ setIsActive(true);
24
+ });
25
+ return;
26
+ }
27
+
28
+ if (!isRendered) return;
29
+ setIsActive(false);
30
+ }, [isOpen, isRendered]);
31
+
32
+ useEffect(() => {
33
+ return () => {
34
+ if (rafRef.current) window.cancelAnimationFrame(rafRef.current);
35
+ };
36
+ }, []);
37
+
38
+ useEffect(() => {
39
+ if (!isOpen) return;
40
+ const onKeyDown = (event: KeyboardEvent) => {
41
+ if (event.key === 'Escape') onClose();
42
+ };
43
+ window.addEventListener('keydown', onKeyDown);
44
+ return () => window.removeEventListener('keydown', onKeyDown);
45
+ }, [isOpen, onClose]);
46
+
47
+ const onTransitionEnd = (event: React.TransitionEvent<HTMLElement>) => {
48
+ if (event.target !== event.currentTarget) return;
49
+ if (event.propertyName !== 'transform') return;
50
+ if (!isOpen && !isActive) {
51
+ setIsRendered(false);
52
+ }
53
+ };
54
+
55
+ return {
56
+ isRendered,
57
+ isActive,
58
+ onTransitionEnd,
59
+ };
60
+ }
61
+
@@ -4,6 +4,7 @@
4
4
 
5
5
  export { PublicLayout } from './PublicLayout';
6
6
  export type { PublicLayoutProps } from './PublicLayout';
7
+ export { PublicNavigation, PublicMobileDrawer } from './components';
7
8
  export {
8
9
  PublicFooter,
9
10
  FooterProjectInfo,
@@ -15,4 +16,5 @@ export {
15
16
  export type {
16
17
  PublicFooterProps,
17
18
  } from './components/PublicFooter';
19
+ export { PublicLayoutProvider, usePublicLayout } from './context';
18
20
 
@@ -30,7 +30,7 @@
30
30
 
31
31
  'use client';
32
32
 
33
- import { LogOut } from 'lucide-react';
33
+ import { ArrowRight, LogOut } from 'lucide-react';
34
34
  import Link from 'next/link';
35
35
  import React, { useMemo } from 'react';
36
36
 
@@ -108,11 +108,15 @@ export function UserMenu({
108
108
  // Guest user - show sign in button
109
109
  if (variant === 'mobile') {
110
110
  return (
111
- <Link href={authPath}>
112
- <Button variant="default" className="w-full">
113
- {labels.signIn}
114
- </Button>
115
- </Link>
111
+ <div className="pt-4 border-t border-border/50">
112
+ <Link
113
+ href={authPath}
114
+ className="group flex items-center justify-between rounded-lg px-2 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
115
+ >
116
+ <span>{labels.signIn}</span>
117
+ <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
118
+ </Link>
119
+ </div>
116
120
  );
117
121
  }
118
122
  return (
@@ -16,6 +16,8 @@ export interface NavigationItem {
16
16
  icon?: LucideIcon | string;
17
17
  badge?: string | number;
18
18
  external?: boolean;
19
+ /** Optional nested items for desktop dropdown navigation */
20
+ items?: NavigationItem[];
19
21
  }
20
22
 
21
23
  export interface NavigationSection {