@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 +18 -18
- package/src/layouts/PublicLayout/PublicLayout.tsx +28 -24
- package/src/layouts/PublicLayout/components/PublicFooter/FooterMenuSections.tsx +4 -16
- package/src/layouts/PublicLayout/components/PublicFooter/FooterProjectInfo.tsx +2 -2
- package/src/layouts/PublicLayout/components/PublicFooter/PublicFooter.tsx +142 -17
- package/src/layouts/PublicLayout/components/PublicFooter/types.ts +3 -0
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +98 -69
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +170 -56
- package/src/layouts/PublicLayout/context.tsx +43 -0
- package/src/layouts/PublicLayout/hooks/index.ts +2 -0
- package/src/layouts/PublicLayout/hooks/useFloatingPanel.ts +61 -0
- package/src/layouts/PublicLayout/index.ts +2 -0
- package/src/layouts/_components/UserMenu.tsx +10 -6
- package/src/layouts/types/ui.types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
78
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
79
|
-
"@djangocfg/i18n": "^2.1.
|
|
80
|
-
"@djangocfg/monitor": "^2.1.
|
|
81
|
-
"@djangocfg/debuger": "^2.1.
|
|
82
|
-
"@djangocfg/ui-core": "^2.1.
|
|
83
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
84
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
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.
|
|
113
|
-
"@djangocfg/i18n": "^2.1.
|
|
114
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
115
|
-
"@djangocfg/monitor": "^2.1.
|
|
116
|
-
"@djangocfg/debuger": "^2.1.
|
|
117
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
118
|
-
"@djangocfg/ui-core": "^2.1.
|
|
119
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
120
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
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
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
92
|
-
|
|
94
|
+
{/* Main Content */}
|
|
95
|
+
<main className="flex-1">{children}</main>
|
|
93
96
|
|
|
94
|
-
|
|
95
|
-
|
|
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="
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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 {
|
|
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=
|
|
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
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 {
|
|
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
|
|
26
|
-
onClose
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
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
|
-
|
|
47
|
+
quickActions: 'Actions',
|
|
48
|
+
signIn: t('layouts.profile.login'),
|
|
48
49
|
}), [t]);
|
|
49
50
|
|
|
51
|
+
if (!isRendered) return null;
|
|
52
|
+
|
|
50
53
|
return (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
<X className="size-5" />
|
|
69
|
-
<span className="sr-only">{labels.closeMenu}</span>
|
|
70
|
-
</DrawerClose>
|
|
71
|
-
</DrawerHeader>
|
|
81
|
+
)}
|
|
72
82
|
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
111
|
-
|
|
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 {
|
|
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
|
|
31
|
-
navigation
|
|
30
|
+
siteName?: string;
|
|
31
|
+
navigation?: NavigationItem[];
|
|
32
32
|
userMenu?: UserMenuConfig;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
containerClassName?: string;
|
|
34
|
+
mobileMenuOpen?: boolean;
|
|
35
|
+
onMobileMenuToggle?: () => void;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function PublicNavigation({
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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-
|
|
70
|
-
{navigation.map((item) =>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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,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
|
-
<
|
|
112
|
-
<
|
|
113
|
-
{
|
|
114
|
-
|
|
115
|
-
|
|
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 (
|