@djangocfg/layouts 2.1.246 → 2.1.248

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.246",
3
+ "version": "2.1.248",
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.246",
78
- "@djangocfg/centrifugo": "^2.1.246",
79
- "@djangocfg/i18n": "^2.1.246",
80
- "@djangocfg/monitor": "^2.1.246",
81
- "@djangocfg/debuger": "^2.1.246",
82
- "@djangocfg/ui-core": "^2.1.246",
83
- "@djangocfg/ui-nextjs": "^2.1.246",
84
- "@djangocfg/ui-tools": "^2.1.246",
77
+ "@djangocfg/api": "^2.1.248",
78
+ "@djangocfg/centrifugo": "^2.1.248",
79
+ "@djangocfg/i18n": "^2.1.248",
80
+ "@djangocfg/monitor": "^2.1.248",
81
+ "@djangocfg/debuger": "^2.1.248",
82
+ "@djangocfg/ui-core": "^2.1.248",
83
+ "@djangocfg/ui-nextjs": "^2.1.248",
84
+ "@djangocfg/ui-tools": "^2.1.248",
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.246",
113
- "@djangocfg/i18n": "^2.1.246",
114
- "@djangocfg/centrifugo": "^2.1.246",
115
- "@djangocfg/monitor": "^2.1.246",
116
- "@djangocfg/debuger": "^2.1.246",
117
- "@djangocfg/typescript-config": "^2.1.246",
118
- "@djangocfg/ui-core": "^2.1.246",
119
- "@djangocfg/ui-nextjs": "^2.1.246",
120
- "@djangocfg/ui-tools": "^2.1.246",
112
+ "@djangocfg/api": "^2.1.248",
113
+ "@djangocfg/i18n": "^2.1.248",
114
+ "@djangocfg/centrifugo": "^2.1.248",
115
+ "@djangocfg/monitor": "^2.1.248",
116
+ "@djangocfg/debuger": "^2.1.248",
117
+ "@djangocfg/typescript-config": "^2.1.248",
118
+ "@djangocfg/ui-core": "^2.1.248",
119
+ "@djangocfg/ui-nextjs": "^2.1.248",
120
+ "@djangocfg/ui-tools": "^2.1.248",
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,114 @@
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 { usePublicLayout } from '../context';
19
+ import { useFloatingPanel } from '../hooks';
21
20
 
