@djangocfg/layouts 2.1.357 → 2.1.359

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.
Files changed (37) hide show
  1. package/package.json +21 -19
  2. package/src/configurator/private/schema.ts +20 -0
  3. package/src/layouts/PrivateLayout/PrivateLayout.tsx +17 -1
  4. package/src/layouts/PrivateLayout/README.md +47 -1
  5. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +5 -72
  6. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +47 -96
  7. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +36 -17
  8. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +223 -0
  9. package/src/layouts/PrivateLayout/components/index.ts +1 -0
  10. package/src/layouts/PrivateLayout/context.tsx +2 -9
  11. package/src/layouts/PrivateLayout/hooks/index.ts +1 -5
  12. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +10 -3
  13. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +11 -88
  14. package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +32 -0
  15. package/src/layouts/PrivateLayout/index.ts +3 -0
  16. package/src/layouts/PrivateLayout/types.ts +41 -0
  17. package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
  18. package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
  19. package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
  20. package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +4 -2
  21. package/src/layouts/ProfileLayout/{ProfileLayout.tsx → ProfileForm/index.tsx} +10 -7
  22. package/src/layouts/ProfileLayout/README.md +65 -5
  23. package/src/layouts/ProfileLayout/components/EditableField.tsx +1 -1
  24. package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
  25. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +1 -1
  26. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +17 -11
  27. package/src/layouts/ProfileLayout/components/index.ts +1 -0
  28. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +17 -12
  29. package/src/layouts/ProfileLayout/index.ts +5 -4
  30. package/src/layouts/ProfileLayout/types.ts +11 -1
  31. package/src/layouts/_components/index.ts +1 -0
  32. package/src/layouts/types/providers.types.ts +2 -2
  33. package/src/theme/ThemeStyleBridge.tsx +1 -3
  34. package/src/theme/index.ts +2 -4
  35. package/src/theme/buildThemeStyleSheet.ts +0 -71
  36. package/src/theme/themeStyle.types.ts +0 -89
  37. package/src/theme/themeStylePresets.ts +0 -202
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.357",
3
+ "version": "2.1.359",
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.357",
88
- "@djangocfg/centrifugo": "^2.1.357",
89
- "@djangocfg/debuger": "^2.1.357",
90
- "@djangocfg/i18n": "^2.1.357",
91
- "@djangocfg/monitor": "^2.1.357",
92
- "@djangocfg/ui-core": "^2.1.357",
93
- "@djangocfg/ui-nextjs": "^2.1.357",
87
+ "@djangocfg/api": "^2.1.359",
88
+ "@djangocfg/centrifugo": "^2.1.359",
89
+ "@djangocfg/debuger": "^2.1.359",
90
+ "@djangocfg/i18n": "^2.1.359",
91
+ "@djangocfg/monitor": "^2.1.359",
92
+ "@djangocfg/ui-core": "^2.1.359",
93
+ "@djangocfg/ui-nextjs": "^2.1.359",
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.357",
124
- "@djangocfg/centrifugo": "^2.1.357",
125
- "@djangocfg/debuger": "^2.1.357",
126
- "@djangocfg/i18n": "^2.1.357",
127
- "@djangocfg/monitor": "^2.1.357",
128
- "@djangocfg/typescript-config": "^2.1.357",
129
- "@djangocfg/ui-core": "^2.1.357",
130
- "@djangocfg/ui-nextjs": "^2.1.357",
131
- "@djangocfg/ui-tools": "^2.1.357",
124
+ "@djangocfg/api": "^2.1.359",
125
+ "@djangocfg/centrifugo": "^2.1.359",
126
+ "@djangocfg/debuger": "^2.1.359",
127
+ "@djangocfg/i18n": "^2.1.359",
128
+ "@djangocfg/monitor": "^2.1.359",
129
+ "@djangocfg/typescript-config": "^2.1.359",
130
+ "@djangocfg/ui-core": "^2.1.359",
131
+ "@djangocfg/ui-nextjs": "^2.1.359",
132
+ "@djangocfg/ui-tools": "^2.1.359",
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,8 @@ export interface PrivateLayoutConfiguratorData {
37
37
  header: {
38
38
  userPlan: string;
39
39
  showSecondaryAction: boolean;
40
+ accountAction: 'menu' | 'dialog';
41
+ showSwitcher: boolean;
40
42
  };
41
43
  }
