@djangocfg/layouts 2.1.592 → 2.1.593
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 +12 -12
- package/src/components/errors/ErrorLayout.tsx +1 -164
- package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
- package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
- package/src/components/errors/errorGlyphs.tsx +167 -0
- package/src/layouts/AppLayout/BaseApp.tsx +1 -1
- package/src/layouts/AppLayout/index.ts +2 -2
- package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
- package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
- package/src/layouts/AuthLayout/layoutContext.ts +25 -0
- package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +10 -51
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
- package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
- package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
- package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
- package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
- package/src/layouts/PublicLayout/shared/index.ts +1 -1
- package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
- package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +13 -3
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
- package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +1 -1
- package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +1 -1
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +14 -29
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
- package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
- package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
- package/src/layouts/SettingsLayout/index.ts +3 -0
- package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +1 -1
- package/src/layouts/SettingsLayout/store.ts +12 -0
- package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
- package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
'use client';
|
|
17
17
|
|
|
18
18
|
import React, {
|
|
19
|
-
type ReactNode,
|
|
20
19
|
memo,
|
|
21
20
|
useEffect,
|
|
22
21
|
useLayoutEffect,
|
|
@@ -31,190 +30,24 @@ import { cn } from '@djangocfg/ui-core/lib';
|
|
|
31
30
|
import { usePathnameWithoutLocale } from '../../../hooks';
|
|
32
31
|
import { navItemsShallowEqual } from '../../../utils/navItemsShallowEqual';
|
|
33
32
|
import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
|
|
34
|
-
import { useLayoutI18nOptional } from '
|
|
33
|
+
import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
|
|
35
34
|
import { usePublicLayoutOptional } from '../context';
|
|
36
35
|
import {
|
|
37
36
|
useDropdownMenu,
|
|
38
37
|
useNavbarScroll,
|
|
39
38
|
useNavbarViewportVars,
|
|
40
39
|
} from '../hooks';
|
|
41
|
-
import type {
|
|
42
|
-
PublicDesktopDropdownRenderer,
|
|
43
|
-
PublicNavbarHeight,
|
|
44
|
-
PublicNavbarInsetX,
|
|
45
|
-
PublicNavbarPosition,
|
|
46
|
-
PublicNavbarVariant,
|
|
47
|
-
PublicNavLayout,
|
|
48
|
-
} from '../navbarTypes';
|
|
49
|
-
import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
40
|
+
import type { NavigationItem } from '../../types';
|
|
50
41
|
|
|
51
42
|
import { NavActions } from '../primitives/NavActions';
|
|
52
|
-
import type { NavAction } from '../primitives/NavActionItem';
|
|
53
43
|
import { NavBrand } from '../primitives/NavBrand';
|
|
54
44
|
import { NavControls } from '../primitives/NavControls';
|
|
55
45
|
import { NavDesktopItems } from '../primitives/NavDesktopItems';
|
|
56
46
|
|
|
57
47
|
import { NavbarRowDesktop, NavbarRowMobile } from './navbarRows';
|
|
58
48
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* `sm:` steps inside it: the mobile row's tallest element is the 44px burger,
|
|
62
|
-
* so padding there is pure added height, while the desktop row needs the extra
|
|
63
|
-
* air to keep the link list off the panel edge.
|
|
64
|
-
*/
|
|
65
|
-
const heightClsDesktop: Record<PublicNavbarHeight, string> = {
|
|
66
|
-
sm: 'py-2',
|
|
67
|
-
md: 'py-3.5',
|
|
68
|
-
lg: 'py-5',
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const heightClsMobile: Record<PublicNavbarHeight, string> = {
|
|
72
|
-
sm: 'py-1.5',
|
|
73
|
-
md: 'py-2',
|
|
74
|
-
lg: 'py-3',
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Token → Tailwind class for the inner-row horizontal padding.
|
|
79
|
-
*
|
|
80
|
-
* Deliberately ASYMMETRIC at every breakpoint (`pl` > `pr`) to correct an
|
|
81
|
-
* optical imbalance, not a measured one. On the left the gutter meets the
|
|
82
|
-
* brand's glyphs directly; on the right it meets a button — an icon square on
|
|
83
|
-
* phones, the Login pill on desktop — whose box carries its own internal
|
|
84
|
-
* padding around the glyph. Equal `px` therefore *looks* lopsided, with a
|
|
85
|
-
* conspicuous moat to the right of the last control.
|
|
86
|
-
*
|
|
87
|
-
* It also stacks with the navbar shell's own outer gutter (`px-3 sm:px-4
|
|
88
|
-
* lg:px-6`), which is why the right side stays small in absolute terms: at
|
|
89
|
-
* `lg` the symmetric version put ~48px between the Login button and the
|
|
90
|
-
* viewport edge.
|
|
91
|
-
*/
|
|
92
|
-
/** Desktop (`lg`+) horizontal inset. Right stays trimmed — see the note above. */
|
|
93
|
-
const insetXClsDesktop: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
|
|
94
|
-
none: '',
|
|
95
|
-
xs: 'lg:pl-4 lg:pr-1.5',
|
|
96
|
-
sm: 'lg:pl-6 lg:pr-2',
|
|
97
|
-
md: 'lg:pl-8 lg:pr-3',
|
|
98
|
-
lg: 'lg:pl-12 lg:pr-5',
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
/** Mobile horizontal inset — tighter, and asymmetric for the same reason. */
|
|
102
|
-
const insetXClsMobile: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
|
|
103
|
-
none: '',
|
|
104
|
-
xs: 'pl-3 pr-1',
|
|
105
|
-
sm: 'pl-4 pr-1.5',
|
|
106
|
-
md: 'pl-5 pr-2',
|
|
107
|
-
lg: 'pl-6 pr-3',
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Resolves the inset token into one class string covering both breakpoints.
|
|
112
|
-
*
|
|
113
|
-
* A caller may also pass raw Tailwind instead of a token; that escape hatch is
|
|
114
|
-
* applied verbatim to both rows, since we cannot know how to split it.
|
|
115
|
-
*/
|
|
116
|
-
function resolveInsetX(value: PublicNavbarInsetX | undefined): string {
|
|
117
|
-
const token = (value ?? 'sm') as keyof typeof insetXClsMobile;
|
|
118
|
-
if (!(token in insetXClsMobile)) return value as string;
|
|
119
|
-
return cn(insetXClsMobile[token], insetXClsDesktop[token]);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
interface ShapeInputs {
|
|
123
|
-
/** true at scrollY >= threshold */
|
|
124
|
-
scrolled: boolean;
|
|
125
|
-
/** Matches `transparent` flag at the source. */
|
|
126
|
-
transparent: boolean;
|
|
127
|
-
/**
|
|
128
|
-
* true while the mobile drawer is open. A `transparent` bar MUST go opaque
|
|
129
|
-
* here regardless of scroll position: an open drawer means the brand + close
|
|
130
|
-
* button sit over page content, and a see-through bar leaves them floating on
|
|
131
|
-
* whatever is scrolled behind — the "navbar lost its background" bug.
|
|
132
|
-
*/
|
|
133
|
-
menuOpen: boolean;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export interface NavbarShellProps {
|
|
137
|
-
// ── Surface identity (reported to context) ────────────────────────────────
|
|
138
|
-
variant: PublicNavbarVariant;
|
|
139
|
-
position: PublicNavbarPosition;
|
|
140
|
-
|
|
141
|
-
// ── Content ───────────────────────────────────────────────────────────────
|
|
142
|
-
brand?: ReactNode;
|
|
143
|
-
brandHref?: string;
|
|
144
|
-
navigation?: NavigationItem[];
|
|
145
|
-
userMenu?: UserMenuConfig;
|
|
146
|
-
renderDesktopDropdown?: PublicDesktopDropdownRenderer;
|
|
147
|
-
/**
|
|
148
|
-
* Hard cap for how many items to try to fit on desktop. Useful when a
|
|
149
|
-
* consumer wants to force a compact primary row even if more would fit.
|
|
150
|
-
* Defaults to the full list length (let the responsive measurer decide).
|
|
151
|
-
*/
|
|
152
|
-
desktopMaxPrimaryItems?: number;
|
|
153
|
-
|
|
154
|
-
// ── Layout ────────────────────────────────────────────────────────────────
|
|
155
|
-
navLayout?: PublicNavLayout;
|
|
156
|
-
navbarHeight?: PublicNavbarHeight;
|
|
157
|
-
/** Horizontal inset between outer edge and the inner row.
|
|
158
|
-
* @default 'sm' */
|
|
159
|
-
insetX?: PublicNavbarInsetX;
|
|
160
|
-
/** Full override for the row container (escape hatch).
|
|
161
|
-
* When set, wins over `insetX`. */
|
|
162
|
-
innerPadding?: string;
|
|
163
|
-
|
|
164
|
-
// ── Scroll behavior ───────────────────────────────────────────────────────
|
|
165
|
-
hideNavOnScroll?: boolean;
|
|
166
|
-
transparent?: boolean;
|
|
167
|
-
transparentThreshold?: number;
|
|
168
|
-
|
|
169
|
-
// ── Chrome (per-variant) ──────────────────────────────────────────────────
|
|
170
|
-
/** Classes on the outer wrapper (fixed/sticky, top offset, outer x-padding). */
|
|
171
|
-
outerClassName?: string;
|
|
172
|
-
/** Classes for the `<nav>` element defining the shape (rounding, border, shadow). */
|
|
173
|
-
shapeClassName?: string;
|
|
174
|
-
/** Surface classes (bg, backdrop-blur). Driven by `shapeForState`. */
|
|
175
|
-
shapeForState?: (inputs: ShapeInputs) => string;
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Replaces the default actions column (UserMenu + mobile-toggle).
|
|
179
|
-
* Variant owns the mobile toggle too. Receives helpers so it can build the
|
|
180
|
-
* right-hand side however it wants.
|
|
181
|
-
*/
|
|
182
|
-
renderActions?: (ctx: NavbarActionsContext) => ReactNode;
|
|
183
|
-
|
|
184
|
-
// ── Default-actions slots (only used when `renderActions` is NOT provided) ─
|
|
185
|
-
/** Typed CTA pills (Book a demo / Get started / …) before UserMenu. */
|
|
186
|
-
actions?: NavAction[];
|
|
187
|
-
/** Arbitrary ReactNode between actions and UserMenu. */
|
|
188
|
-
actionsLeadingSlot?: ReactNode;
|
|
189
|
-
/** Arbitrary ReactNode after the mobile toggle. */
|
|
190
|
-
actionsTrailingSlot?: ReactNode;
|
|
191
|
-
|
|
192
|
-
// ── Theme + locale controls (rendered next to UserMenu on desktop) ────────
|
|
193
|
-
/** Toggle individual controls. Locale switcher also requires `i18n`. */
|
|
194
|
-
controls?: {
|
|
195
|
-
/** Light / system / dark pill. @default false */
|
|
196
|
-
showThemeSwitcher?: boolean;
|
|
197
|
-
/** Locale dropdown. Requires `i18n`. @default false */
|
|
198
|
-
showLocaleSwitcher?: boolean;
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
// ── Props override (used by variants that proxy ctx differently) ──────────
|
|
202
|
-
mobileMenuOpen?: boolean;
|
|
203
|
-
onMobileMenuToggle?: () => void;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export interface NavbarActionsContext {
|
|
207
|
-
userMenu?: UserMenuConfig;
|
|
208
|
-
mobileMenuOpen: boolean;
|
|
209
|
-
toggleMobileMenu: () => void;
|
|
210
|
-
toggleMobileLabel: string;
|
|
211
|
-
navLayout: PublicNavLayout;
|
|
212
|
-
/**
|
|
213
|
-
* Pre-built theme/locale controls node. `null` when both toggles are off.
|
|
214
|
-
* Variants with a custom `renderActions` can choose where to place it.
|
|
215
|
-
*/
|
|
216
|
-
controls: ReactNode | null;
|
|
217
|
-
}
|
|
49
|
+
import { heightClsDesktop, heightClsMobile, resolveInsetX } from './navbarShellStyles';
|
|
50
|
+
import type { NavbarShellProps } from './navbarShell.types';
|
|
218
51
|
|
|
219
52
|
function NavbarShellRaw(props: NavbarShellProps) {
|
|
220
53
|
const context = usePublicLayoutOptional();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { NavbarShell } from './NavbarShell';
|
|
2
|
-
export type { NavbarShellProps, NavbarActionsContext } from './
|
|
2
|
+
export type { NavbarShellProps, NavbarActionsContext } from './navbarShell.types';
|
|
3
3
|
export { MobileDrawerShell } from './MobileDrawerShell';
|
|
4
4
|
export type { MobileDrawerShellProps } from './MobileDrawerShell';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
PublicDesktopDropdownRenderer,
|
|
5
|
+
PublicNavbarHeight,
|
|
6
|
+
PublicNavbarInsetX,
|
|
7
|
+
PublicNavbarPosition,
|
|
8
|
+
PublicNavbarVariant,
|
|
9
|
+
PublicNavLayout,
|
|
10
|
+
} from '../navbarTypes';
|
|
11
|
+
import type { NavigationItem, UserMenuConfig } from '../../types';
|
|
12
|
+
import type { NavAction } from '../primitives/NavActionItem';
|
|
13
|
+
|
|
14
|
+
export interface ShapeInputs {
|
|
15
|
+
/** true at scrollY >= threshold */
|
|
16
|
+
scrolled: boolean;
|
|
17
|
+
/** Matches `transparent` flag at the source. */
|
|
18
|
+
transparent: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* true while the mobile drawer is open. A `transparent` bar MUST go opaque
|
|
21
|
+
* here regardless of scroll position: an open drawer means the brand + close
|
|
22
|
+
* button sit over page content, and a see-through bar leaves them floating on
|
|
23
|
+
* whatever is scrolled behind — the "navbar lost its background" bug.
|
|
24
|
+
*/
|
|
25
|
+
menuOpen: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NavbarShellProps {
|
|
29
|
+
// ── Surface identity (reported to context) ────────────────────────────────
|
|
30
|
+
variant: PublicNavbarVariant;
|
|
31
|
+
position: PublicNavbarPosition;
|
|
32
|
+
|
|
33
|
+
// ── Content ───────────────────────────────────────────────────────────────
|
|
34
|
+
brand?: ReactNode;
|
|
35
|
+
brandHref?: string;
|
|
36
|
+
navigation?: NavigationItem[];
|
|
37
|
+
userMenu?: UserMenuConfig;
|
|
38
|
+
renderDesktopDropdown?: PublicDesktopDropdownRenderer;
|
|
39
|
+
/**
|
|
40
|
+
* Hard cap for how many items to try to fit on desktop. Useful when a
|
|
41
|
+
* consumer wants to force a compact primary row even if more would fit.
|
|
42
|
+
* Defaults to the full list length (let the responsive measurer decide).
|
|
43
|
+
*/
|
|
44
|
+
desktopMaxPrimaryItems?: number;
|
|
45
|
+
|
|
46
|
+
// ── Layout ────────────────────────────────────────────────────────────────
|
|
47
|
+
navLayout?: PublicNavLayout;
|
|
48
|
+
navbarHeight?: PublicNavbarHeight;
|
|
49
|
+
/** Horizontal inset between outer edge and the inner row.
|
|
50
|
+
* @default 'sm' */
|
|
51
|
+
insetX?: PublicNavbarInsetX;
|
|
52
|
+
/** Full override for the row container (escape hatch).
|
|
53
|
+
* When set, wins over `insetX`. */
|
|
54
|
+
innerPadding?: string;
|
|
55
|
+
|
|
56
|
+
// ── Scroll behavior ───────────────────────────────────────────────────────
|
|
57
|
+
hideNavOnScroll?: boolean;
|
|
58
|
+
transparent?: boolean;
|
|
59
|
+
transparentThreshold?: number;
|
|
60
|
+
|
|
61
|
+
// ── Chrome (per-variant) ──────────────────────────────────────────────────
|
|
62
|
+
/** Classes on the outer wrapper (fixed/sticky, top offset, outer x-padding). */
|
|
63
|
+
outerClassName?: string;
|
|
64
|
+
/** Classes for the `<nav>` element defining the shape (rounding, border, shadow). */
|
|
65
|
+
shapeClassName?: string;
|
|
66
|
+
/** Surface classes (bg, backdrop-blur). Driven by `shapeForState`. */
|
|
67
|
+
shapeForState?: (inputs: ShapeInputs) => string;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Replaces the default actions column (UserMenu + mobile-toggle).
|
|
71
|
+
* Variant owns the mobile toggle too. Receives helpers so it can build the
|
|
72
|
+
* right-hand side however it wants.
|
|
73
|
+
*/
|
|
74
|
+
renderActions?: (ctx: NavbarActionsContext) => ReactNode;
|
|
75
|
+
|
|
76
|
+
// ── Default-actions slots (only used when `renderActions` is NOT provided) ─
|
|
77
|
+
/** Typed CTA pills (Book a demo / Get started / …) before UserMenu. */
|
|
78
|
+
actions?: NavAction[];
|
|
79
|
+
/** Arbitrary ReactNode between actions and UserMenu. */
|
|
80
|
+
actionsLeadingSlot?: ReactNode;
|
|
81
|
+
/** Arbitrary ReactNode after the mobile toggle. */
|
|
82
|
+
actionsTrailingSlot?: ReactNode;
|
|
83
|
+
|
|
84
|
+
// ── Theme + locale controls (rendered next to UserMenu on desktop) ────────
|
|
85
|
+
/** Toggle individual controls. Locale switcher also requires `i18n`. */
|
|
86
|
+
controls?: {
|
|
87
|
+
/** Light / system / dark pill. @default false */
|
|
88
|
+
showThemeSwitcher?: boolean;
|
|
89
|
+
/** Locale dropdown. Requires `i18n`. @default false */
|
|
90
|
+
showLocaleSwitcher?: boolean;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ── Props override (used by variants that proxy ctx differently) ──────────
|
|
94
|
+
mobileMenuOpen?: boolean;
|
|
95
|
+
onMobileMenuToggle?: () => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface NavbarActionsContext {
|
|
99
|
+
userMenu?: UserMenuConfig;
|
|
100
|
+
mobileMenuOpen: boolean;
|
|
101
|
+
toggleMobileMenu: () => void;
|
|
102
|
+
toggleMobileLabel: string;
|
|
103
|
+
navLayout: PublicNavLayout;
|
|
104
|
+
/**
|
|
105
|
+
* Pre-built theme/locale controls node. `null` when both toggles are off.
|
|
106
|
+
* Variants with a custom `renderActions` can choose where to place it.
|
|
107
|
+
*/
|
|
108
|
+
controls: ReactNode | null;
|
|
109
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
4
|
+
|
|
5
|
+
import type { PublicNavbarHeight, PublicNavbarInsetX } from '../navbarTypes';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Vertical padding, per breakpoint. Two independent scales rather than one with
|
|
9
|
+
* `sm:` steps inside it: the mobile row's tallest element is the 44px burger,
|
|
10
|
+
* so padding there is pure added height, while the desktop row needs the extra
|
|
11
|
+
* air to keep the link list off the panel edge.
|
|
12
|
+
*/
|
|
13
|
+
export const heightClsDesktop: Record<PublicNavbarHeight, string> = {
|
|
14
|
+
sm: 'py-2',
|
|
15
|
+
md: 'py-3.5',
|
|
16
|
+
lg: 'py-5',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const heightClsMobile: Record<PublicNavbarHeight, string> = {
|
|
20
|
+
sm: 'py-1.5',
|
|
21
|
+
md: 'py-2',
|
|
22
|
+
lg: 'py-3',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Token → Tailwind class for the inner-row horizontal padding.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately ASYMMETRIC at every breakpoint (`pl` > `pr`) to correct an
|
|
29
|
+
* optical imbalance, not a measured one. On the left the gutter meets the
|
|
30
|
+
* brand's glyphs directly; on the right it meets a button — an icon square on
|
|
31
|
+
* phones, the Login pill on desktop — whose box carries its own internal
|
|
32
|
+
* padding around the glyph. Equal `px` therefore *looks* lopsided, with a
|
|
33
|
+
* conspicuous moat to the right of the last control.
|
|
34
|
+
*
|
|
35
|
+
* It also stacks with the navbar shell's own outer gutter (`px-3 sm:px-4
|
|
36
|
+
* lg:px-6`), which is why the right side stays small in absolute terms: at
|
|
37
|
+
* `lg` the symmetric version put ~48px between the Login button and the
|
|
38
|
+
* viewport edge.
|
|
39
|
+
*/
|
|
40
|
+
/** Desktop (`lg`+) horizontal inset. Right stays trimmed — see the note above. */
|
|
41
|
+
export const insetXClsDesktop: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
|
|
42
|
+
none: '',
|
|
43
|
+
xs: 'lg:pl-4 lg:pr-1.5',
|
|
44
|
+
sm: 'lg:pl-6 lg:pr-2',
|
|
45
|
+
md: 'lg:pl-8 lg:pr-3',
|
|
46
|
+
lg: 'lg:pl-12 lg:pr-5',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Mobile horizontal inset — tighter, and asymmetric for the same reason. */
|
|
50
|
+
export const insetXClsMobile: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
|
|
51
|
+
none: '',
|
|
52
|
+
xs: 'pl-3 pr-1',
|
|
53
|
+
sm: 'pl-4 pr-1.5',
|
|
54
|
+
md: 'pl-5 pr-2',
|
|
55
|
+
lg: 'pl-6 pr-3',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolves the inset token into one class string covering both breakpoints.
|
|
60
|
+
*
|
|
61
|
+
* A caller may also pass raw Tailwind instead of a token; that escape hatch is
|
|
62
|
+
* applied verbatim to both rows, since we cannot know how to split it.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveInsetX(value: PublicNavbarInsetX | undefined): string {
|
|
65
|
+
const token = (value ?? 'sm') as keyof typeof insetXClsMobile;
|
|
66
|
+
if (!(token in insetXClsMobile)) return value as string;
|
|
67
|
+
return cn(insetXClsMobile[token], insetXClsDesktop[token]);
|
|
68
|
+
}
|
|
69
|
+
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useCallback, useMemo } from 'react';
|
|
3
|
+
import React, { useCallback, useEffect, useMemo } from 'react';
|
|
4
4
|
|
|
5
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
5
6
|
import {
|
|
6
7
|
Dialog,
|
|
7
8
|
DialogContent,
|
|
@@ -46,6 +47,8 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
|
|
|
46
47
|
const setSection = useSettingsDialogStore((s) => s.setSection);
|
|
47
48
|
const isMobile = useIsMobile();
|
|
48
49
|
const copy = useSettingsCopy();
|
|
50
|
+
// Settings are about the signed-in user: for a guest the panels are empty, so `#settings` opens nothing until sign-in.
|
|
51
|
+
const { isAuthenticated } = useAuth();
|
|
49
52
|
|
|
50
53
|
// Set of valid ids, so a bad URL hash falls back gracefully instead of
|
|
51
54
|
// selecting a non-existent section. Cheap to recompute; built-ins are pure.
|
|
@@ -56,17 +59,24 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
|
|
|
56
59
|
return ids;
|
|
57
60
|
}, [builtins, enableDeleteAccount, appSections, copy]);
|
|
58
61
|
|
|
62
|
+
// Menus (the account menu) link to these; only this component knows the host's sections.
|
|
63
|
+
const setMenuSections = useSettingsDialogStore((s) => s.setSections);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const all = [...buildBuiltinSections({ builtins, enableDeleteAccount, copy }), ...appSections];
|
|
66
|
+
setMenuSections(all.filter((s) => !s.hidden).map(({ id, label, icon }) => ({ id, label, icon })));
|
|
67
|
+
}, [builtins, enableDeleteAccount, appSections, copy, setMenuSections]);
|
|
68
|
+
|
|
59
69
|
const resolveSection = useCallback(
|
|
60
70
|
(id: string | null) => (id && knownIds.has(id) ? id : null),
|
|
61
71
|
[knownIds],
|
|
62
72
|
);
|
|
63
73
|
|
|
64
|
-
useSettingsUrl({ urlKey, enabled: syncUrl, resolveSection });
|
|
74
|
+
useSettingsUrl({ urlKey, enabled: syncUrl && isAuthenticated, resolveSection });
|
|
65
75
|
|
|
66
76
|
const resolvedTitle = title ?? copy.nav.title;
|
|
67
77
|
|
|
68
78
|
return (
|
|
69
|
-
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
|
|
79
|
+
<Dialog open={isOpen && isAuthenticated} onOpenChange={(open) => !open && close()}>
|
|
70
80
|
<DialogContent
|
|
71
81
|
// Desktop: large centred card, proportional to the viewport.
|
|
72
82
|
// Mobile: near-fullscreen sheet (small inset) so the phone settings get
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronDown } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
7
|
+
import { Avatar, AvatarFallback, AvatarImage, Button, MenuBuilder } from '@djangocfg/ui-core/components';
|
|
8
|
+
import type { MenuItem } from '@djangocfg/ui-core/components';
|
|
9
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
10
|
+
|
|
11
|
+
import { useAccountMenuItems } from '../hooks/useAccountMenuItems';
|
|
12
|
+
|
|
13
|
+
export interface AccountMenuButtonProps {
|
|
14
|
+
/** `row`: avatar, name and chevron (a sidebar footer; the menu opens up). `avatar`: the avatar alone (a header; the menu opens down). */
|
|
15
|
+
variant?: 'row' | 'avatar';
|
|
16
|
+
/** Items between the Settings shortcuts and Sign out (language, app links). */
|
|
17
|
+
extra?: MenuItem[];
|
|
18
|
+
/** Small muted text after the name (a plan). */
|
|
19
|
+
plan?: string | null;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The ready user menu: trigger button plus the shared menu (email, Settings, a shortcut per
|
|
25
|
+
* Settings section, Sign out). Drop it into any chrome; renders nothing for a signed-out visitor.
|
|
26
|
+
* `PrivateLayout`'s sidebar footer builds on the same `useAccountMenuItems`.
|
|
27
|
+
*/
|
|
28
|
+
export const AccountMenuButton: React.FC<AccountMenuButtonProps> = ({
|
|
29
|
+
variant = 'row',
|
|
30
|
+
extra,
|
|
31
|
+
plan,
|
|
32
|
+
className,
|
|
33
|
+
}) => {
|
|
34
|
+
const { user } = useAuth();
|
|
35
|
+
const items = useAccountMenuItems({ extra });
|
|
36
|
+
const [open, setOpen] = React.useState(false);
|
|
37
|
+
|
|
38
|
+
if (!user) return null;
|
|
39
|
+
|
|
40
|
+
const name = user.display_username || user.email || 'User';
|
|
41
|
+
const initial = (name.charAt(0) || '?').toUpperCase();
|
|
42
|
+
const isRow = variant === 'row';
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<MenuBuilder
|
|
46
|
+
items={items}
|
|
47
|
+
open={open}
|
|
48
|
+
onOpenChange={setOpen}
|
|
49
|
+
side={isRow ? 'top' : 'bottom'}
|
|
50
|
+
align={isRow ? 'start' : 'end'}
|
|
51
|
+
sideOffset={8}
|
|
52
|
+
contentClassName="w-60 p-1.5"
|
|
53
|
+
>
|
|
54
|
+
<Button
|
|
55
|
+
type="button"
|
|
56
|
+
variant="ghost"
|
|
57
|
+
aria-label={name}
|
|
58
|
+
className={cn(
|
|
59
|
+
'group min-w-0 select-none gap-2 rounded-lg font-normal focus-visible:ring-0',
|
|
60
|
+
isRow ? 'h-9 w-full justify-start px-2 text-left' : 'size-9 p-0',
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<Avatar className="size-6 shrink-0">
|
|
65
|
+
<AvatarImage src={user.avatar ?? ''} alt={name} />
|
|
66
|
+
<AvatarFallback className="text-xs font-medium">{initial}</AvatarFallback>
|
|
67
|
+
</Avatar>
|
|
68
|
+
{isRow && (
|
|
69
|
+
<>
|
|
70
|
+
<span className="flex min-w-0 items-baseline gap-1">
|
|
71
|
+
<span className="truncate text-sm">{name}</span>
|
|
72
|
+
{plan ? <span className="shrink-0 text-xs text-muted-foreground">· {plan}</span> : null}
|
|
73
|
+
</span>
|
|
74
|
+
<ChevronDown
|
|
75
|
+
className="ml-auto size-4 shrink-0 opacity-50 transition-transform duration-150 group-data-[state=open]:rotate-180"
|
|
76
|
+
aria-hidden
|
|
77
|
+
/>
|
|
78
|
+
</>
|
|
79
|
+
)}
|
|
80
|
+
</Button>
|
|
81
|
+
</MenuBuilder>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -31,7 +31,7 @@ export const SettingsNavItem: React.FC<SettingsNavItemProps> = ({
|
|
|
31
31
|
aria-selected={active}
|
|
32
32
|
onClick={() => onSelect(section.id)}
|
|
33
33
|
className={cn(
|
|
34
|
-
'group flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-
|
|
34
|
+
'group flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-sm',
|
|
35
35
|
'outline-none transition-colors',
|
|
36
36
|
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
|
37
37
|
active
|
|
@@ -30,7 +30,7 @@ export const SettingsSearch: React.FC = () => {
|
|
|
30
30
|
placeholder={placeholder}
|
|
31
31
|
aria-label={placeholder}
|
|
32
32
|
className={cn(
|
|
33
|
-
'h-8 w-full rounded-lg border border-transparent bg-foreground/[0.05] pl-8 pr-8 text-base',
|
|
33
|
+
'h-8 w-full rounded-lg border border-transparent bg-foreground/[0.05] pl-8 pr-8 text-base md:text-sm',
|
|
34
34
|
'text-foreground placeholder:text-muted-foreground',
|
|
35
35
|
'outline-none transition-[border-color,background-color,box-shadow]',
|
|
36
36
|
// Crisp thin accent outline (matches ui-core Input), no blurry halo.
|
|
@@ -14,13 +14,12 @@ import {
|
|
|
14
14
|
} from '@djangocfg/ui-core/components';
|
|
15
15
|
|
|
16
16
|
import { fill, useSettingsCopy, type SettingsCopy } from '../../i18n';
|
|
17
|
-
import {
|
|
17
|
+
import { TwoFactorSetupDialog } from './TwoFactorSetupDialog';
|
|
18
18
|
|
|
19
19
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
20
|
// Types
|
|
21
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
22
|
|
|
23
|
-
type View = 'status' | 'setup';
|
|
24
23
|
|
|
25
24
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
25
|
// Disable confirmation — inline, not a dialog. Settings already lives in a
|
|
@@ -109,7 +108,7 @@ function deviceDescription(copy: SettingsCopy['security'], createdAt: string, is
|
|
|
109
108
|
|
|
110
109
|
export const TwoFactorSection: React.FC = () => {
|
|
111
110
|
const copy = useSettingsCopy().security;
|
|
112
|
-
const [
|
|
111
|
+
const [setupOpen, setSetupOpen] = useState(false);
|
|
113
112
|
const [showDisable, setShowDisable] = useState(false);
|
|
114
113
|
|
|
115
114
|
const {
|
|
@@ -128,11 +127,11 @@ export const TwoFactorSection: React.FC = () => {
|
|
|
128
127
|
|
|
129
128
|
const handleEnableClick = () => {
|
|
130
129
|
resetSetup();
|
|
131
|
-
|
|
130
|
+
setSetupOpen(true);
|
|
132
131
|
};
|
|
133
132
|
|
|
134
133
|
const handleSetupDone = () => {
|
|
135
|
-
|
|
134
|
+
setSetupOpen(false);
|
|
136
135
|
fetchStatus();
|
|
137
136
|
};
|
|
138
137
|
|
|
@@ -146,30 +145,6 @@ export const TwoFactorSection: React.FC = () => {
|
|
|
146
145
|
clearError();
|
|
147
146
|
};
|
|
148
147
|
|
|
149
|
-
// ── Setup flow ──────────────────────────────────────────────────────────────
|
|
150
|
-
// Lighter, card-free container. The standalone setup flow brings its own
|
|
151
|
-
// visible (Preloader-based) loading state.
|
|
152
|
-
if (view === 'setup') {
|
|
153
|
-
return (
|
|
154
|
-
<SettingsBlock title={copy.setupTitle}>
|
|
155
|
-
<div className="flex items-center justify-between pb-2">
|
|
156
|
-
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
|
157
|
-
{copy.setupHint}
|
|
158
|
-
</p>
|
|
159
|
-
<Button variant="ghost" size="sm" onClick={() => setView('status')}>
|
|
160
|
-
{copy.cancel}
|
|
161
|
-
</Button>
|
|
162
|
-
</div>
|
|
163
|
-
<div className="flex justify-center py-2">
|
|
164
|
-
<TwoFactorSetup
|
|
165
|
-
onComplete={handleSetupDone}
|
|
166
|
-
onSkip={() => setView('status')}
|
|
167
|
-
/>
|
|
168
|
-
</div>
|
|
169
|
-
</SettingsBlock>
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
148
|
// ── Status view ─────────────────────────────────────────────────────────────
|
|
174
149
|
// One compact block: a single status row (state + action), the inline confirm
|
|
175
150
|
// when turning off, then the connected devices as plain rows.
|
|
@@ -223,6 +198,16 @@ export const TwoFactorSection: React.FC = () => {
|
|
|
223
198
|
description={deviceDescription(copy, device.createdAt, device.isPrimary)}
|
|
224
199
|
/>
|
|
225
200
|
))}
|
|
201
|
+
|
|
202
|
+
<TwoFactorSetupDialog
|
|
203
|
+
open={setupOpen}
|
|
204
|
+
onOpenChange={(open) => {
|
|
205
|
+
setSetupOpen(open);
|
|
206
|
+
// Closing mid-way or after finishing: the status row must reflect the server.
|
|
207
|
+
if (!open) fetchStatus();
|
|
208
|
+
}}
|
|
209
|
+
onComplete={handleSetupDone}
|
|
210
|
+
/>
|
|
226
211
|
</SettingsBlock>
|
|
227
212
|
);
|
|
228
213
|
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@djangocfg/ui-core/components';
|
|
6
|
+
|
|
7
|
+
import { TwoFactorSetup } from '../../../../snippets/TwoFactor';
|
|
8
|
+
import { useSettingsCopy } from '../../i18n';
|
|
9
|
+
|
|
10
|
+
export interface TwoFactorSetupDialogProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onOpenChange: (open: boolean) => void;
|
|
13
|
+
/** Runs after the user dismisses the backup codes. */
|
|
14
|
+
onComplete: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Enabling 2FA is one focused task, so it gets its own dialog over Settings instead of
|
|
19
|
+
* replacing the Security block inline. One exit (close); no "skip" — that belongs to the
|
|
20
|
+
* sign-in flow, not to a deliberate opt-in. Content mounts only while open, so each
|
|
21
|
+
* opening starts a fresh setup and an unfinished one is dropped on close.
|
|
22
|
+
*/
|
|
23
|
+
export const TwoFactorSetupDialog: React.FC<TwoFactorSetupDialogProps> = ({ open, onOpenChange, onComplete }) => {
|
|
24
|
+
const copy = useSettingsCopy().security;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
28
|
+
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-1.5rem)] max-w-md flex-col items-center overflow-y-auto bg-card p-6">
|
|
29
|
+
<DialogTitle className="sr-only">{copy.setupTitle}</DialogTitle>
|
|
30
|
+
<DialogDescription className="sr-only">{copy.setupHint}</DialogDescription>
|
|
31
|
+
{open && <TwoFactorSetup onComplete={onComplete} />}
|
|
32
|
+
</DialogContent>
|
|
33
|
+
</Dialog>
|
|
34
|
+
);
|
|
35
|
+
};
|