@djangocfg/layouts 2.1.247 → 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 +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 +84 -79
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +52 -51
- package/src/layouts/PublicLayout/context.tsx +39 -0
- package/src/layouts/PublicLayout/hooks/index.ts +2 -0
- package/src/layouts/PublicLayout/hooks/useFloatingPanel.ts +61 -0
- package/src/layouts/_components/UserMenu.tsx +10 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
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.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.
|
|
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.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
|
-
<
|
|
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,114 @@
|
|
|
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 { usePublicLayout } from '../context';
|
|
19
|
+
import { useFloatingPanel } from '../hooks';
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
38
|
+
quickActions: 'Actions',
|
|
39
|
+
signIn: t('layouts.profile.login'),
|
|
48
40
|
}), [t]);
|
|
49
41
|
|
|
42
|
+
if (!isRendered) return null;
|
|
43
|
+
|
|
50
44
|
return (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
<X className="size-5" />
|
|
69
|
-
<span className="sr-only">{labels.closeMenu}</span>
|
|
70
|
-
</DrawerClose>
|
|
71
|
-
</DrawerHeader>
|
|
72
|
+
)}
|
|
72
73
|
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
<
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
</
|
|
111
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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,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
|
-
<
|
|
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 (
|