42
44
 
@@ -126,6 +128,17 @@ export const privateLayoutConfiguratorSchema: CustomJsonSchema7 = {
126
128
  title: 'Footer secondary action',
127
129
  description: 'Adds a download-style icon button inside the footer trigger with a pulsing accent dot.',
128
130
  },
131
+ accountAction: {
132
+ type: 'string',
133
+ title: 'Account action',
134
+ enum: ['menu', 'dialog'],
135
+ description: "'menu' opens a dropdown; 'dialog' opens the global ProfileDialog.",
136
+ },
137
+ showSwitcher: {
138
+ type: 'boolean',
139
+ title: 'Brand switcher',
140
+ description: 'Replace the static brand with a workspace/account dropdown.',
141
+ },
129
142
  },
130
143
  },
131
144
  },
@@ -165,6 +178,11 @@ export const privateLayoutConfiguratorUiSchema: CustomJsonUiSchema7 = {
165
178
  header: {
166
179
  'ui:collapsible': true,
167
180
  showSecondaryAction: { 'ui:widget': 'switch' },
181
+ showSwitcher: { 'ui:widget': 'switch' },
182
+ accountAction: {
183
+ 'ui:widget': 'radio',
184
+ 'ui:options': { inline: true },
185
+ },
168
186
  },
169
187
  };
170
188
 
@@ -186,5 +204,7 @@ export const defaultPrivateLayoutConfiguratorData: PrivateLayoutConfiguratorData
186
204
  header: {
187
205
  userPlan: 'Pro plan',
188
206
  showSecondaryAction: false,
207
+ accountAction: 'menu',
208
+ showSwitcher: false,
189
209
  },
190
210
  };
@@ -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,
@@ -33,10 +35,20 @@ export type {
33
35
  SidebarActiveIndicator,
34
36
  SidebarGroupLabelStyle,
35
37
  SidebarFeaturedConfig,
38
+ SidebarBrandSwitcherConfig,
39
+ SidebarBrandSwitcherItem,
36
40
  } from './types';
37
41
 
42
+ export { SidebarBrandSwitcher } from './components';
43
+
38
44
  export { PrivateLayoutProps };
39
45
 
