@djangocfg/layouts 2.1.357 → 2.1.358
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 +21 -19
- package/src/configurator/private/schema.ts +12 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +13 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +9 -15
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +46 -90
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +9 -6
- package/src/layouts/PrivateLayout/hooks/index.ts +1 -0
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +10 -3
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +1 -1
- package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +21 -0
- package/src/layouts/PrivateLayout/types.ts +6 -0
- package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
- package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
- package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
- package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +4 -2
- package/src/layouts/ProfileLayout/{ProfileLayout.tsx → ProfileForm/index.tsx} +10 -7
- package/src/layouts/ProfileLayout/README.md +65 -5
- package/src/layouts/ProfileLayout/components/EditableField.tsx +1 -1
- package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
- package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +1 -1
- package/src/layouts/ProfileLayout/components/ProfileTab.tsx +17 -11
- package/src/layouts/ProfileLayout/components/index.ts +1 -0
- package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +17 -12
- package/src/layouts/ProfileLayout/index.ts +5 -4
- package/src/layouts/ProfileLayout/types.ts +11 -1
- package/src/layouts/_components/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.358",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -84,13 +84,13 @@
|
|
|
84
84
|
"check": "tsc --noEmit"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
|
-
"@djangocfg/api": "^2.1.
|
|
88
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
89
|
-
"@djangocfg/debuger": "^2.1.
|
|
90
|
-
"@djangocfg/i18n": "^2.1.
|
|
91
|
-
"@djangocfg/monitor": "^2.1.
|
|
92
|
-
"@djangocfg/ui-core": "^2.1.
|
|
93
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
87
|
+
"@djangocfg/api": "^2.1.358",
|
|
88
|
+
"@djangocfg/centrifugo": "^2.1.358",
|
|
89
|
+
"@djangocfg/debuger": "^2.1.358",
|
|
90
|
+
"@djangocfg/i18n": "^2.1.358",
|
|
91
|
+
"@djangocfg/monitor": "^2.1.358",
|
|
92
|
+
"@djangocfg/ui-core": "^2.1.358",
|
|
93
|
+
"@djangocfg/ui-nextjs": "^2.1.358",
|
|
94
94
|
"@hookform/resolvers": "^5.2.2",
|
|
95
95
|
"consola": "^3.4.2",
|
|
96
96
|
"lucide-react": "^0.545.0",
|
|
@@ -105,7 +105,8 @@
|
|
|
105
105
|
"swr": "^2.3.7",
|
|
106
106
|
"tailwindcss": "^4.1.18",
|
|
107
107
|
"tailwindcss-animate": "^1.0.7",
|
|
108
|
-
"zod": "^4.3.6"
|
|
108
|
+
"zod": "^4.3.6",
|
|
109
|
+
"zustand": "^5.0.0"
|
|
109
110
|
},
|
|
110
111
|
"peerDependenciesMeta": {
|
|
111
112
|
"@djangocfg/monitor": {
|
|
@@ -120,21 +121,22 @@
|
|
|
120
121
|
"uuid": "^11.1.0"
|
|
121
122
|
},
|
|
122
123
|
"devDependencies": {
|
|
123
|
-
"@djangocfg/api": "^2.1.
|
|
124
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
125
|
-
"@djangocfg/debuger": "^2.1.
|
|
126
|
-
"@djangocfg/i18n": "^2.1.
|
|
127
|
-
"@djangocfg/monitor": "^2.1.
|
|
128
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
129
|
-
"@djangocfg/ui-core": "^2.1.
|
|
130
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
131
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
124
|
+
"@djangocfg/api": "^2.1.358",
|
|
125
|
+
"@djangocfg/centrifugo": "^2.1.358",
|
|
126
|
+
"@djangocfg/debuger": "^2.1.358",
|
|
127
|
+
"@djangocfg/i18n": "^2.1.358",
|
|
128
|
+
"@djangocfg/monitor": "^2.1.358",
|
|
129
|
+
"@djangocfg/typescript-config": "^2.1.358",
|
|
130
|
+
"@djangocfg/ui-core": "^2.1.358",
|
|
131
|
+
"@djangocfg/ui-nextjs": "^2.1.358",
|
|
132
|
+
"@djangocfg/ui-tools": "^2.1.358",
|
|
132
133
|
"@types/node": "^24.7.2",
|
|
133
134
|
"@types/react": "^19.1.0",
|
|
134
135
|
"@types/react-dom": "^19.1.0",
|
|
135
136
|
"eslint": "^9.37.0",
|
|
136
137
|
"next-intl": "^4.9.1",
|
|
137
|
-
"typescript": "^5.9.3"
|
|
138
|
+
"typescript": "^5.9.3",
|
|
139
|
+
"zustand": "^5.0.4"
|
|
138
140
|
},
|
|
139
141
|
"publishConfig": {
|
|
140
142
|
"access": "public"
|
|
@@ -37,6 +37,7 @@ export interface PrivateLayoutConfiguratorData {
|
|
|
37
37
|
header: {
|
|
38
38
|
userPlan: string;
|
|
39
39
|
showSecondaryAction: boolean;
|
|
40
|
+
accountAction: 'menu' | 'dialog';
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -126,6 +127,12 @@ export const privateLayoutConfiguratorSchema: CustomJsonSchema7 = {
|
|
|
126
127
|
title: 'Footer secondary action',
|
|
127
128
|
description: 'Adds a download-style icon button inside the footer trigger with a pulsing accent dot.',
|
|
128
129
|
},
|
|
130
|
+
accountAction: {
|
|
131
|
+
type: 'string',
|
|
132
|
+
title: 'Account action',
|
|
133
|
+
enum: ['menu', 'dialog'],
|
|
134
|
+
description: "'menu' opens a dropdown; 'dialog' opens the global ProfileDialog.",
|
|
135
|
+
},
|
|
129
136
|
},
|
|
130
137
|
},
|
|
131
138
|
},
|
|
@@ -165,6 +172,10 @@ export const privateLayoutConfiguratorUiSchema: CustomJsonUiSchema7 = {
|
|
|
165
172
|
header: {
|
|
166
173
|
'ui:collapsible': true,
|
|
167
174
|
showSecondaryAction: { 'ui:widget': 'switch' },
|
|
175
|
+
accountAction: {
|
|
176
|
+
'ui:widget': 'radio',
|
|
177
|
+
'ui:options': { inline: true },
|
|
178
|
+
},
|
|
168
179
|
},
|
|
169
180
|
};
|
|
170
181
|
|
|
@@ -186,5 +197,6 @@ export const defaultPrivateLayoutConfiguratorData: PrivateLayoutConfiguratorData
|
|
|
186
197
|
header: {
|
|
187
198
|
userPlan: 'Pro plan',
|
|
188
199
|
showSecondaryAction: false,
|
|
200
|
+
accountAction: 'menu',
|
|
189
201
|
},
|
|
190
202
|
};
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
'use client';
|
|
10
10
|
|
|
11
11
|
import React, { ReactNode } from 'react';
|
|
12
|
+
import dynamic from 'next/dynamic';
|
|
12
13
|
|
|
13
14
|
import { Preloader } from '@djangocfg/ui-core/components';
|
|
14
15
|
import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
|
|
@@ -18,6 +19,7 @@ import type { LayoutVisualConfig } from '../types';
|
|
|
18
19
|
import { PrivateContent, PrivateSidebar } from './components';
|
|
19
20
|
import { useAuthGuard } from './hooks';
|
|
20
21
|
import { useLayoutVisual } from './hooks';
|
|
22
|
+
import { useSidebarDefaultOpen } from './hooks';
|
|
21
23
|
|
|
22
24
|
import type {
|
|
23
25
|
HeaderConfig,
|
|
@@ -37,6 +39,12 @@ export type {
|
|
|
37
39
|
|
|
38
40
|
export { PrivateLayoutProps };
|
|
39
41
|
|
|
42
|
+
// Lazy-load ProfileDialog so the profile bundle is only fetched when opened.
|
|
43
|
+
const ProfileDialog = dynamic(
|
|
44
|
+
() => import('../ProfileLayout/ProfileDialog/ProfileDialog').then((m) => m.ProfileDialog),
|
|
45
|
+
{ ssr: false },
|
|
46
|
+
);
|
|
47
|
+
|
|
40
48
|
export function PrivateLayout({
|
|
41
49
|
children,
|
|
42
50
|
sidebar,
|
|
@@ -55,6 +63,8 @@ export function PrivateLayout({
|
|
|
55
63
|
const { providerStyle, providerClassName, insetClassName, sidebarVariant } =
|
|
56
64
|
useLayoutVisual(visual);
|
|
57
65
|
|
|
66
|
+
const defaultOpen = useSidebarDefaultOpen();
|
|
67
|
+
|
|
58
68
|
if (isLoading) {
|
|
59
69
|
return (
|
|
60
70
|
<Preloader
|
|
@@ -69,7 +79,7 @@ export function PrivateLayout({
|
|
|
69
79
|
|
|
70
80
|
return (
|
|
71
81
|
<SidebarProvider
|
|
72
|
-
defaultOpen={
|
|
82
|
+
defaultOpen={defaultOpen}
|
|
73
83
|
style={providerStyle}
|
|
74
84
|
className={providerClassName}
|
|
75
85
|
>
|
|
@@ -92,6 +102,8 @@ export function PrivateLayout({
|
|
|
92
102
|
{children}
|
|
93
103
|
</PrivateContent>
|
|
94
104
|
</SidebarInset>
|
|
105
|
+
|
|
106
|
+
<ProfileDialog />
|
|
95
107
|
</SidebarProvider>
|
|
96
108
|
);
|
|
97
109
|
}
|
|
@@ -51,7 +51,7 @@ export function PrivateSidebar({
|
|
|
51
51
|
}, [pathname, isMobile, setOpenMobile]);
|
|
52
52
|
|
|
53
53
|
const collapsedRail = !isMobile && state === 'collapsed';
|
|
54
|
-
const { isHoverExpanded, onMouseEnter, onMouseLeave } = useHoverExpand({
|
|
54
|
+
const { isHoverExpanded, onMouseEnter, onMouseLeave, setHoverExpanded } = useHoverExpand({
|
|
55
55
|
enabled: collapsedRail,
|
|
56
56
|
});
|
|
57
57
|
|
|
@@ -69,7 +69,7 @@ export function PrivateSidebar({
|
|
|
69
69
|
header={header}
|
|
70
70
|
variant={variant}
|
|
71
71
|
collapsedRail={collapsedRail}
|
|
72
|
-
|
|
72
|
+
setHoverExpanded={setHoverExpanded}
|
|
73
73
|
onMouseEnter={onMouseEnter}
|
|
74
74
|
onMouseLeave={onMouseLeave}
|
|
75
75
|
/>
|
|
@@ -87,7 +87,7 @@ interface PrivateSidebarInnerProps {
|
|
|
87
87
|
header?: HeaderConfig;
|
|
88
88
|
variant: 'sidebar' | 'inset';
|
|
89
89
|
collapsedRail: boolean;
|
|
90
|
-
|
|
90
|
+
setHoverExpanded: (value: boolean) => void;
|
|
91
91
|
onMouseEnter: () => void;
|
|
92
92
|
onMouseLeave: () => void;
|
|
93
93
|
}
|
|
@@ -97,7 +97,7 @@ function PrivateSidebarInner({
|
|
|
97
97
|
header,
|
|
98
98
|
variant,
|
|
99
99
|
collapsedRail,
|
|
100
|
-
|
|
100
|
+
setHoverExpanded,
|
|
101
101
|
onMouseEnter,
|
|
102
102
|
onMouseLeave,
|
|
103
103
|
}: PrivateSidebarInnerProps) {
|
|
@@ -105,26 +105,20 @@ function PrivateSidebarInner({
|
|
|
105
105
|
const { modifiers } = useShellVisualState(layoutVariant);
|
|
106
106
|
const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
* tooltips) keep their original behaviour: we bail out as soon as the click
|
|
112
|
-
* target sits inside a `button`, `a`, or anything explicitly marked
|
|
113
|
-
* non-expandable via `data-no-expand`.
|
|
114
|
-
*/
|
|
108
|
+
const railExpandHintClass = collapsedRail ? 'cursor-pointer' : undefined;
|
|
109
|
+
|
|
110
|
+
/** Click on the collapsed rail acts like a hover — temporary expand, not persistent. */
|
|
115
111
|
const expandOnRailClick = React.useCallback(
|
|
116
112
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
117
113
|
const interactive = (event.target as Element | null)?.closest(
|
|
118
114
|
'a, button, [role="menuitem"], [data-no-expand]',
|
|
119
115
|
);
|
|
120
116
|
if (interactive) return;
|
|
121
|
-
|
|
117
|
+
setHoverExpanded(true);
|
|
122
118
|
},
|
|
123
|
-
[
|
|
119
|
+
[setHoverExpanded],
|
|
124
120
|
);
|
|
125
121
|
|
|
126
|
-
const railExpandHintClass = collapsedRail ? 'cursor-pointer' : undefined;
|
|
127
|
-
|
|
128
122
|
const sidebarRootClass = React.useMemo(
|
|
129
123
|
() =>
|
|
130
124
|
cn(
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sidebar account: rich footer trigger (avatar + name + plan + optional secondary
|
|
3
|
-
* action) opens a popover (DropdownMenu) upward with email, account links,
|
|
4
|
-
*
|
|
3
|
+
* action) opens a popover (DropdownMenu) upward with email, account links, and
|
|
4
|
+
* sign-out. Language and theme controls live inside ProfileLayout / PreferencesSection
|
|
5
|
+
* so they are not duplicated here.
|
|
5
6
|
*
|
|
6
7
|
* Reads `isAccountMenuOpen` / `setIsAccountMenuOpen` directly from
|
|
7
8
|
* PrivateLayoutContext so the parent sidebar can block collapse while the menu
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
|
|
11
12
|
'use client';
|
|
12
13
|
|
|
13
|
-
import {
|
|
14
|
+
import { ChevronsUpDown, LogOut } from 'lucide-react';
|
|
14
15
|
import { Link } from '@djangocfg/ui-core/components';
|
|
15
16
|
import React, { memo, useMemo } from 'react';
|
|
16
17
|
|
|
@@ -30,15 +31,13 @@ import {
|
|
|
30
31
|
} from '@djangocfg/ui-core/components';
|
|
31
32
|
import { cn, isDev } from '@djangocfg/ui-core/lib';
|
|
32
33
|
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
33
|
-
import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
|
|
34
34
|
|
|
35
35
|
import { useLogout } from '../../../hooks';
|
|
36
|
-
import { LocaleSwitcherDialog } from '../../_components/locale-switcher';
|
|
37
|
-
import { getLocaleMeta } from '../../_components/locale-switcher/localeMeta';
|
|
38
36
|
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
39
37
|
import { LucideIcon as LucideIconRender } from '../../../components';
|
|
40
38
|
import { useShellVisualState } from '../hooks';
|
|
41
39
|
import { blockSidebarCollapse, allowSidebarCollapse } from '../hooks/useHoverExpand';
|
|
40
|
+
import { useProfileDialogStore } from '../../ProfileLayout/ProfileDialog/store';
|
|
42
41
|
|
|
43
42
|
import type { HeaderConfig } from '../types';
|
|
44
43
|
|
|
@@ -62,8 +61,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
62
61
|
const { setOpen: setSidebarOpen } = useSidebar();
|
|
63
62
|
const { content } = useShellVisualState();
|
|
64
63
|
const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
|
|
65
|
-
const { theme, setTheme } = useThemeContext();
|
|
66
|
-
const [langDialogOpen, setLangDialogOpen] = React.useState(false);
|
|
67
64
|
|
|
68
65
|
const signOutLabel = t('layouts.profile.signOut');
|
|
69
66
|
|
|
@@ -110,21 +107,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
110
107
|
handleLogout();
|
|
111
108
|
}, [handleLogout]);
|
|
112
109
|
|
|
113
|
-
const onLanguageSelect = React.useCallback((e: Event) => {
|
|
114
|
-
// Keep the dropdown closed (default behaviour) but defer dialog mount to
|
|
115
|
-
// the next tick so Radix has time to unmount the dropdown overlay first
|
|
116
|
-
// (avoids the "two open overlays steal focus" bug).
|
|
117
|
-
e.preventDefault();
|
|
118
|
-
setTimeout(() => setLangDialogOpen(true), 0);
|
|
119
|
-
}, []);
|
|
120
|
-
|
|
121
|
-
const onThemeSelect = React.useCallback((e: Event) => {
|
|
122
|
-
e.preventDefault();
|
|
123
|
-
// Cycle: light → dark → system → light
|
|
124
|
-
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
|
|
125
|
-
setTheme(next);
|
|
126
|
-
}, [theme, setTheme]);
|
|
127
|
-
|
|
128
110
|
// Hide entirely in production when there's no user (auth still loading or
|
|
129
111
|
// /me failed and the parent guard hasn't redirected yet). In dev keep a
|
|
130
112
|
// placeholder so the footer + Log out are reachable for debugging.
|
|
@@ -135,7 +117,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
135
117
|
const secondary = header?.footerSecondaryAction;
|
|
136
118
|
|
|
137
119
|
const triggerClassName = cn(
|
|
138
|
-
'group h-auto w-full gap-3 rounded-
|
|
120
|
+
'group h-auto w-full gap-3 rounded-lg px-3 py-3 text-left',
|
|
139
121
|
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
140
122
|
content.isAccountCompact ? 'justify-center px-0 py-2' : 'min-h-[52px]',
|
|
141
123
|
);
|
|
@@ -182,40 +164,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
182
164
|
</>
|
|
183
165
|
) : null;
|
|
184
166
|
|
|
185
|
-
const currentLocaleLabel = layoutI18n
|
|
186
|
-
? getLocaleMeta(layoutI18n.locale).native
|
|
187
|
-
: null;
|
|
188
|
-
const languageItem = layoutI18n ? (
|
|
189
|
-
<DropdownMenuItem
|
|
190
|
-
onSelect={onLanguageSelect}
|
|
191
|
-
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
|
192
|
-
>
|
|
193
|
-
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
194
|
-
<span className="flex-1 truncate">Language</span>
|
|
195
|
-
<span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
|
196
|
-
{currentLocaleLabel}
|
|
197
|
-
<ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
|
198
|
-
</span>
|
|
199
|
-
</DropdownMenuItem>
|
|
200
|
-
) : null;
|
|
201
|
-
|
|
202
|
-
const themeIcon = theme === 'dark'
|
|
203
|
-
? <Moon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
204
|
-
: theme === 'light'
|
|
205
|
-
? <Sun className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
206
|
-
: <Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
|
207
|
-
const themeValueLabel = theme === 'dark' ? 'Dark' : theme === 'light' ? 'Light' : 'System';
|
|
208
|
-
const themeItem = (
|
|
209
|
-
<DropdownMenuItem
|
|
210
|
-
onSelect={onThemeSelect}
|
|
211
|
-
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
|
212
|
-
>
|
|
213
|
-
{themeIcon}
|
|
214
|
-
<span className="flex-1 truncate">Theme</span>
|
|
215
|
-
<span className="ml-auto shrink-0 text-xs text-muted-foreground">{themeValueLabel}</span>
|
|
216
|
-
</DropdownMenuItem>
|
|
217
|
-
);
|
|
218
|
-
|
|
219
167
|
const expandedMeta = content.isAccountCompact ? null : (
|
|
220
168
|
<>
|
|
221
169
|
<span className="flex min-w-0 flex-1 flex-col text-left">
|
|
@@ -230,13 +178,49 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
230
178
|
</span>
|
|
231
179
|
<span className="flex shrink-0 items-center gap-1.5">
|
|
232
180
|
{secondaryButton}
|
|
233
|
-
|
|
181
|
+
{header?.accountAction !== 'dialog' ? (
|
|
182
|
+
<ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
|
|
183
|
+
) : null}
|
|
234
184
|
</span>
|
|
235
185
|
</>
|
|
236
186
|
);
|
|
237
187
|
|
|
188
|
+
const openProfileDialog = React.useCallback(() => {
|
|
189
|
+
useProfileDialogStore.getState().open();
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const triggerButton = (
|
|
193
|
+
<Button
|
|
194
|
+
type="button"
|
|
195
|
+
variant="ghost"
|
|
196
|
+
aria-label={content.isAccountCompact ? account.displayName : undefined}
|
|
197
|
+
className={triggerClassName}
|
|
198
|
+
onClick={header?.accountAction === 'dialog' ? openProfileDialog : onTriggerInteract}
|
|
199
|
+
>
|
|
200
|
+
<Avatar className={avatarClass}>
|
|
201
|
+
<AvatarImage src={account.avatarUrl} alt={account.displayName} />
|
|
202
|
+
<AvatarFallback className="text-sm font-semibold">{userInitial}</AvatarFallback>
|
|
203
|
+
</Avatar>
|
|
204
|
+
{expandedMeta}
|
|
205
|
+
</Button>
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const wrapperClass = cn(
|
|
209
|
+
'w-full min-w-0',
|
|
210
|
+
content.isAccountCompact ? 'px-0 pb-0' : 'px-2 pb-2',
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Dialog mode: simple button that opens the global ProfileDialog
|
|
214
|
+
if (header?.accountAction === 'dialog') {
|
|
215
|
+
return (
|
|
216
|
+
<div className={wrapperClass}>
|
|
217
|
+
{triggerButton}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
238
222
|
return (
|
|
239
|
-
<div className=
|
|
223
|
+
<div className={wrapperClass}>
|
|
240
224
|
<DropdownMenu
|
|
241
225
|
open={isAccountMenuOpen}
|
|
242
226
|
onOpenChange={(open) => {
|
|
@@ -246,19 +230,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
246
230
|
}}
|
|
247
231
|
>
|
|
248
232
|
<DropdownMenuTrigger asChild>
|
|
249
|
-
|
|
250
|
-
type="button"
|
|
251
|
-
variant="ghost"
|
|
252
|
-
aria-label={content.isAccountCompact ? account.displayName : undefined}
|
|
253
|
-
className={triggerClassName}
|
|
254
|
-
onClick={onTriggerInteract}
|
|
255
|
-
>
|
|
256
|
-
<Avatar className={avatarClass}>
|
|
257
|
-
<AvatarImage src={account.avatarUrl} alt={account.displayName} />
|
|
258
|
-
<AvatarFallback className="text-sm font-semibold">{userInitial}</AvatarFallback>
|
|
259
|
-
</Avatar>
|
|
260
|
-
{expandedMeta}
|
|
261
|
-
</Button>
|
|
233
|
+
{triggerButton}
|
|
262
234
|
</DropdownMenuTrigger>
|
|
263
235
|
|
|
264
236
|
<DropdownMenuContent
|
|
@@ -270,10 +242,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
270
242
|
{headerLabel}
|
|
271
243
|
{accountLinksBlock}
|
|
272
244
|
|
|
273
|
-
<DropdownMenuSeparator />
|
|
274
|
-
{languageItem}
|
|
275
|
-
{themeItem}
|
|
276
|
-
|
|
277
245
|
<DropdownMenuSeparator />
|
|
278
246
|
<DropdownMenuItem
|
|
279
247
|
onSelect={onLogoutSelect}
|
|
@@ -284,25 +252,13 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
284
252
|
</DropdownMenuItem>
|
|
285
253
|
</DropdownMenuContent>
|
|
286
254
|
</DropdownMenu>
|
|
287
|
-
|
|
288
|
-
{layoutI18n ? (
|
|
289
|
-
<LocaleSwitcherDialog
|
|
290
|
-
open={langDialogOpen}
|
|
291
|
-
onOpenChange={setLangDialogOpen}
|
|
292
|
-
locale={layoutI18n.locale}
|
|
293
|
-
locales={layoutI18n.locales}
|
|
294
|
-
onChange={layoutI18n.onLocaleChange}
|
|
295
|
-
brand={layoutI18n.brand}
|
|
296
|
-
i18nLabels={layoutI18n.dialogLabels}
|
|
297
|
-
/>
|
|
298
|
-
) : null}
|
|
299
255
|
</div>
|
|
300
256
|
);
|
|
301
257
|
}
|
|
302
258
|
|
|
303
259
|
/**
|
|
304
260
|
* Memoised account footer. Re-renders only when the `header` prop reference
|
|
305
|
-
* changes. Internal reactive data (user from useAuth,
|
|
261
|
+
* changes. Internal reactive data (user from useAuth, locale) are
|
|
306
262
|
* consumed via hooks and still update independently.
|
|
307
263
|
*/
|
|
308
264
|
export const PrivateSidebarAccount = memo(PrivateSidebarAccountRaw);
|
|
@@ -17,7 +17,7 @@ import { usePrivateLayoutContext } from '../context';
|
|
|
17
17
|
import { useShellVisualState } from '../hooks';
|
|
18
18
|
|
|
19
19
|
function SidebarBrandRaw() {
|
|
20
|
-
const { header, homeHref, brandTitle, brandMonogram, isMobile } =
|
|
20
|
+
const { header, homeHref, brandTitle, brandMonogram, isMobile, isHoverExpanded } =
|
|
21
21
|
usePrivateLayoutContext();
|
|
22
22
|
const { content } = useShellVisualState();
|
|
23
23
|
|
|
@@ -41,8 +41,8 @@ function SidebarBrandRaw() {
|
|
|
41
41
|
const headerRowClass = useMemo(
|
|
42
42
|
() =>
|
|
43
43
|
cn(
|
|
44
|
-
'flex items-center gap-2',
|
|
45
|
-
content.showLabels ? 'px-2' : 'px-1.5',
|
|
44
|
+
'flex items-center gap-2 mb-2',
|
|
45
|
+
// content.showLabels ? 'px-2' : 'px-1.5',
|
|
46
46
|
),
|
|
47
47
|
[content.showLabels],
|
|
48
48
|
);
|
|
@@ -147,10 +147,13 @@ function SidebarBrandRaw() {
|
|
|
147
147
|
cn(
|
|
148
148
|
'pb-2',
|
|
149
149
|
isMobile
|
|
150
|
-
? '
|
|
151
|
-
: '
|
|
150
|
+
? 'pb-3 pt-[max(1.25rem,env(safe-area-inset-top,0px))]'
|
|
151
|
+
: 'pt-3.5',
|
|
152
|
+
// Hover-expanded overlay: SidebarHeader from ui-core forces paddingLeft/Right to 0
|
|
153
|
+
// when state is collapsed. Override it so content has breathing room.
|
|
154
|
+
!isMobile && isHoverExpanded && '!px-2',
|
|
152
155
|
),
|
|
153
|
-
[isMobile],
|
|
156
|
+
[isMobile, isHoverExpanded],
|
|
154
157
|
);
|
|
155
158
|
|
|
156
159
|
return <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>;
|
|
@@ -30,11 +30,13 @@ interface UseHoverExpandResult {
|
|
|
30
30
|
onMouseEnter: () => void;
|
|
31
31
|
/** Attach to the sidebar root element */
|
|
32
32
|
onMouseLeave: () => void;
|
|
33
|
+
/** Programmatically set hover-expanded state (for click-to-expand) */
|
|
34
|
+
setHoverExpanded: (value: boolean) => void;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function useHoverExpand({
|
|
36
|
-
enterDelay =
|
|
37
|
-
leaveDelay =
|
|
38
|
+
enterDelay = 2000,
|
|
39
|
+
leaveDelay = 450,
|
|
38
40
|
enabled = true,
|
|
39
41
|
}: UseHoverExpandOptions = {}): UseHoverExpandResult {
|
|
40
42
|
const [isHoverExpanded, setIsHoverExpanded] = useState(false);
|
|
@@ -89,7 +91,12 @@ export function useHoverExpand({
|
|
|
89
91
|
return () => clearTimers();
|
|
90
92
|
}, [clearTimers]);
|
|
91
93
|
|
|
92
|
-
|
|
94
|
+
const setHoverExpanded = useCallback((value: boolean) => {
|
|
95
|
+
clearTimers();
|
|
96
|
+
setIsHoverExpanded(value);
|
|
97
|
+
}, [clearTimers]);
|
|
98
|
+
|
|
99
|
+
return { isHoverExpanded, onMouseEnter, onMouseLeave, setHoverExpanded };
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
/** Dispatch from any descendant to block sidebar collapse while e.g. a dropdown is open. */
|
|
@@ -142,7 +142,7 @@ export function useShellVisualState(
|
|
|
142
142
|
const sidebarContent: string[] = [];
|
|
143
143
|
|
|
144
144
|
if (isHoverOverlay) {
|
|
145
|
-
sidebarRoot.push('z-50', '!w-[var(--sidebar-width)]');
|
|
145
|
+
sidebarRoot.push('z-50', '!w-[var(--sidebar-width)]', 'min-w-[var(--sidebar-width)]');
|
|
146
146
|
// Allow scroll inside hover-expanded overlay — shadcn hardcodes
|
|
147
147
|
// overflow-hidden on div[data-sidebar=sidebar]; override it here.
|
|
148
148
|
sidebarInner.push('!overflow-auto');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the sidebar open/closed state from the cookie written by shadcn-sidebar.
|
|
3
|
+
* Falls back to `true` (expanded) when no cookie exists.
|
|
4
|
+
*
|
|
5
|
+
* Must run on the client — returns `true` during SSR.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
|
|
9
|
+
|
|
10
|
+
export function useSidebarDefaultOpen(): boolean {
|
|
11
|
+
if (typeof document === 'undefined') return true;
|
|
12
|
+
|
|
13
|
+
const match = document.cookie
|
|
14
|
+
.split('; ')
|
|
15
|
+
.find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`));
|
|
16
|
+
|
|
17
|
+
if (!match) return true;
|
|
18
|
+
|
|
19
|
+
const value = match.split('=')[1];
|
|
20
|
+
return value === 'true';
|
|
21
|
+
}
|
|
@@ -132,6 +132,12 @@ export interface HeaderConfig {
|
|
|
132
132
|
brandLetter?: string;
|
|
133
133
|
/** User menu groups (account panel in the sidebar footer) */
|
|
134
134
|
groups?: UserMenuConfig['groups'];
|
|
135
|
+
/**
|
|
136
|
+
* Behaviour of the footer account button.
|
|
137
|
+
* - `'menu'` (default) — opens a DropdownMenu with account links, locale/theme controls, and sign-out.
|
|
138
|
+
* - `'dialog'` — opens the global ProfileDialog (managed via Zustand store).
|
|
139
|
+
*/
|
|
140
|
+
accountAction?: 'menu' | 'dialog';
|
|
135
141
|
/** Auth page path (for sign in button) */
|
|
136
142
|
authPath?: string;
|
|
137
143
|
/** Subtitle under the display name in the sidebar footer (e.g. "Max plan"). */
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from '@djangocfg/ui-core/components';
|
|
11
|
+
|
|
12
|
+
import { ProfileForm } from '../ProfileForm';
|
|
13
|
+
import { useProfileDialogStore } from './store';
|
|
14
|
+
|
|
15
|
+
export interface ProfileDialogProps {
|
|
16
|
+
title?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const ProfileDialog: React.FC<ProfileDialogProps> = ({ title }) => {
|
|
20
|
+
const { isOpen, close, initialTab } = useProfileDialogStore();
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
|
|
24
|
+
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
|
|
25
|
+
<DialogHeader className="sr-only">
|
|
26
|
+
<DialogTitle>Profile</DialogTitle>
|
|
27
|
+
</DialogHeader>
|
|
28
|
+
<ProfileForm title={title} defaultTab={initialTab} />
|
|
29
|
+
</DialogContent>
|
|
30
|
+
</Dialog>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
|
|
3
|
+
import type { ProfileTabValue } from '../hooks/useProfileTabs';
|
|
4
|
+
|
|
5
|
+
interface ProfileDialogState {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
initialTab: ProfileTabValue | undefined;
|
|
8
|
+
open: (options?: { initialTab?: ProfileTabValue }) => void;
|
|
9
|
+
close: () => void;
|
|
10
|
+
toggle: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const useProfileDialogStore = create<ProfileDialogState>((set) => ({
|
|
14
|
+
isOpen: false,
|
|
15
|
+
initialTab: undefined,
|
|
16
|
+
open: (options) => set({ isOpen: true, initialTab: options?.initialTab }),
|
|
17
|
+
close: () => set({ isOpen: false, initialTab: undefined }),
|
|
18
|
+
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
|
19
|
+
}));
|
|
@@ -6,8 +6,8 @@ import { useAuth } from '@djangocfg/api/auth';
|
|
|
6
6
|
import { useAppT } from '@djangocfg/i18n';
|
|
7
7
|
import { toast } from '@djangocfg/ui-core/hooks';
|
|
8
8
|
|
|
9
|
-
import { profileLogger } from '
|
|
10
|
-
import { useLogout } from '
|
|
9
|
+
import { profileLogger } from '../../../utils/logger';
|
|
10
|
+
import { useLogout } from '../../../hooks';
|
|
11
11
|
|
|
12
12
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
13
|
// Types
|
|
@@ -19,6 +19,7 @@ export interface ProfileLabels {
|
|
|
19
19
|
work: string;
|
|
20
20
|
security: string;
|
|
21
21
|
apiKeys: string;
|
|
22
|
+
preferences: string;
|
|
22
23
|
firstName: string;
|
|
23
24
|
lastName: string;
|
|
24
25
|
phone: string;
|
|
@@ -79,6 +80,7 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({ children, titl
|
|
|
79
80
|
work: t('layouts.profilePage.work'),
|
|
80
81
|
security: t('layouts.profilePage.security'),
|
|
81
82
|
apiKeys: 'API Keys',
|
|
83
|
+
preferences: 'Preferences',
|
|
82
84
|
firstName: t('layouts.profilePage.firstName'),
|
|
83
85
|
lastName: t('layouts.profilePage.lastName'),
|
|
84
86
|
phone: t('layouts.profilePage.phone'),
|
|
@@ -12,11 +12,11 @@ import {
|
|
|
12
12
|
TabsTrigger,
|
|
13
13
|
} from '@djangocfg/ui-core/components';
|
|
14
14
|
|
|
15
|
-
import { ApiKeySection, ProfileHeader, ProfileTab, TwoFactorSection } from '
|
|
15
|
+
import { ApiKeySection, ProfileHeader, ProfileTab, TwoFactorSection } from '../components';
|
|
16
16
|
import { ProfileProvider, useProfileContext } from './context';
|
|
17
|
-
import { useProfileTabs } from '
|
|
18
|
-
import type {
|
|
19
|
-
import type { ProfileTabValue } from '
|
|
17
|
+
import { useProfileTabs } from '../hooks/useProfileTabs';
|
|
18
|
+
import type { ProfileFormProps } from '../types';
|
|
19
|
+
import type { ProfileTabValue } from '../hooks/useProfileTabs';
|
|
20
20
|
|
|
21
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
22
|
// Built-in tab panels
|
|
@@ -49,15 +49,18 @@ function ProfileContent({
|
|
|
49
49
|
enableDeleteAccount = true,
|
|
50
50
|
tabs = [],
|
|
51
51
|
slots,
|
|
52
|
-
|
|
52
|
+
defaultTab,
|
|
53
|
+
}: ProfileFormProps) {
|
|
53
54
|
const { labels } = useProfileContext();
|
|
54
55
|
const { user, isLoading } = useAuth();
|
|
55
56
|
|
|
56
57
|
const extraTabValues = React.useMemo(() => tabs.map((t) => t.value), [tabs]);
|
|
57
|
-
|
|
58
|
+
|
|
59
|
+
const { tab, setTab, allowed } = useProfileTabs({
|
|
58
60
|
enable2FA,
|
|
59
61
|
enableAPIKeys,
|
|
60
62
|
extraTabValues,
|
|
63
|
+
defaultTab,
|
|
61
64
|
});
|
|
62
65
|
|
|
63
66
|
const handleTabChange = React.useCallback(
|
|
@@ -136,7 +139,7 @@ function ProfileContent({
|
|
|
136
139
|
// Export
|
|
137
140
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
141
|
|
|
139
|
-
export const
|
|
142
|
+
export const ProfileForm: React.FC<ProfileFormProps> = ({ title, ...props }) => (
|
|
140
143
|
<ProfileProvider title={title}>
|
|
141
144
|
<ProfileContent title={title} {...props} />
|
|
142
145
|
</ProfileProvider>
|
|
@@ -4,10 +4,12 @@ User profile page with tabbed interface: **Profile** | **Security** | **API Keys
|
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
|
+
### Standalone page
|
|
8
|
+
|
|
7
9
|
```tsx
|
|
8
|
-
import {
|
|
10
|
+
import { ProfileForm } from '@djangocfg/layouts';
|
|
9
11
|
|
|
10
|
-
<
|
|
12
|
+
<ProfileForm
|
|
11
13
|
enable2FA
|
|
12
14
|
enableAPIKeys
|
|
13
15
|
enableDeleteAccount
|
|
@@ -19,6 +21,18 @@ import { ProfileLayout } from '@djangocfg/layouts';
|
|
|
19
21
|
/>
|
|
20
22
|
```
|
|
21
23
|
|
|
24
|
+
### Dialog
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { ProfileDialog, useProfileDialogStore } from '@djangocfg/layouts';
|
|
28
|
+
|
|
29
|
+
// Open on a specific tab
|
|
30
|
+
useProfileDialogStore.getState().open({ initialTab: 'security' });
|
|
31
|
+
|
|
32
|
+
// In your layout (lazy-loaded)
|
|
33
|
+
<ProfileDialog />
|
|
34
|
+
```
|
|
35
|
+
|
|
22
36
|
## Props
|
|
23
37
|
|
|
24
38
|
| Prop | Default | Description |
|
|
@@ -30,14 +44,60 @@ import { ProfileLayout } from '@djangocfg/layouts';
|
|
|
30
44
|
| `tabs` | `[]` | Extra `ProfileTab[]` appended after built-in tabs |
|
|
31
45
|
| `slots` | — | Named slots: `headerMenuItems`, `headerBadge`, `headerAfter`, `footer` |
|
|
32
46
|
| `title` | i18n | Page title |
|
|
47
|
+
| `defaultTab` | — | Initial active tab. When provided (e.g. by `ProfileDialog`), tabs start at this value. |
|
|
48
|
+
|
|
49
|
+
## Global Profile Dialog
|
|
50
|
+
|
|
51
|
+
`ProfileDialog` is a Zustand-driven dialog that can be opened from anywhere:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { useProfileDialogStore } from '@djangocfg/layouts';
|
|
55
|
+
|
|
56
|
+
const { open, close } = useProfileDialogStore();
|
|
57
|
+
|
|
58
|
+
// Open on Profile tab
|
|
59
|
+
open();
|
|
60
|
+
|
|
61
|
+
// Open on Security tab
|
|
62
|
+
open({ initialTab: 'security' });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Wire it into `PrivateLayout` for global access:
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { PrivateLayout } from '@djangocfg/layouts';
|
|
69
|
+
|
|
70
|
+
<PrivateLayout sidebar={...} header={...}>
|
|
71
|
+
{children}
|
|
72
|
+
</PrivateLayout>
|
|
73
|
+
// ProfileDialog is rendered automatically inside PrivateLayout
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Sidebar Account Button Modes
|
|
77
|
+
|
|
78
|
+
The footer account button in `PrivateLayout` supports two modes via `HeaderConfig`:
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
const header: HeaderConfig = {
|
|
82
|
+
// ...
|
|
83
|
+
accountAction: 'menu', // Default — opens DropdownMenu with links, theme, logout
|
|
84
|
+
accountAction: 'dialog', // Opens ProfileDialog instead
|
|
85
|
+
};
|
|
86
|
+
```
|
|
33
87
|
|
|
34
88
|
## Architecture
|
|
35
89
|
|
|
36
90
|
```
|
|
37
91
|
ProfileLayout/
|
|
38
|
-
├──
|
|
39
|
-
├──
|
|
40
|
-
|
|
92
|
+
├── ProfileForm/
|
|
93
|
+
│ ├── index.tsx Shell: ProfileProvider → header + Tabs
|
|
94
|
+
│ └── context.tsx Root context (labels, onLogout, onFieldSave)
|
|
95
|
+
├── ProfileDialog/
|
|
96
|
+
│ ├── ProfileDialog.tsx Dialog wrapper around ProfileForm
|
|
97
|
+
│ └── store.ts Zustand store (isOpen, initialTab, open/close)
|
|
98
|
+
├── hooks/
|
|
99
|
+
│ └── useProfileTabs.ts Local tab state (useState)
|
|
100
|
+
├── types.ts ProfileFormProps, ProfileTab, ProfileSlots
|
|
41
101
|
└── components/
|
|
42
102
|
├── ProfileHeader Avatar + name + dropdown menu
|
|
43
103
|
├── ProfileTab Editable fields grid (first_name, last_name, phone, company, position)
|
|
@@ -6,7 +6,7 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js';
|
|
|
6
6
|
import { Button, Input, PhoneInput } from '@djangocfg/ui-core/components';
|
|
7
7
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
8
|
|
|
9
|
-
import { useProfileContext } from '../context';
|
|
9
|
+
import { useProfileContext } from '../ProfileForm/context';
|
|
10
10
|
|
|
11
11
|
function formatPhone(raw: string): string {
|
|
12
12
|
if (!raw) return '';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preferences Section
|
|
3
|
+
*
|
|
4
|
+
* Language + theme controls for ProfileForm.
|
|
5
|
+
* Uses ThemeToggle from ui-nextjs and LocaleSwitcherDropdown from _components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
|
|
12
|
+
import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
|
|
13
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
|
+
|
|
15
|
+
import { LocaleSwitcherDropdown } from '../../_components/locale-switcher';
|
|
16
|
+
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
17
|
+
|
|
18
|
+
interface PreferencesSectionProps {
|
|
19
|
+
/** Extra className for the root. */
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const PreferencesSection: React.FC<PreferencesSectionProps> = ({
|
|
24
|
+
className,
|
|
25
|
+
}) => {
|
|
26
|
+
const layoutI18n = useLayoutI18nOptional();
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={cn('py-0', className)}>
|
|
30
|
+
{layoutI18n && (
|
|
31
|
+
<>
|
|
32
|
+
<div className="flex items-center justify-between py-3">
|
|
33
|
+
<span className="text-sm">Language</span>
|
|
34
|
+
<LocaleSwitcherDropdown
|
|
35
|
+
locale={layoutI18n.locale}
|
|
36
|
+
locales={layoutI18n.locales}
|
|
37
|
+
onChange={layoutI18n.onLocaleChange}
|
|
38
|
+
variant="outline"
|
|
39
|
+
size="sm"
|
|
40
|
+
showCode
|
|
41
|
+
showIcon={false}
|
|
42
|
+
showFlag
|
|
43
|
+
showTriggerLabel
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="h-px bg-border/60" />
|
|
47
|
+
</>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
<div className="flex items-center justify-between py-3">
|
|
51
|
+
<span className="text-sm">Theme</span>
|
|
52
|
+
<ThemeToggle size="default" />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from '@djangocfg/ui-core/components';
|
|
17
17
|
|
|
18
18
|
import { AvatarSection } from './AvatarSection';
|
|
19
|
-
import { useProfileContext } from '../context';
|
|
19
|
+
import { useProfileContext } from '../ProfileForm/context';
|
|
20
20
|
import type { ProfileSlots } from '../types';
|
|
21
21
|
|
|
22
22
|
interface ProfileHeaderProps {
|
|
@@ -4,8 +4,8 @@ import React from 'react';
|
|
|
4
4
|
|
|
5
5
|
import { useAuth } from '@djangocfg/api/auth';
|
|
6
6
|
|
|
7
|
-
import { EditableField, Section } from '.';
|
|
8
|
-
import { useProfileContext } from '../context';
|
|
7
|
+
import { EditableField, PreferencesSection, Section } from '.';
|
|
8
|
+
import { useProfileContext } from '../ProfileForm/context';
|
|
9
9
|
|
|
10
10
|
export const ProfileTab: React.FC = () => {
|
|
11
11
|
const { labels, onFieldSave } = useProfileContext();
|
|
@@ -13,16 +13,22 @@ export const ProfileTab: React.FC = () => {
|
|
|
13
13
|
if (!user) return null;
|
|
14
14
|
|
|
15
15
|
return (
|
|
16
|
-
<div className="
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
<div className="space-y-6 pt-4">
|
|
17
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-6">
|
|
18
|
+
<Section title={labels.personalInfo}>
|
|
19
|
+
<EditableField label={labels.firstName} value={user.first_name || ''} placeholder={labels.addFirstName} onSave={(v) => onFieldSave('first_name', v)} />
|
|
20
|
+
<EditableField label={labels.lastName} value={user.last_name || ''} placeholder={labels.addLastName} onSave={(v) => onFieldSave('last_name', v)} />
|
|
21
|
+
<EditableField label={labels.phone} value={user.phone || ''} placeholder={labels.addPhone} onSave={(v) => onFieldSave('phone', v)} type="phone" />
|
|
22
|
+
</Section>
|
|
23
|
+
|
|
24
|
+
<Section title={labels.work}>
|
|
25
|
+
<EditableField label={labels.company} value={user.company || ''} placeholder={labels.addCompany} onSave={(v) => onFieldSave('company', v)} />
|
|
26
|
+
<EditableField label={labels.position} value={user.position || ''} placeholder={labels.addPosition} onSave={(v) => onFieldSave('position', v)} />
|
|
27
|
+
</Section>
|
|
28
|
+
</div>
|
|
22
29
|
|
|
23
|
-
<Section title={labels.
|
|
24
|
-
<
|
|
25
|
-
<EditableField label={labels.position} value={user.position || ''} placeholder={labels.addPosition} onSave={(v) => onFieldSave('position', v)} />
|
|
30
|
+
<Section title={labels.preferences ?? 'Preferences'}>
|
|
31
|
+
<PreferencesSection />
|
|
26
32
|
</Section>
|
|
27
33
|
</div>
|
|
28
34
|
);
|
|
@@ -4,6 +4,7 @@ export type { ApiKeyLabels } from './ApiKeySection';
|
|
|
4
4
|
export { AvatarSection } from './AvatarSection';
|
|
5
5
|
export { DeleteAccountSection, DeleteAccountScreen } from './DeleteAccountSection';
|
|
6
6
|
export { EditableField } from './EditableField';
|
|
7
|
+
export { PreferencesSection } from './PreferencesSection';
|
|
7
8
|
export { ProfileHeader } from './ProfileHeader';
|
|
8
9
|
export { ProfileTab } from './ProfileTab';
|
|
9
10
|
export { Section } from './Section';
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useMemo } from 'react';
|
|
4
|
-
|
|
5
|
-
import { useQueryState, parseAsStringEnum } from '@djangocfg/ui-core/hooks';
|
|
3
|
+
import { useMemo, useState, useCallback } from 'react';
|
|
6
4
|
|
|
7
5
|
export type ProfileTabValue = 'profile' | 'security' | 'api-keys';
|
|
8
6
|
|
|
@@ -10,20 +8,21 @@ export interface UseProfileTabsOptions {
|
|
|
10
8
|
enable2FA?: boolean;
|
|
11
9
|
enableAPIKeys?: boolean;
|
|
12
10
|
extraTabValues?: string[];
|
|
11
|
+
/** Initial active tab. Defaults to `'profile'`. */
|
|
12
|
+
defaultTab?: ProfileTabValue;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* Manages the active profile tab with local React state.
|
|
17
17
|
*
|
|
18
|
-
* Falls back to `'profile'` when
|
|
19
|
-
* Setting the default value clears the key from the URL (no `?tab=profile`).
|
|
18
|
+
* Falls back to `'profile'` when `defaultTab` is missing or not allowed.
|
|
20
19
|
*
|
|
21
20
|
* @example
|
|
22
|
-
* const { tab, setTab, allowed } = useProfileTabs({ enable2FA: true });
|
|
21
|
+
* const { tab, setTab, allowed } = useProfileTabs({ enable2FA: true, defaultTab: 'security' });
|
|
23
22
|
* <Tabs value={tab} onValueChange={setTab}>...</Tabs>
|
|
24
23
|
*/
|
|
25
24
|
export function useProfileTabs(options: UseProfileTabsOptions = {}) {
|
|
26
|
-
const { enable2FA, enableAPIKeys, extraTabValues = [] } = options;
|
|
25
|
+
const { enable2FA, enableAPIKeys, extraTabValues = [], defaultTab } = options;
|
|
27
26
|
|
|
28
27
|
const allowed = useMemo(() => {
|
|
29
28
|
const base: ProfileTabValue[] = ['profile'];
|
|
@@ -32,12 +31,18 @@ export function useProfileTabs(options: UseProfileTabsOptions = {}) {
|
|
|
32
31
|
return [...base, ...extraTabValues] as ProfileTabValue[];
|
|
33
32
|
}, [enable2FA, enableAPIKeys, extraTabValues]);
|
|
34
33
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
[allowed],
|
|
34
|
+
const [tab, setTabState] = useState<ProfileTabValue>(
|
|
35
|
+
defaultTab && allowed.includes(defaultTab) ? defaultTab : 'profile',
|
|
38
36
|
);
|
|
39
37
|
|
|
40
|
-
const
|
|
38
|
+
const setTab = useCallback(
|
|
39
|
+
(value: ProfileTabValue) => {
|
|
40
|
+
if (allowed.includes(value)) {
|
|
41
|
+
setTabState(value);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
[allowed],
|
|
45
|
+
);
|
|
41
46
|
|
|
42
47
|
return { tab, setTab, allowed };
|
|
43
48
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export { ProfileProvider } from './context';
|
|
1
|
+
export { ProfileForm, useProfileContext } from './ProfileForm';
|
|
2
|
+
export { ProfileProvider } from './ProfileForm/context';
|
|
3
3
|
export { useProfileTabs } from './hooks';
|
|
4
|
-
export
|
|
5
|
-
export type {
|
|
4
|
+
export { ProfileDialog, useProfileDialogStore } from './ProfileDialog';
|
|
5
|
+
export type { ProfileLabels } from './ProfileForm/context';
|
|
6
|
+
export type { ProfileFormProps, ProfileLayoutProps, ProfileTab, ProfileSlots } from './types';
|
|
6
7
|
export type { ProfileTabValue, UseProfileTabsOptions } from './hooks';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
|
+
import type { ProfileTabValue } from './hooks/useProfileTabs';
|
|
4
|
+
|
|
3
5
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
6
|
// Slot + Tab types
|
|
5
7
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -24,7 +26,7 @@ export interface ProfileSlots {
|
|
|
24
26
|
footer?: React.ReactNode;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
export interface
|
|
29
|
+
export interface ProfileFormProps {
|
|
28
30
|
onUnauthenticated?: () => void;
|
|
29
31
|
title?: string;
|
|
30
32
|
enable2FA?: boolean;
|
|
@@ -34,4 +36,12 @@ export interface ProfileLayoutProps {
|
|
|
34
36
|
tabs?: ProfileTab[];
|
|
35
37
|
/** Named slots for additional content */
|
|
36
38
|
slots?: ProfileSlots;
|
|
39
|
+
/**
|
|
40
|
+
* When provided, the active tab is controlled locally (no URL sync).
|
|
41
|
+
* Useful for dialogs where query-string pollution is undesirable.
|
|
42
|
+
*/
|
|
43
|
+
defaultTab?: ProfileTabValue;
|
|
37
44
|
}
|
|
45
|
+
|
|
46
|
+
/** @deprecated Use ProfileFormProps instead */
|
|
47
|
+
export type ProfileLayoutProps = ProfileFormProps;
|