22
- import type { NavigationItem, UserMenuConfig } from '../../types';
23
-
24
- interface PublicMobileDrawerProps {
25
- isOpen: boolean;
26
- onClose: () => void;
27
- logo?: string;
28
- siteName: string;
29
- navigation: NavigationItem[];
30
- userMenu?: UserMenuConfig;
31
- }
32
-
33
- export function PublicMobileDrawer({
34
- isOpen,
35
- onClose,
36
- logo,
37
- siteName,
38
- navigation,
39
- userMenu,
40
- }: PublicMobileDrawerProps) {
41
- const { isAuthenticated: _isAuthenticated } = useAuth();
21
+ export function PublicMobileDrawer() {
22
+ const {
23
+ mobileMenuOpen,
24
+ closeMobileMenu,
25
+ navigation,
26
+ userMenu,
27
+ containerClassName,
28
+ } = usePublicLayout();
29
+ const { isAuthenticated } = useAuth();
42
30
  const t = useAppT();
31
+ const { isRendered, isActive, onTransitionEnd } = useFloatingPanel({
32
+ isOpen: mobileMenuOpen,
33
+ onClose: closeMobileMenu,
34
+ });
43
35
 
44
36
  const labels = useMemo(() => ({
45
- closeMenu: t('layouts.mobile.closeMenu'),
46
37
  menu: t('layouts.navigation.menu'),
47
- theme: t('layouts.theme.toggle'),
38
+ quickActions: 'Actions',
39
+ signIn: t('layouts.profile.login'),
48
40
  }), [t]);
49
41
 
42
+ if (!isRendered) return null;
43
+
50
44
  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>
45
+ <>
46
+ {mobileMenuOpen && (
47
+ <button
48
+ type="button"
49
+ aria-label={t('layouts.mobile.closeMenu')}
50
+ className="fixed inset-0 z-[998] lg:hidden bg-black/35 transition-opacity duration-200"
51
+ onClick={closeMobileMenu}
52
+ />
53
+ )}
54
+ <div className="fixed inset-x-0 top-20 z-1000 lg:hidden px-4 sm:px-6 lg:px-8">
55
+ <div
56
+ onTransitionEnd={onTransitionEnd}
57
+ className={`mx-auto w-full rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl shadow-2xl overflow-hidden transform-gpu will-change-transform transition-[transform,opacity] duration-[220ms] ease-out ${containerClassName || ''} ${
58
+ isActive
59
+ ? 'opacity-100 translate-y-0 scale-100'
60
+ : 'opacity-0 -translate-y-2 scale-[0.985] pointer-events-none'
61
+ }`}
62
+ style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
63
+ >
64
+ {/* Content */}
65
+ <div className="max-h-[min(72vh,560px)] overflow-y-auto px-4 py-4 space-y-5">
66
+ {isAuthenticated && (
67
+ <div className="px-2">
68
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
69
+ {labels.quickActions}
70
+ </h3>
66
71
  </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>
72
+ )}
72
73
 
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
- />
74
+ {isAuthenticated && (
75
+ <UserMenu variant="mobile" groups={userMenu?.groups} authPath={userMenu?.authPath} />
76
+ )}
81
77
 
82
- {/* Navigation Items */}
78
+ {/* Navigation Items */}
79
+ <div className="space-y-2">
80
+ <div className="px-2">
81
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
82
+ {labels.menu}
83
+ </h3>
84
+ </div>
83
85
  <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) => (
91
- <Link
92
- key={item.href}
93
- 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"
95
- >
96
- {item.label}
97
- </Link>
98
- ))}
99
- </div>
86
+ {navigation.map((item) => (
87
+ <Link
88
+ key={item.href}
89
+ href={item.href}
90
+ onClick={closeMobileMenu}
91
+ 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"
92
+ >
93
+ {item.label}
94
+ </Link>
95
+ ))}
100
96
  </div>
101
97
  </div>
102
98
 
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 />
99
+ {!isAuthenticated && (
100
+ <div className="pt-4 border-t border-border/50">
101
+ <Link
102
+ href={userMenu?.authPath || '/auth'}
103
+ onClick={closeMobileMenu}
104
+ className="block"
105
+ >
106
+ <Button className="w-full justify-between rounded-lg h-11">
107
+ <span>{labels.signIn}</span>
108
+ <ArrowRight className="h-4 w-4" />
109
+ </Button>
110
+ </Link>
111
+ </div>
112
+ )}
108
113
  </div>
109
114
  </div>
110
- </DrawerContent>
111
- </Drawer>
115
+ </div>
116
+ </>
112
117
  );
113
118
  }
114
119
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  'use client';
8
8
 
9
- import { Menu } from 'lucide-react';
9
+ import { Menu, X } from 'lucide-react';
10
10
  import Link from 'next/link';
11
11
  import React, { useMemo } from 'react';
12
12
 
@@ -17,53 +17,64 @@ import { Button } from '@djangocfg/ui-core/components';
17
17
  import { useIsMobile } from '@djangocfg/ui-core/hooks';
18
18
  // cn is reserved for future conditional styling
19
19
  import { cn as _cn } from '@djangocfg/ui-core/lib';
20
- import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
21
20
 
22
- import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
23
21
  import { UserMenu } from '../../_components/UserMenu';