46
+ // Lazy-load ProfileDialog so the profile bundle is only fetched when opened.
47
+ const ProfileDialog = dynamic(
48
+ () => import('../ProfileLayout/ProfileDialog/ProfileDialog').then((m) => m.ProfileDialog),
49
+ { ssr: false },
50
+ );
51
+
40
52
  export function PrivateLayout({
41
53
  children,
42
54
  sidebar,
@@ -55,6 +67,8 @@ export function PrivateLayout({
55
67
  const { providerStyle, providerClassName, insetClassName, sidebarVariant } =
56
68
  useLayoutVisual(visual);
57
69
 
70
+ const defaultOpen = useSidebarDefaultOpen();
71
+
58
72
  if (isLoading) {
59
73
  return (
60
74
  <Preloader
@@ -69,7 +83,7 @@ export function PrivateLayout({
69
83
 
70
84
  return (
71
85
  <SidebarProvider
72
- defaultOpen={true}
86
+ defaultOpen={defaultOpen}
73
87
  style={providerStyle}
74
88
  className={providerClassName}
75
89
  >
@@ -92,6 +106,8 @@ export function PrivateLayout({
92
106
  {children}
93
107
  </PrivateContent>
94
108
  </SidebarInset>
109
+
110
+ <ProfileDialog />
95
111
  </SidebarProvider>
96
112
  );
97
113
  }
@@ -119,16 +119,62 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
119
119
 
120
120
  | Field | Type | Notes |
121
121
  |---|---|---|
122
- | `brand`, `title`, `brandIcon`, `brandLetter` | | Sidebar header brand. |
122
+ | `switcher` | `SidebarBrandSwitcherConfig` | Brand switcher dropdown (replaces `brand`/`title`/`brandIcon` when set). |
123
+ | `brand`, `title`, `brandIcon`, `brandLetter` | — | Static sidebar header brand (used when `switcher` is not set). |
123
124
  | `groups` | `UserMenuGroup[]` | Account links rendered inside the footer dropdown. |
124
125
  | `authPath` | `string` | Sign-in redirect target. |
125
126
  | `userPlan` | `string` | Subtitle under the display name (e.g. `"Max plan"`). |
127
+ | `accountAction` | `'menu' \| 'dialog'` | Footer button behaviour. `'menu'` opens dropdown; `'dialog'` opens global ProfileDialog. Default `'menu'`. |
126
128
  | `footerSecondaryAction` | `{ icon, href?, onClick?, ariaLabel, pulse? }` | Optional secondary icon button inside the footer trigger (e.g. "Get apps" with pulsing dot). |
127
129
 
128
130
  The footer button opens a popover (`DropdownMenu`, `side="top"`) with: email, account links, **Language** (opens fullscreen `LocaleSwitcherDialog` if `LayoutI18nProvider` is mounted), **Theme** (cycles `light → dark → system`), and **Log out**. In dev mode the footer renders a `Guest (dev)` placeholder when there's no authenticated user, so debug controls stay reachable; in production the block hides itself.
129
131
 
130
132
  ---
131
133
 
134
+ ## Brand switcher
135
+
136
+ Replace the static brand header with a workspace/account/project switcher:
137
+
138
+ ```tsx
139
+ import { SidebarBrandSwitcherConfig } from '@djangocfg/layouts';
140
+
141
+ const switcher: SidebarBrandSwitcherConfig = {
142
+ items: [
143
+ { label: 'My Workspace', description: 'Personal', href: '/', active: true },
144
+ { label: 'Team Workspace', description: 'Pro plan', href: '/team' },
145
+ { label: 'Client Project', onSelect: () => switchProject('client') },
146
+ ],
147
+ addLabel: 'Add workspace',
148
+ onAdd: () => openCreateDialog(),
149
+ };
150
+
151
+ <PrivateLayout header={{ switcher }} ...>
152
+ ```
153
+
154
+ `SidebarBrandSwitcherConfig`:
155
+
156
+ | Field | Type | Notes |
157
+ |---|---|---|
158
+ | `items` | `SidebarBrandSwitcherItem[]` | List of workspaces / accounts / projects. |
159
+ | `addLabel` | `string` | Label for the "add new" action at the bottom. Omit to hide. |
160
+ | `onAdd` | `() => void` | Called when "add new" is clicked. |
161
+
162
+ `SidebarBrandSwitcherItem`:
163
+
164
+ | Field | Type | Notes |
165
+ |---|---|---|
166
+ | `label` | `string` | Display name. |
167
+ | `avatar` | `string` | Image URL. Falls back to `monogram` / first letter of `label`. |
168
+ | `monogram` | `string` | Single letter override for the avatar fallback. |
169
+ | `description` | `string` | Secondary line under the label (plan, role, etc.). |
170
+ | `href` | `string` | Navigate on select. |
171
+ | `onSelect` | `() => void` | Callback on select (alternative to `href`). |
172
+ | `active` | `boolean` | Mark as currently selected (shows checkmark in dropdown). |
173
+
174
+ On the collapsed icon-rail the switcher renders only the active item's avatar — no dropdown trigger.
175
+
176
+ ---
177
+
132
178
  ## Sample
133
179
 
134
180
  ```tsx
@@ -1,10 +1,3 @@
1
- /**
2
- * Private Sidebar
3
- *
4
- * Composed from smaller components: SidebarBrand, SidebarNavGroup, SidebarSlots.
5
- * Uses PrivateLayoutContext for all UI state.
6
- */
7
-
8
1
  'use client';
9
2
 
10
3
  import React from 'react';
@@ -19,7 +12,7 @@ import { cn } from '@djangocfg/ui-core/lib';
19
12
 
20
13
  import { PrivateSidebarAccount } from './PrivateSidebarAccount';
21
14
  import { PrivateLayoutProvider } from '../context';
22
- import { useHoverExpand, useShellVisualState, useSidebarKeyboard } from '../hooks';
15
+ import { useShellVisualState, useSidebarKeyboard } from '../hooks';
23
16
  import type { HeaderConfig, SidebarConfig } from '../types';
24
17
  import { SidebarBrand } from './SidebarBrand';
25
18
  import { SidebarNavGroup } from './SidebarNavGroup';
@@ -29,11 +22,6 @@ interface PrivateSidebarProps {
29
22
  sidebar: SidebarConfig;
30
23
  header?: HeaderConfig;
31
24
  pathname?: string;
32
- /**
33
- * shadcn-sidebar `variant`. Used to trigger the inset/boxed visual:
34
- * `'inset'` makes the sidebar wrapper paint `bg-sidebar` and lets `SidebarInset`
35
- * float as a rounded card. Default `'sidebar'` (full-bleed).
36
- */
37
25
  variant?: 'sidebar' | 'inset';
38
26
  }
39
27
 
@@ -43,18 +31,13 @@ export function PrivateSidebar({
43
31
  pathname: pathnameProp,
44
32
  variant = 'sidebar',
45
33
  }: PrivateSidebarProps) {
46
- const { state, isMobile, setOpenMobile, setOpen } = useSidebar();
34
+ const { state, isMobile, setOpenMobile } = useSidebar();
47
35
  const pathname = pathnameProp ?? '';
48
36
 
49
37
  React.useEffect(() => {
50
38
  if (isMobile) setOpenMobile(false);
51
39
  }, [pathname, isMobile, setOpenMobile]);
52
40
 
53
- const collapsedRail = !isMobile && state === 'collapsed';
54
- const { isHoverExpanded, onMouseEnter, onMouseLeave } = useHoverExpand({
55
- enabled: collapsedRail,
56
- });
57
-
58
41
  return (
59
42
  <PrivateLayoutProvider
60
43
  sidebar={sidebar}
@@ -62,86 +45,39 @@ export function PrivateSidebar({
62
45
  pathname={pathname}
63
46
  isMobile={isMobile}
64
47
  state={state}
65
- isHoverExpanded={isHoverExpanded}
66
48
  >
67
49
  <PrivateSidebarInner
68
50
  sidebar={sidebar}
69
51
  header={header}
70
52
  variant={variant}
71
- collapsedRail={collapsedRail}
72
- setOpen={setOpen}
73
- onMouseEnter={onMouseEnter}
74
- onMouseLeave={onMouseLeave}
75
53
  />
76
54
  </PrivateLayoutProvider>
77
55
  );
78
56
  }
79
57
 
80
- // ---------------------------------------------------------------------------
81
- // Inner component — runs inside PrivateLayoutProvider so useShellVisualState
82
- // can safely consume the context.
83
- // ---------------------------------------------------------------------------
84
-
85
58
  interface PrivateSidebarInnerProps {
86
59
  sidebar: SidebarConfig;
87
60
  header?: HeaderConfig;
88
61
  variant: 'sidebar' | 'inset';
89
- collapsedRail: boolean;
90
- setOpen: (open: boolean) => void;
91
- onMouseEnter: () => void;
92
- onMouseLeave: () => void;
93
62
  }
94
63
 
95
- function PrivateSidebarInner({
96
- sidebar,
97
- header,
98
- variant,
99
- collapsedRail,
100
- setOpen,
101
- onMouseEnter,
102
- onMouseLeave,
103
- }: PrivateSidebarInnerProps) {
64
+ function PrivateSidebarInner({ sidebar, header, variant }: PrivateSidebarInnerProps) {
104
65
  const layoutVariant = variant === 'inset' ? 'boxed' : 'full-bleed';
105
66
  const { modifiers } = useShellVisualState(layoutVariant);
106
67
  const { setSidebarRef, handleSidebarKeyDown } = useSidebarKeyboard();
107
68
 
108
- /**
109
- * Click on the collapsed icon-rail expands the sidebar — but only on empty
110
- * areas. Native interactive elements (nav links, the trigger, account menu,
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
- */
115
- const expandOnRailClick = React.useCallback(
116
- (event: React.MouseEvent<HTMLDivElement>) => {
117
- const interactive = (event.target as Element | null)?.closest(
118
- 'a, button, [role="menuitem"], [data-no-expand]',
119
- );
120
- if (interactive) return;
121
- setOpen(true);
122
- },
123
- [setOpen],
124
- );
125
-
126
- const railExpandHintClass = collapsedRail ? 'cursor-pointer' : undefined;
127
-
128
69
  const sidebarRootClass = React.useMemo(
129
70
  () =>
130
71
  cn(
131
- railExpandHintClass,
132
72
  '[&>[data-sidebar=sidebar]]:bg-gradient-to-t [&>[data-sidebar=sidebar]]:from-sidebar/85 [&>[data-sidebar=sidebar]]:to-sidebar',
133
73
  modifiers.sidebarRoot,
134
74
  modifiers.sidebarInner.map((m) => `[&>[data-sidebar=sidebar]]:${m}`),
135
75
  ),
136
- [railExpandHintClass, modifiers],
76
+ [modifiers],
137
77
  );
138
78
 
139
79
  const sidebarContentClass = React.useMemo(
140
- () =>
141
- cn(
142
- 'gap-2',
143
- modifiers.sidebarContent,
144
- ),
80
+ () => cn('gap-2', modifiers.sidebarContent),
145
81
  [modifiers.sidebarContent],
146
82
  );
147
83
 
@@ -165,9 +101,6 @@ function PrivateSidebarInner({
165
101
  collapsible="icon"
166
102
  variant={variant}
167
103
  className={sidebarRootClass}
168
- onClick={collapsedRail ? expandOnRailClick : undefined}
169
- onMouseEnter={onMouseEnter}
170
- onMouseLeave={onMouseLeave}
171
104
  onKeyDown={handleSidebarKeyDown}
172
105
  >
173
106
  <SidebarBrand />
@@ -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, locale +
4
- * theme controls, and sign-out. Replaces the legacy inline collapsible.
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 { ChevronRight, ChevronsUpDown, Globe, LogOut, Monitor, Moon, Sun } from 'lucide-react';
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,12 @@ 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
- import { blockSidebarCollapse, allowSidebarCollapse } from '../hooks/useHoverExpand';
39
+ import { useProfileDialogStore } from '../../ProfileLayout/ProfileDialog/store';
42
40
 
43
41
  import type { HeaderConfig } from '../types';
44
42
 
@@ -62,8 +60,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
62
60
  const { setOpen: setSidebarOpen } = useSidebar();
63
61
  const { content } = useShellVisualState();
64
62
  const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
65
- const { theme, setTheme } = useThemeContext();
66
- const [langDialogOpen, setLangDialogOpen] = React.useState(false);
67
63
 
68
64
  const signOutLabel = t('layouts.profile.signOut');
69
65
 
@@ -110,21 +106,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
110
106
  handleLogout();
111
107
  }, [handleLogout]);
112
108
 
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
109
  // Hide entirely in production when there's no user (auth still loading or
129
110
  // /me failed and the parent guard hasn't redirected yet). In dev keep a
130
111
  // placeholder so the footer + Log out are reachable for debugging.
@@ -135,7 +116,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
135
116
  const secondary = header?.footerSecondaryAction;
136
117
 
137
118
  const triggerClassName = cn(
138
- 'group h-auto w-full gap-3 rounded-none px-3 py-3 text-left',
119
+ 'group h-auto w-full gap-3 rounded-lg px-3 py-3 text-left',
139
120
  'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
140
121
  content.isAccountCompact ? 'justify-center px-0 py-2' : 'min-h-[52px]',
141
122
  );
@@ -182,40 +163,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
182
163
  </>
183
164
  ) : null;
184
165
 
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
166
  const expandedMeta = content.isAccountCompact ? null : (
220
167
  <>
221
168
  <span className="flex min-w-0 flex-1 flex-col text-left">
@@ -230,35 +177,55 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
230
177
  </span>
231
178
  <span className="flex shrink-0 items-center gap-1.5">
232
179
  {secondaryButton}
233
- <ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
180
+ {header?.accountAction !== 'dialog' ? (
181
+ <ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
182
+ ) : null}
234
183
  </span>
235
184
  </>
236
185
  );
237
186
 
187
+ const openProfileDialog = React.useCallback(() => {
188
+ useProfileDialogStore.getState().open();
189
+ }, []);
190
+
191
+ const triggerButton = (
192
+ <Button
193
+ type="button"
194
+ variant="ghost"
195
+ aria-label={content.isAccountCompact ? account.displayName : undefined}
196
+ className={triggerClassName}
197
+ onClick={header?.accountAction === 'dialog' ? openProfileDialog : onTriggerInteract}
198
+ >
199
+ <Avatar className={avatarClass}>
200
+ <AvatarImage src={account.avatarUrl} alt={account.displayName} />
201
+ <AvatarFallback className="text-sm font-semibold">{userInitial}</AvatarFallback>
202
+ </Avatar>
203
+ {expandedMeta}
204
+ </Button>
205
+ );
206
+
207
+ const wrapperClass = cn(
208
+ 'w-full min-w-0',
209
+ content.isAccountCompact ? 'px-0 pb-0' : 'px-2 pb-2',
210
+ );
211
+
212
+ // Dialog mode: simple button that opens the global ProfileDialog
213
+ if (header?.accountAction === 'dialog') {
214
+ return (
215
+ <div className={wrapperClass}>
216
+ {triggerButton}
217
+ </div>
218
+ );
219
+ }
220
+
238
221
  return (
239
- <div className="w-full min-w-0 border-t border-sidebar-border/40">
222
+ <div className={wrapperClass}>
240
223
  <DropdownMenu
241
224
  open={isAccountMenuOpen}
242
- onOpenChange={(open) => {
243
- setIsAccountMenuOpen(open);
244
- if (open) blockSidebarCollapse();
245
- else allowSidebarCollapse();
246
- }}
225
+ onOpenChange={setIsAccountMenuOpen}
247
226
  >
248
227
  <DropdownMenuTrigger asChild>
249
- <Button
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>
228
+ {triggerButton}
262
229
  </DropdownMenuTrigger>
263
230
 
264
231
  <DropdownMenuContent
@@ -270,10 +237,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
270
237
  {headerLabel}
271
238
  {accountLinksBlock}
272
239
 
273
- <DropdownMenuSeparator />
274
- {languageItem}
275
- {themeItem}
276
-
277
240
  <DropdownMenuSeparator />
278
241
  <DropdownMenuItem
279
242
  onSelect={onLogoutSelect}
@@ -284,25 +247,13 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
284
247
  </DropdownMenuItem>
285
248
  </DropdownMenuContent>
286
249
  </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
250
  </div>
300
251
  );
301
252
  }
302
253
 
303
254
  /**
304
255
  * Memoised account footer. Re-renders only when the `header` prop reference
305
- * changes. Internal reactive data (user from useAuth, theme, locale) are
256
+ * changes. Internal reactive data (user from useAuth, locale) are
306
257
  * consumed via hooks and still update independently.
307
258
  */
308
259
  export const PrivateSidebarAccount = memo(PrivateSidebarAccountRaw);