@djangocfg/layouts 2.1.264 → 2.1.267
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/README.md +113 -4
- package/package.json +19 -18
- package/src/hooks/index.ts +1 -1
- package/src/hooks/usePathnameWithoutLocale.ts +35 -19
- package/src/layouts/AppLayout/AppLayout.tsx +15 -4
- package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +50 -6
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +206 -235
- package/src/layouts/ProfileLayout/components/AvatarSection.tsx +2 -3
- package/src/layouts/ProfileLayout/components/DeleteAccountSection.tsx +22 -106
- package/src/layouts/ProfileLayout/components/EditableField.tsx +15 -10
- package/src/layouts/ProfileLayout/components/Section.tsx +1 -1
- package/src/layouts/ProfileLayout/components/TwoFactorSection.tsx +255 -215
- package/src/layouts/ProfileLayout/context.tsx +108 -16
- package/src/layouts/ProfileLayout/index.ts +1 -1
- package/src/layouts/PublicLayout/PublicLayout.tsx +18 -0
- package/src/layouts/PublicLayout/components/NavActions.tsx +50 -0
- package/src/layouts/PublicLayout/components/NavBrand.tsx +26 -0
- package/src/layouts/PublicLayout/components/NavDesktopItems.tsx +207 -0
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +1 -1
- package/src/layouts/PublicLayout/components/PublicNavbar.tsx +44 -6
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +199 -396
- package/src/layouts/PublicLayout/hooks/index.ts +5 -1
- package/src/layouts/PublicLayout/hooks/useDropdownMenu.ts +58 -0
- package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +61 -0
- package/src/layouts/PublicLayout/hooks/useNavbarViewportVars.ts +46 -0
- package/src/layouts/PublicLayout/index.ts +4 -0
- package/src/layouts/PublicLayout/navbarTypes.ts +17 -0
- package/src/utils/pathMatcher.ts +6 -3
- package/src/layouts/ProfileLayout/.claude/.sidecar/activity.jsonl +0 -2
- package/src/layouts/ProfileLayout/.claude/.sidecar/history/2026-03-15.md +0 -35
- package/src/layouts/ProfileLayout/.claude/.sidecar/review.md +0 -35
- package/src/layouts/ProfileLayout/.claude/.sidecar/scan.log +0 -3
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-001.md +0 -18
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-002.md +0 -19
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-003.md +0 -18
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-004.md +0 -18
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-005.md +0 -18
- package/src/layouts/ProfileLayout/.claude/.sidecar/usage.json +0 -5
|
@@ -1,31 +1,123 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { createContext, useCallback, useContext,
|
|
3
|
+
import React, { createContext, useCallback, useContext, useMemo } from 'react';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
6
|
+
import { toast } from '@djangocfg/ui-core/hooks';
|
|
7
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
8
|
+
|
|
9
|
+
import { profileLogger } from '../../utils/logger';
|
|
10
|
+
import { useLogout } from '../../hooks';
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// Types
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface ProfileLabels {
|
|
17
|
+
title: string;
|
|
18
|
+
personalInfo: string;
|
|
19
|
+
work: string;
|
|
20
|
+
security: string;
|
|
21
|
+
firstName: string;
|
|
22
|
+
lastName: string;
|
|
23
|
+
phone: string;
|
|
24
|
+
company: string;
|
|
25
|
+
position: string;
|
|
26
|
+
addFirstName: string;
|
|
27
|
+
addLastName: string;
|
|
28
|
+
addPhone: string;
|
|
29
|
+
addCompany: string;
|
|
30
|
+
addPosition: string;
|
|
31
|
+
signOut: string;
|
|
32
|
+
deleteAccount: string;
|
|
33
|
+
profileUpdated: string;
|
|
34
|
+
failedToUpdate: string;
|
|
35
|
+
notAuthenticated: string;
|
|
36
|
+
pleaseLogIn: string;
|
|
37
|
+
loading: string;
|
|
38
|
+
save: string;
|
|
39
|
+
saving: string;
|
|
40
|
+
cancel: string;
|
|
41
|
+
}
|
|
6
42
|
|
|
7
43
|
interface ProfileContextValue {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
44
|
+
labels: ProfileLabels;
|
|
45
|
+
onLogout: () => void;
|
|
46
|
+
onFieldSave: (field: string, value: string) => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
// Context
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const ProfileContext = createContext<ProfileContextValue | null>(null);
|
|
54
|
+
|
|
55
|
+
export const useProfileContext = (): ProfileContextValue => {
|
|
56
|
+
const ctx = useContext(ProfileContext);
|
|
57
|
+
if (!ctx) throw new Error('useProfileContext must be used within ProfileProvider');
|
|
58
|
+
return ctx;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// Provider
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
interface ProfileProviderProps {
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
title?: string;
|
|
68
|
+
onUnauthenticated?: () => void;
|
|
11
69
|
}
|
|
12
70
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
});
|
|
71
|
+
export const ProfileProvider: React.FC<ProfileProviderProps> = ({
|
|
72
|
+
children,
|
|
73
|
+
title,
|
|
74
|
+
}) => {
|
|
75
|
+
const { updateProfile } = useAuth();
|
|
76
|
+
const t = useAppT();
|
|
77
|
+
const onLogout = useLogout();
|
|
18
78
|
|
|
19
|
-
|
|
20
|
-
|
|
79
|
+
const labels = useMemo<ProfileLabels>(() => ({
|
|
80
|
+
title: title || t('layouts.profilePage.title'),
|
|
81
|
+
personalInfo: t('layouts.profilePage.personalInfo'),
|
|
82
|
+
work: t('layouts.profilePage.work'),
|
|
83
|
+
security: t('layouts.profilePage.security'),
|
|
84
|
+
firstName: t('layouts.profilePage.firstName'),
|
|
85
|
+
lastName: t('layouts.profilePage.lastName'),
|
|
86
|
+
phone: t('layouts.profilePage.phone'),
|
|
87
|
+
company: t('layouts.profilePage.company'),
|
|
88
|
+
position: t('layouts.profilePage.position'),
|
|
89
|
+
addFirstName: t('layouts.profilePage.addFirstName') || 'Add first name',
|
|
90
|
+
addLastName: t('layouts.profilePage.addLastName') || 'Add last name',
|
|
91
|
+
addPhone: t('layouts.profilePage.addPhone') || 'Add phone number',
|
|
92
|
+
addCompany: t('layouts.profilePage.addCompany') || 'Add company',
|
|
93
|
+
addPosition: t('layouts.profilePage.addPosition') || 'Add position',
|
|
94
|
+
signOut: t('layouts.profilePage.signOut'),
|
|
95
|
+
deleteAccount: t('layouts.profilePage.deleteAccount'),
|
|
96
|
+
profileUpdated: t('layouts.profilePage.profileUpdated'),
|
|
97
|
+
failedToUpdate: t('layouts.profilePage.failedToUpdate'),
|
|
98
|
+
notAuthenticated: t('layouts.profilePage.notAuthenticated'),
|
|
99
|
+
pleaseLogIn: t('layouts.profilePage.pleaseLogIn'),
|
|
100
|
+
loading: t('ui.states.loading'),
|
|
101
|
+
save: t('layouts.profilePage.save'),
|
|
102
|
+
saving: t('layouts.profilePage.saving'),
|
|
103
|
+
cancel: t('layouts.profilePage.cancel'),
|
|
104
|
+
}), [t, title]);
|
|
21
105
|
|
|
22
|
-
const
|
|
106
|
+
const onFieldSave = useCallback(async (field: string, value: string) => {
|
|
107
|
+
try {
|
|
108
|
+
await updateProfile({ [field]: value });
|
|
109
|
+
toast.success(labels.profileUpdated);
|
|
110
|
+
} catch (error: unknown) {
|
|
111
|
+
profileLogger.error('Profile update error:', error);
|
|
112
|
+
const apiErr = error as { response?: Record<string, string[]> };
|
|
113
|
+
toast.error(apiErr?.response?.[field]?.[0] || labels.failedToUpdate);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}, [updateProfile, labels]);
|
|
23
117
|
|
|
24
118
|
return (
|
|
25
|
-
<ProfileContext.Provider value={{
|
|
119
|
+
<ProfileContext.Provider value={{ labels, onLogout, onFieldSave }}>
|
|
26
120
|
{children}
|
|
27
121
|
</ProfileContext.Provider>
|
|
28
122
|
);
|
|
29
123
|
};
|
|
30
|
-
|
|
31
|
-
export const useProfileContext = () => useContext(ProfileContext);
|
|
@@ -53,6 +53,20 @@ export interface PublicLayoutProps {
|
|
|
53
53
|
*/
|
|
54
54
|
navbar?: ReactNode;
|
|
55
55
|
footer?: ReactNode;
|
|
56
|
+
/**
|
|
57
|
+
* Optional background layer rendered behind navbar and content.
|
|
58
|
+
* Use `position: fixed; inset: 0; pointer-events: none` (or similar) on the element
|
|
59
|
+
* so it fills the viewport without affecting layout flow.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* backgroundSlot={
|
|
64
|
+
* <div className="fixed inset-0 -z-10 pointer-events-none"
|
|
65
|
+
* style={{ background: 'radial-gradient(ellipse 60% 50% at 10% 0%, violet, transparent)' }} />
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
backgroundSlot?: ReactNode;
|
|
56
70
|
/**
|
|
57
71
|
* When `auto` (default), `<main>` gets a small top offset from `PublicNavigation` surface
|
|
58
72
|
* (`floating` vs `flush`). Set `none` if the page controls spacing itself.
|
|
@@ -105,6 +119,7 @@ export function PublicLayout({
|
|
|
105
119
|
children,
|
|
106
120
|
navbar,
|
|
107
121
|
footer,
|
|
122
|
+
backgroundSlot,
|
|
108
123
|
contentTopSpacing = 'auto',
|
|
109
124
|
contentBottomSpacing = 'auto',
|
|
110
125
|
}: PublicLayoutProps) {
|
|
@@ -134,6 +149,9 @@ export function PublicLayout({
|
|
|
134
149
|
|
|
135
150
|
return (
|
|
136
151
|
<PublicLayoutProvider value={contextValue}>
|
|
152
|
+
{/* Background slot — renders behind everything, including the sticky navbar */}
|
|
153
|
+
{backgroundSlot ?? null}
|
|
154
|
+
|
|
137
155
|
<div className="min-h-screen flex flex-col">
|
|
138
156
|
{navbar ?? null}
|
|
139
157
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Menu, X } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
7
|
+
|
|
8
|
+
import { UserMenu } from '../../_components/UserMenu';
|
|
9
|
+
import type { UserMenuConfig } from '../../types';
|
|
10
|
+
|
|
11
|
+
interface NavActionsProps {
|
|
12
|
+
userMenu?: UserMenuConfig;
|
|
13
|
+
mobileMenuOpen: boolean;
|
|
14
|
+
onMobileMenuToggle: () => void;
|
|
15
|
+
toggleMobileLabel: string;
|
|
16
|
+
/** When true, mobile trigger is always visible (not hidden on lg+). Used for `split` layout. */
|
|
17
|
+
forceShowMobileTrigger?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function NavActions({
|
|
21
|
+
userMenu,
|
|
22
|
+
mobileMenuOpen,
|
|
23
|
+
onMobileMenuToggle,
|
|
24
|
+
toggleMobileLabel,
|
|
25
|
+
forceShowMobileTrigger = false,
|
|
26
|
+
}: NavActionsProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex items-center gap-4">
|
|
29
|
+
<div className="hidden lg:flex">
|
|
30
|
+
<UserMenu
|
|
31
|
+
variant="desktop"
|
|
32
|
+
groups={userMenu?.groups}
|
|
33
|
+
authPath={userMenu?.authPath}
|
|
34
|
+
i18n={userMenu?.i18n}
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<Button
|
|
39
|
+
variant="ghost"
|
|
40
|
+
size="icon"
|
|
41
|
+
aria-label={toggleMobileLabel}
|
|
42
|
+
data-mobile-menu-trigger="true"
|
|
43
|
+
className={forceShowMobileTrigger ? 'rounded-full' : 'lg:hidden rounded-full'}
|
|
44
|
+
onClick={onMobileMenuToggle}
|
|
45
|
+
>
|
|
46
|
+
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
|
47
|
+
</Button>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import React, { type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
interface NavBrandProps {
|
|
7
|
+
brand?: ReactNode;
|
|
8
|
+
brandHref?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function NavBrand({ brand, brandHref = '/' }: NavBrandProps) {
|
|
12
|
+
if (brand == null || brand === '' || brand === false) return null;
|
|
13
|
+
|
|
14
|
+
if (typeof brand === 'string') {
|
|
15
|
+
return (
|
|
16
|
+
<Link
|
|
17
|
+
href={brandHref}
|
|
18
|
+
className="font-bold text-[15px] text-foreground hover:opacity-90 transition-opacity"
|
|
19
|
+
>
|
|
20
|
+
{brand}
|
|
21
|
+
</Link>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return <>{brand}</>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronDown } from 'lucide-react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
8
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
9
|
+
|
|
10
|
+
import type { NavigationItem } from '../../types';
|
|
11
|
+
import type { UseDropdownMenuReturn } from '../hooks/useDropdownMenu';
|
|
12
|
+
import type { PublicDesktopDropdownRenderer } from './PublicNavigation';
|
|
13
|
+
|
|
14
|
+
interface NavDesktopItemsProps {
|
|
15
|
+
primaryItems: NavigationItem[];
|
|
16
|
+
overflowItems: NavigationItem[];
|
|
17
|
+
isActivePath: (href: string) => boolean;
|
|
18
|
+
isGroupActive: (item: NavigationItem) => boolean;
|
|
19
|
+
dropdown: UseDropdownMenuReturn;
|
|
20
|
+
renderDesktopDropdown?: PublicDesktopDropdownRenderer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const navItemCls = cn(
|
|
24
|
+
'inline-flex min-h-9 items-center justify-center gap-1 rounded-full border-0 px-4 py-1.5 text-sm font-medium',
|
|
25
|
+
'ring-0 focus-visible:ring-0',
|
|
26
|
+
'text-foreground/90 transition-colors',
|
|
27
|
+
'hover:bg-accent/55 hover:text-foreground',
|
|
28
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const navItemActiveCls =
|
|
32
|
+
'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted dark:shadow-none';
|
|
33
|
+
|
|
34
|
+
const labelCls = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
|
|
35
|
+
|
|
36
|
+
function subMenuLinkCls(active: boolean) {
|
|
37
|
+
return cn(
|
|
38
|
+
'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-full border-0 px-4 py-2 text-sm font-medium transition-colors',
|
|
39
|
+
'hover:bg-accent/55',
|
|
40
|
+
active
|
|
41
|
+
? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted/90 dark:shadow-none'
|
|
42
|
+
: 'border-0 text-foreground/90',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const popoverCls =
|
|
47
|
+
'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]';
|
|
48
|
+
|
|
49
|
+
export function NavDesktopItems({
|
|
50
|
+
primaryItems,
|
|
51
|
+
overflowItems,
|
|
52
|
+
isActivePath,
|
|
53
|
+
isGroupActive,
|
|
54
|
+
dropdown,
|
|
55
|
+
renderDesktopDropdown,
|
|
56
|
+
}: NavDesktopItemsProps) {
|
|
57
|
+
const { openDropdownKey, scheduleOpen, scheduleClose, closeDropdown } = dropdown;
|
|
58
|
+
|
|
59
|
+
const renderItem = (item: NavigationItem) => {
|
|
60
|
+
if (item.items && item.items.length > 0) {
|
|
61
|
+
const key = `${item.label}-${item.href}`;
|
|
62
|
+
const isOpen = openDropdownKey === key;
|
|
63
|
+
const isActive = isGroupActive(item);
|
|
64
|
+
|
|
65
|
+
const defaultItems = (
|
|
66
|
+
<>
|
|
67
|
+
{item.items.map((sub) => {
|
|
68
|
+
const subActive = isActivePath(sub.href);
|
|
69
|
+
return (
|
|
70
|
+
<div key={`${item.label}-${sub.href}`} className="rounded-full">
|
|
71
|
+
{sub.external ? (
|
|
72
|
+
<a
|
|
73
|
+
href={sub.href}
|
|
74
|
+
target="_blank"
|
|
75
|
+
rel="noopener noreferrer"
|
|
76
|
+
className={subMenuLinkCls(subActive)}
|
|
77
|
+
>
|
|
78
|
+
<span className="min-w-0 truncate" title={sub.label}>{sub.label}</span>
|
|
79
|
+
</a>
|
|
80
|
+
) : (
|
|
81
|
+
<Link href={sub.href} className={subMenuLinkCls(subActive)}>
|
|
82
|
+
<span className="min-w-0 truncate" title={sub.label}>{sub.label}</span>
|
|
83
|
+
</Link>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
})}
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const defaultPopover = (
|
|
92
|
+
<div
|
|
93
|
+
className={popoverCls}
|
|
94
|
+
onMouseEnter={() => { scheduleOpen(key); }}
|
|
95
|
+
onMouseLeave={() => scheduleClose(key)}
|
|
96
|
+
>
|
|
97
|
+
{defaultItems}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
key={key}
|
|
104
|
+
className="relative"
|
|
105
|
+
onMouseEnter={() => scheduleOpen(key)}
|
|
106
|
+
onMouseLeave={() => scheduleClose(key)}
|
|
107
|
+
>
|
|
108
|
+
<Button
|
|
109
|
+
variant="ghost"
|
|
110
|
+
size="sm"
|
|
111
|
+
className={cn(
|
|
112
|
+
'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
|
|
113
|
+
navItemCls,
|
|
114
|
+
(isOpen || isActive) && navItemActiveCls,
|
|
115
|
+
isOpen && 'border-0 dark:border dark:border-border',
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
<span className={labelCls} title={item.label}>{item.label}</span>
|
|
119
|
+
<span className="inline-flex size-3.5 shrink-0 items-center justify-center" aria-hidden>
|
|
120
|
+
<ChevronDown
|
|
121
|
+
className={cn(
|
|
122
|
+
'size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform',
|
|
123
|
+
isOpen && 'rotate-180',
|
|
124
|
+
)}
|
|
125
|
+
/>
|
|
126
|
+
</span>
|
|
127
|
+
</Button>
|
|
128
|
+
|
|
129
|
+
{isOpen && (
|
|
130
|
+
renderDesktopDropdown
|
|
131
|
+
? renderDesktopDropdown({ item, isOpen, isActive, close: closeDropdown, defaultPopover, defaultItems })
|
|
132
|
+
: defaultPopover
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const active = isActivePath(item.href);
|
|
139
|
+
return (
|
|
140
|
+
<Link
|
|
141
|
+
key={item.href}
|
|
142
|
+
href={item.href}
|
|
143
|
+
className={cn(navItemCls, active && navItemActiveCls)}
|
|
144
|
+
>
|
|
145
|
+
<span className={labelCls} title={item.label}>{item.label}</span>
|
|
146
|
+
</Link>
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const hasOverflow = overflowItems.length > 0;
|
|
151
|
+
const isMoreOpen = openDropdownKey === '__overflow-more';
|
|
152
|
+
const moreActive = isMoreOpen || overflowItems.some((i) => isGroupActive(i));
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<>
|
|
156
|
+
{primaryItems.map(renderItem)}
|
|
157
|
+
|
|
158
|
+
{hasOverflow && (
|
|
159
|
+
<div
|
|
160
|
+
className="relative"
|
|
161
|
+
onMouseEnter={() => scheduleOpen('__overflow-more')}
|
|
162
|
+
onMouseLeave={() => scheduleClose('__overflow-more')}
|
|
163
|
+
>
|
|
164
|
+
<Button
|
|
165
|
+
variant="ghost"
|
|
166
|
+
size="sm"
|
|
167
|
+
className={cn(
|
|
168
|
+
'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
|
|
169
|
+
navItemCls,
|
|
170
|
+
moreActive && navItemActiveCls,
|
|
171
|
+
isMoreOpen && 'border-0 dark:border dark:border-border',
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
<span className={labelCls}>More</span>
|
|
175
|
+
<span className="inline-flex size-3.5 shrink-0 items-center justify-center" aria-hidden>
|
|
176
|
+
<ChevronDown
|
|
177
|
+
className={cn(
|
|
178
|
+
'size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform',
|
|
179
|
+
isMoreOpen && 'rotate-180',
|
|
180
|
+
)}
|
|
181
|
+
/>
|
|
182
|
+
</span>
|
|
183
|
+
</Button>
|
|
184
|
+
|
|
185
|
+
{isMoreOpen && (
|
|
186
|
+
<div
|
|
187
|
+
className={cn(popoverCls, 'left-auto right-0')}
|
|
188
|
+
onMouseEnter={() => { scheduleOpen('__overflow-more'); }}
|
|
189
|
+
onMouseLeave={() => scheduleClose('__overflow-more')}
|
|
190
|
+
>
|
|
191
|
+
{overflowItems.map((item) => {
|
|
192
|
+
const active = isGroupActive(item);
|
|
193
|
+
return (
|
|
194
|
+
<div key={`overflow-${item.href}`} className="rounded-full">
|
|
195
|
+
<Link href={item.href} className={subMenuLinkCls(active)}>
|
|
196
|
+
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
|
|
197
|
+
</Link>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
})}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -108,7 +108,7 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
|
|
|
108
108
|
}}
|
|
109
109
|
>
|
|
110
110
|
{/* Scrollable content */}
|
|
111
|
-
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5">
|
|
111
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 pb-10 space-y-5">
|
|
112
112
|
{hasSessionUser && (
|
|
113
113
|
<div className="px-2">
|
|
114
114
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
@@ -11,6 +11,8 @@ import type {
|
|
|
11
11
|
PublicNavbarShellConfig,
|
|
12
12
|
PublicNavbarPosition,
|
|
13
13
|
PublicNavbarVariant,
|
|
14
|
+
PublicNavLayout,
|
|
15
|
+
PublicNavbarHeight,
|
|
14
16
|
} from '../navbarTypes';
|
|
15
17
|
|
|
16
18
|
export interface PublicNavbarConfig {
|
|
@@ -25,6 +27,39 @@ export interface PublicNavbarConfig {
|
|
|
25
27
|
navbarPosition?: PublicNavbarPosition;
|
|
26
28
|
renderDesktopDropdown?: PublicDesktopDropdownRenderer;
|
|
27
29
|
desktopMaxPrimaryItems?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Desktop nav arrangement.
|
|
32
|
+
* - `default` — brand left | nav centered | actions right
|
|
33
|
+
* - `brand-left` — brand left | nav after brand | actions pushed right
|
|
34
|
+
* - `centered` — all items centered in one row
|
|
35
|
+
* - `split` — brand left | actions right | no desktop nav (drawer only)
|
|
36
|
+
* @default 'default'
|
|
37
|
+
*/
|
|
38
|
+
navLayout?: PublicNavLayout;
|
|
39
|
+
/**
|
|
40
|
+
* Navbar vertical padding / height.
|
|
41
|
+
* - `sm` → compact
|
|
42
|
+
* - `md` → default
|
|
43
|
+
* - `lg` → tall
|
|
44
|
+
* @default 'md'
|
|
45
|
+
*/
|
|
46
|
+
navbarHeight?: PublicNavbarHeight;
|
|
47
|
+
/**
|
|
48
|
+
* Slide navbar off-screen on scroll-down; restore on scroll-up.
|
|
49
|
+
* @default false
|
|
50
|
+
*/
|
|
51
|
+
hideNavOnScroll?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Transparent at page top, opaque after scrolling.
|
|
54
|
+
* Pair with `navbarVariant="floating"` for best results.
|
|
55
|
+
* @default false
|
|
56
|
+
*/
|
|
57
|
+
transparent?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* scrollY threshold (px) for transparent → opaque transition.
|
|
60
|
+
* @default 40
|
|
61
|
+
*/
|
|
62
|
+
transparentThreshold?: number;
|
|
28
63
|
}
|
|
29
64
|
|
|
30
65
|
export interface PublicNavbarProps {
|
|
@@ -32,8 +67,6 @@ export interface PublicNavbarProps {
|
|
|
32
67
|
}
|
|
33
68
|
|
|
34
69
|
export function PublicNavbar({ config }: PublicNavbarProps) {
|
|
35
|
-
const rounding = config.shell?.rounding;
|
|
36
|
-
const containerClassName = config.shell?.className;
|
|
37
70
|
const navigation = config.navigation ?? [];
|
|
38
71
|
|
|
39
72
|
return (
|
|
@@ -43,18 +76,23 @@ export function PublicNavbar({ config }: PublicNavbarProps) {
|
|
|
43
76
|
brandHref={config.brandHref}
|
|
44
77
|
navigation={navigation}
|
|
45
78
|
userMenu={config.userMenu}
|
|
46
|
-
containerClassName={
|
|
79
|
+
containerClassName={config.shell?.className}
|
|
47
80
|
navbarVariant={config.navbarVariant}
|
|
48
81
|
navbarPosition={config.navbarPosition}
|
|
49
82
|
renderDesktopDropdown={config.renderDesktopDropdown}
|
|
50
83
|
desktopMaxPrimaryItems={config.desktopMaxPrimaryItems}
|
|
51
|
-
rounding={rounding}
|
|
84
|
+
rounding={config.shell?.rounding}
|
|
85
|
+
navLayout={config.navLayout}
|
|
86
|
+
navbarHeight={config.navbarHeight}
|
|
87
|
+
hideNavOnScroll={config.hideNavOnScroll}
|
|
88
|
+
transparent={config.transparent}
|
|
89
|
+
transparentThreshold={config.transparentThreshold}
|
|
52
90
|
/>
|
|
53
91
|
<PublicMobileDrawer
|
|
54
92
|
navigation={navigation}
|
|
55
93
|
userMenu={config.userMenu}
|
|
56
|
-
containerClassName={
|
|
57
|
-
rounding={rounding}
|
|
94
|
+
containerClassName={config.shell?.className}
|
|
95
|
+
rounding={config.shell?.rounding}
|
|
58
96
|
/>
|
|
59
97
|
</>
|
|
60
98
|
);
|