24
-
25
- import type { NavigationItem, UserMenuConfig } from '../../types';
26
- import type { I18nLayoutConfig } from '../../AppLayout/AppLayout';
27
-
28
- interface PublicNavigationProps {
29
- logo?: string;
30
- siteName: string;
31
- navigation: NavigationItem[];
32
- userMenu?: UserMenuConfig;
33
- onMobileMenuClick: () => void;
34
- /** i18n configuration for locale switching */
35
- i18n?: I18nLayoutConfig;
36
- }
37
-
38
- export function PublicNavigation({
39
- logo,
40
- siteName,
41
- navigation,
42
- userMenu,
43
- onMobileMenuClick,
44
- i18n,
45
- }: PublicNavigationProps) {
22
+ import { usePublicLayout } from '../context';
23
+
24
+ export function PublicNavigation() {
25
+ const {
26
+ logo,
27
+ siteName,
28
+ navigation,
29
+ userMenu,
30
+ containerClassName,
31
+ mobileMenuOpen,
32
+ toggleMobileMenu,
33
+ } = usePublicLayout();
46
34
  const { isAuthenticated: _isAuthenticated } = useAuth();
47
35
  const isMobile = useIsMobile();
48
36
  const t = useAppT();
49
37
 
50
38
  const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
51
39
 
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';
40
+ const navClass = 'sticky top-3 z-50 px-4 sm:px-6 lg:px-8';
55
41
 
56
42
  return (
57
- <nav className={navClass} style={{ backgroundColor: 'hsl(var(--background) / 0.6)', backdropFilter: 'blur(10px)' }}>
43
+ <div className={navClass}>
44
+ <nav
45
+ 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 || ''}`}
46
+ style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
47
+ >
58
48
  <div className="w-full px-4 sm:px-6 lg:px-8">
59
- <div className="flex items-center justify-between py-4">
49
+ <div
50
+ className="flex items-center justify-between py-3.5"
51
+ onClick={isMobile ? toggleMobileMenu : undefined}
52
+ onKeyDown={isMobile ? (event) => {
53
+ if (event.key === 'Enter' || event.key === ' ') {
54
+ event.preventDefault();
55
+ toggleMobileMenu();
56
+ }
57
+ } : undefined}
58
+ role={isMobile ? 'button' : undefined}
59
+ tabIndex={isMobile ? 0 : undefined}
60
+ aria-label={isMobile ? toggleMobileLabel : undefined}
61
+ >
60
62
  {/* 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>
63
+ {isMobile ? (
64
+ <div className="flex items-center gap-2">
65
+ {logo && (
66
+ <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
67
+ )}
68
+ <span className="font-bold text-lg">{siteName}</span>
69
+ </div>
70
+ ) : (
71
+ <Link href="/" className="flex items-center gap-2">
72
+ {logo && (
73
+ <img src={logo} alt={siteName} className="h-6 w-auto object-contain" />
74
+ )}
75
+ <span className="font-bold text-lg">{siteName}</span>
76
+ </Link>
77
+ )}
67
78
 
68
79
  {/* Desktop Navigation */}
69
80
  <div className="hidden md:flex items-center gap-6">
@@ -82,18 +93,6 @@ export function PublicNavigation({
82
93
  <div className="flex items-center gap-4">
83
94
  {!isMobile && (
84
95
  <>
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
96
  {/* User Menu */}
98
97
  <UserMenu
99
98
  variant="desktop"
@@ -108,16 +107,18 @@ export function PublicNavigation({
108
107
  <Button
109
108
  variant="ghost"
110
109
  size="icon"
111
- onClick={onMobileMenuClick}
112
110
  aria-label={toggleMobileLabel}
111
+ data-mobile-menu-trigger="true"
112
+ className="pointer-events-none"
113
113
  >
114
- <Menu className="h-5 w-5" />
114
+ {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
115
115
  </Button>
116
116
  )}
117
117
  </div>
118
118
  </div>
119
119
  </div>
120
- </nav>
120
+ </nav>
121
+ </div>
121
122
  );
122
123
  }
123
124
 
@@ -0,0 +1,39 @@
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
+
@@ -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
+
@@ -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 (