@djangocfg/layouts 2.1.426 → 2.1.428

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 (77) hide show
  1. package/README.md +29 -21
  2. package/package.json +15 -17
  3. package/src/components/errors/ErrorsTracker/components/ErrorToast.tsx +19 -0
  4. package/src/components/errors/ErrorsTracker/utils/curl-generator.ts +24 -10
  5. package/src/components/errors/README.md +63 -0
  6. package/src/layouts/AppLayout/BaseApp.tsx +36 -52
  7. package/src/layouts/AppLayout/README.md +79 -64
  8. package/src/layouts/AppLayout/index.ts +12 -19
  9. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +6 -4
  10. package/src/layouts/PrivateLayout/PrivateLayout.tsx +7 -4
  11. package/src/layouts/PrivateLayout/README.md +30 -0
  12. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +6 -2
  13. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +105 -70
  14. package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +12 -3
  15. package/src/layouts/PrivateLayout/types.ts +8 -3
  16. package/src/layouts/PublicLayout/components/UserMenu.tsx +68 -113
  17. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +0 -6
  18. package/src/layouts/PublicLayout/navbars/MinimalNavbar/index.ts +1 -1
  19. package/src/layouts/SettingsLayout/README.md +258 -0
  20. package/src/layouts/SettingsLayout/SettingsDialog.tsx +101 -0
  21. package/src/layouts/SettingsLayout/SettingsForm.tsx +100 -0
  22. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +192 -0
  23. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +71 -0
  24. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +57 -0
  25. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +48 -0
  26. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +50 -0
  27. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +77 -0
  28. package/src/layouts/SettingsLayout/components/SettingsTabs.tsx +56 -0
  29. package/src/layouts/{ProfileLayout → SettingsLayout}/components/TwoFactorSection/TwoFactorSection.tsx +84 -130
  30. package/src/layouts/SettingsLayout/components/index.ts +6 -0
  31. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +122 -0
  32. package/src/layouts/SettingsLayout/context/index.ts +2 -0
  33. package/src/layouts/SettingsLayout/hooks/index.ts +12 -0
  34. package/src/layouts/SettingsLayout/hooks/useProfileSave.ts +95 -0
  35. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +52 -0
  36. package/src/layouts/SettingsLayout/hooks/useSettingsSections.ts +123 -0
  37. package/src/layouts/SettingsLayout/hooks/useSettingsUrl.ts +140 -0
  38. package/src/layouts/SettingsLayout/index.ts +67 -0
  39. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +100 -0
  40. package/src/layouts/SettingsLayout/sections/ApiKeysSection.tsx +15 -0
  41. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +57 -0
  42. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +43 -0
  43. package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +15 -0
  44. package/src/layouts/SettingsLayout/sections/builtins.tsx +77 -0
  45. package/src/layouts/SettingsLayout/sections/index.ts +8 -0
  46. package/src/layouts/SettingsLayout/store.ts +47 -0
  47. package/src/layouts/SettingsLayout/types.ts +107 -0
  48. package/src/layouts/index.ts +1 -2
  49. package/src/layouts/types/index.ts +0 -1
  50. package/src/layouts/types/layout.types.ts +0 -4
  51. package/src/utils/logger.ts +9 -4
  52. package/src/layouts/AdminLayout/AdminLayout.tsx +0 -57
  53. package/src/layouts/AdminLayout/index.ts +0 -7
  54. package/src/layouts/AppLayout/AppLayout.tsx +0 -520
  55. package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +0 -56
  56. package/src/layouts/ProfileLayout/ProfileDialog/index.ts +0 -4
  57. package/src/layouts/ProfileLayout/ProfileDialog/store.ts +0 -51
  58. package/src/layouts/ProfileLayout/ProfileForm/context.tsx +0 -123
  59. package/src/layouts/ProfileLayout/ProfileForm/index.tsx +0 -147
  60. package/src/layouts/ProfileLayout/README.md +0 -150
  61. package/src/layouts/ProfileLayout/components/ActionButton.tsx +0 -38
  62. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +0 -197
  63. package/src/layouts/ProfileLayout/components/DeleteAccountSection.tsx +0 -44
  64. package/src/layouts/ProfileLayout/components/EditableField.tsx +0 -128
  65. package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +0 -56
  66. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +0 -110
  67. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +0 -35
  68. package/src/layouts/ProfileLayout/components/Section.tsx +0 -22
  69. package/src/layouts/ProfileLayout/components/index.ts +0 -11
  70. package/src/layouts/ProfileLayout/hooks/index.ts +0 -2
  71. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +0 -56
  72. package/src/layouts/ProfileLayout/index.ts +0 -8
  73. package/src/layouts/ProfileLayout/types.ts +0 -48
  74. /package/src/layouts/{ProfileLayout → SettingsLayout}/components/ApiKeySection/context.tsx +0 -0
  75. /package/src/layouts/{ProfileLayout → SettingsLayout}/components/ApiKeySection/index.ts +0 -0
  76. /package/src/layouts/{ProfileLayout → SettingsLayout}/components/AvatarSection.tsx +0 -0
  77. /package/src/layouts/{ProfileLayout → SettingsLayout}/components/TwoFactorSection/index.ts +0 -0
@@ -1,25 +1,18 @@
1
1
  /**
2
- * AppLayout exports
2
+ * Provider stack exports.
3
+ *
4
+ * `BaseApp` is the canonical, framework-agnostic provider wrapper (theme, auth,
5
+ * i18n, monitor, error tracking…). Mount it ONCE at the app root. In Next.js
6
+ * apps, per-route shells are chosen by native route-group `layout.tsx` files
7
+ * (each imports `PrivateLayout` / `PublicLayout` and passes its own config) —
8
+ * there is no runtime layout-router component here anymore.
3
9
  */
4
10
 
5
- export { AppLayout } from './AppLayout';
6
- export type {
7
- AppLayoutProps,
8
- AppLayoutLayoutsConfig,
9
- AppLayoutBaseAppConfig,
10
- AppLayoutLayoutComponentProps,
11
- AppLayoutPublicChrome,
12
- LayoutMode,
13
- I18nLayoutConfig,
14
- PublicMainTopSpacing,
15
- PublicMainBottomSpacing,
16
- } from './AppLayout';
17
-
18
- export { mergeAppLayoutPublicChrome } from './AppLayout';
19
-
20
11
  export { BaseApp } from './BaseApp';
21
12
  export type { BaseAppProps } from './BaseApp';
22
13
 
14
+ export type { I18nLayoutConfig } from '../types';
15
+
23
16
  export {
24
17
  LayoutI18nProvider,
25
18
  useLayoutI18n,
@@ -27,8 +20,9 @@ export {
27
20
  } from './LayoutI18nProvider';
28
21
  export type { LayoutI18nProviderProps } from './LayoutI18nProvider';
29
22
 
30
- // Re-export theme-override primitives so consumers can import both the
31
- // AppLayout config surface and the rule/theme types from one place.
23
+ // Re-export theme-override primitives so consumers can force a theme on a
24
+ // subtree (e.g. an always-dark marketing route) without depending on ui-core
25
+ // internals directly.
32
26
  export {
33
27
  ThemeOverride,
34
28
  resolveForcedTheme,
@@ -40,4 +34,3 @@ export type {
40
34
  ThemeOverrideProps,
41
35
  ForcedTheme,
42
36
  } from '@djangocfg/ui-core/theme';
43
-
@@ -3,12 +3,16 @@
3
3
  import React, { memo, useMemo } from 'react';
4
4
 
5
5
  import { useAppT } from '@djangocfg/i18n';
6
+ import { Preloader } from '@djangocfg/ui-core/components';
6
7
 
7
- import { AuthButton, AuthContainer, AuthHeader } from '../../shared';
8
+ import { AuthContainer, AuthHeader } from '../../shared';
8
9
 
9
10
  /**
10
11
  * SetupLoading - Loading state for 2FA setup.
11
12
  *
13
+ * Uses the ui-core Preloader (text-primary spinner) so the "Setting up…"
14
+ * indicator stays clearly visible instead of the near-invisible chip spinner.
15
+ *
12
16
  * Memoised: no props; pure translation-driven component. Prevents
13
17
  * re-renders when parent orchestrators re-render for unrelated reasons.
14
18
  */
@@ -23,9 +27,7 @@ function SetupLoadingRaw() {
23
27
  <AuthContainer step="2fa-setup">
24
28
  <AuthHeader title={content.title} />
25
29
  <div className="auth-form-group">
26
- <AuthButton loading disabled>
27
- {content.button}
28
- </AuthButton>
30
+ <Preloader variant="inline" text={content.button} className="py-4" />
29
31
  </div>
30
32
  </AuthContainer>
31
33
  );
@@ -13,7 +13,6 @@ import React, { ReactNode } from 'react';
13
13
  import { Preloader } from '@djangocfg/ui-core/components';
14
14
  import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
15
15
 
16
- import type { AppLayoutPublicChrome } from '../AppLayout/AppLayout';
17
16
  import type { LayoutVisualConfig } from '../types';
18
17
  import { PrivateContent, PrivateSidebar } from './components';
19
18
  import { useAuthGuard } from './hooks';
@@ -42,8 +41,8 @@ export { SidebarBrandSwitcher } from './components';
42
41
 
43
42
  export { PrivateLayoutProps };
44
43
 
45
- const ProfileDialog = React.lazy(() =>
46
- import('../ProfileLayout/ProfileDialog/ProfileDialog').then((m) => ({ default: m.ProfileDialog }))
44
+ const SettingsDialog = React.lazy(() =>
45
+ import('../SettingsLayout/SettingsDialog').then((m) => ({ default: m.SettingsDialog }))
47
46
  );
48
47
 
49
48
  export function PrivateLayout({
@@ -55,6 +54,7 @@ export function PrivateLayout({
55
54
  contentScroll = 'auto',
56
55
  visual,
57
56
  requireAuth = true,
57
+ settings,
58
58
  }: PrivateLayoutProps) {
59
59
  const { isLoading, loadingText } = useAuthGuard({
60
60
  requireAuth,
@@ -104,7 +104,10 @@ export function PrivateLayout({
104
104
  </PrivateContent>
105
105
  </SidebarInset>
106
106
 
107
- <ProfileDialog />
107
+ {/* Settings is the primary account dialog — always mounted so the
108
+ account-menu "Settings" item works out of the box. `settings` is
109
+ optional config (extra app sections / flags), not an on/off switch. */}
110
+ <SettingsDialog {...(settings ?? {})} />
108
111
  </SidebarProvider>
109
112
  );
110
113
  }
@@ -17,6 +17,36 @@ The auth guard redirects to `header.authPath` when there's no session. Pass `req
17
17
 
18
18
  ---
19
19
 
20
+ ## Wiring (canonical)
21
+
22
+ Mount it in the **route-group `layout.tsx`** that owns the authenticated section
23
+ — a thin config wrapper. Providers (`BaseApp`) live once at the app root; this
24
+ file just chooses the private shell and feeds it sidebar/header config:
25
+
26
+ ```tsx
27
+ // app/[locale]/(pages)/private/layout.tsx
28
+ 'use client';
29
+ import { PrivateLayout } from '@djangocfg/layouts';
30
+ import { usePathname } from '@djangocfg/nextjs/i18n/navigation';
31
+
32
+ export default function PrivateRouteLayout({ children }) {
33
+ const pathname = usePathname();
34
+ return (
35
+ <PrivateLayout sidebar={sidebar} header={header} pathname={pathname}>
36
+ {children}
37
+ </PrivateLayout>
38
+ );
39
+ }
40
+ ```
41
+
42
+ **Admin** is the same component with a different `sidebar` — there is no separate
43
+ `AdminLayout`. Put it in `app/.../admin/layout.tsx` with an admin menu config.
44
+
45
+ See the demo app (`apps/demo/app/_layouts/PrivateLayout.tsx`) for a complete
46
+ thin-wrapper reference, and the **BaseApp README** for the provider root.
47
+
48
+ ---
49
+
20
50
  ## Visual variants
21
51
 
22
52
  `boxed` (default) — `<SidebarInset>` becomes a rounded card; the wrapper paints `bg-sidebar` so the brand colour bleeds to the viewport edges. Mobile (<md) degrades to full-bleed automatically.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Private layout main column — on narrow viewports a fixed menu FAB (`SidebarTrigger`) + scrollable area.
3
- * On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `Drawer` from ui-nextjs sidebar.
3
+ * On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `Drawer` from the ui-core sidebar.
4
4
  */
5
5
 
6
6
  'use client';
@@ -64,7 +64,11 @@ export function PrivateContent({
64
64
  : undefined;
65
65
 
66
66
  const scrollAreaClass = cn(
67
- 'min-h-0 flex-1 bg-card',
67
+ // The boxed content is the MAIN surface (Claude bg-100), not a raised card.
68
+ // `bg-background` keeps it the warm page tone; the slightly darker
69
+ // sidebar-canvas behind the inset gives the floating look. (`bg-card`, now
70
+ // lifted to 18.4%, made this big panel read too light.)
71
+ 'min-h-0 flex-1 bg-background',
68
72
  scroll === 'auto' ? 'overflow-y-auto' : 'flex flex-col overflow-hidden',
69
73
  padding === 'default' && scroll === 'auto' && [
70
74
  'px-4 sm:px-6 lg:px-8',
@@ -11,9 +11,9 @@
11
11
 
12
12
  'use client';
13
13
 
14
- import { ChevronsUpDown, LogOut } from 'lucide-react';
14
+ import { ChevronsUpDown, Languages, LogOut, Settings as SettingsIcon } from 'lucide-react';
15
15
  import { Link } from '@djangocfg/ui-core/components';
16
- import React, { memo, useMemo } from 'react';
16
+ import React, { memo } from 'react';
17
17
 
18
18
  import { useAuth } from '@djangocfg/api/auth';
19
19
  import { useAppT } from '@djangocfg/i18n';
@@ -22,21 +22,19 @@ import {
22
22
  AvatarFallback,
23
23
  AvatarImage,
24
24
  Button,
25
- DropdownMenu,
26
- DropdownMenuContent,
27
- DropdownMenuItem,
28
- DropdownMenuLabel,
29
- DropdownMenuSeparator,
30
- DropdownMenuTrigger,
25
+ LanguageFlag,
26
+ MenuBuilder,
31
27
  } from '@djangocfg/ui-core/components';
28
+ import type { MenuItem } from '@djangocfg/ui-core/components';
32
29
  import { cn, isDev } from '@djangocfg/ui-core/lib';
33
30
  import { useSidebar } from '@djangocfg/ui-core/components';
34
31
 
35
32
  import { useLogout } from '../../../hooks';
36
33
  import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
37
34
  import { LucideIcon as LucideIconRender } from '../../../components';
35
+ import { getLocaleMeta } from '../../_components/locale-switcher/localeMeta';
38
36
  import { useShellVisualState } from '../hooks';
39
- import { useProfileDialogStore } from '../../ProfileLayout/ProfileDialog/store';
37
+ import { useSettingsDialogStore } from '../../SettingsLayout/store';
40
38
 
41
39
  import type { HeaderConfig } from '../types';
42
40
 
@@ -101,11 +99,6 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
101
99
  setSidebarOpen(true);
102
100
  }, [setSidebarOpen]);
103
101
 
104
- const onLogoutSelect = React.useCallback((e: Event) => {
105
- e.preventDefault();
106
- handleLogout();
107
- }, [handleLogout]);
108
-
109
102
  // Hide entirely in production when there's no user (auth still loading or
110
103
  // /me failed and the parent guard hasn't redirected yet). In dev keep a
111
104
  // placeholder so the footer + Log out are reachable for debugging.
@@ -116,8 +109,10 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
116
109
  const secondary = header?.footerSecondaryAction;
117
110
 
118
111
  const triggerClassName = cn(
119
- 'group h-auto w-full gap-3 rounded-lg px-3 py-3 text-left',
112
+ 'group h-auto w-full gap-3 rounded-lg px-3 py-3 text-left select-none',
120
113
  'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
114
+ // No focus ring on the menu trigger — the open menu is the affordance.
115
+ 'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
121
116
  content.isAccountCompact ? 'justify-center px-0 py-2' : 'min-h-[52px]',
122
117
  );
123
118
 
@@ -129,39 +124,16 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
129
124
  'p-1.5',
130
125
  content.isAccountCompact ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
131
126
  );
132
- const dropdownSide: 'top' | 'right' = content.isAccountCompact ? 'right' : 'top';
127
+ // Always open UPWARD over the avatar (Claude pattern). The footer sits at the
128
+ // bottom of the rail in both expanded and collapsed states, so `top` reads
129
+ // correctly either way (vs. `right`, which floated off to the side).
130
+ const dropdownSide: 'top' = 'top';
133
131
  const avatarClass = cn(
134
132
  'h-9 w-9 shrink-0 border border-transparent transition-colors',
135
133
  'group-hover:border-sidebar-border/70',
136
134
  );
137
135
 
138
136
  const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
139
- const headerLabel = headerLabelText ? (
140
- <DropdownMenuLabel className="truncate px-2 py-1.5 text-xs font-normal text-muted-foreground">
141
- {headerLabelText}
142
- </DropdownMenuLabel>
143
- ) : null;
144
-
145
- const accountLinksItems = accountLinks.map((item) => {
146
- const Icon = item.icon;
147
- return (
148
- <DropdownMenuItem key={item.href} asChild>
149
- <Link
150
- href={item.href!}
151
- className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
152
- >
153
- {Icon ? <Icon className="h-4 w-4 shrink-0 text-muted-foreground" /> : null}
154
- <span className="truncate">{item.label}</span>
155
- </Link>
156
- </DropdownMenuItem>
157
- );
158
- });
159
- const accountLinksBlock = accountLinks.length > 0 ? (
160
- <>
161
- {headerLabel ? <DropdownMenuSeparator /> : null}
162
- {accountLinksItems}
163
- </>
164
- ) : null;
165
137
 
166
138
  const expandedMeta = content.isAccountCompact ? null : (
167
139
  <>
@@ -184,17 +156,96 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
184
156
  </>
185
157
  );
186
158
 
187
- const openProfileDialog = React.useCallback(() => {
188
- useProfileDialogStore.getState().open();
159
+ // `accountAction: 'dialog'` opens the global SettingsDialog (mounted in
160
+ // PrivateLayout). Requires `settings` to be passed to PrivateLayout.
161
+ const openSettingsDialog = React.useCallback(() => {
162
+ useSettingsDialogStore.getState().open();
189
163
  }, []);
190
164
 
165
+ // ── Declarative account menu (Claude-style) — fed to MenuBuilder ──
166
+ const menuItems = React.useMemo<MenuItem[]>(() => {
167
+ const items: MenuItem[] = [];
168
+
169
+ // Email header.
170
+ if (headerLabelText) {
171
+ items.push({ kind: 'label', id: 'email', label: headerLabelText });
172
+ }
173
+
174
+ // Settings → opens the global SettingsDialog (⌘, like Claude).
175
+ items.push({
176
+ kind: 'item',
177
+ id: 'settings',
178
+ label: t('layouts.profile.settings') || 'Settings',
179
+ icon: SettingsIcon,
180
+ shortcut: '⇧⌘,',
181
+ onSelect: () => useSettingsDialogStore.getState().open(),
182
+ });
183
+
184
+ // Language → submenu with a radio group of locales (flag + native name,
185
+ // checkmark on the active one). Only when an i18n layout context is present.
186
+ if (layoutI18n && layoutI18n.locales.length > 1) {
187
+ items.push({
188
+ kind: 'submenu',
189
+ id: 'language',
190
+ label: t('layouts.profile.language') || 'Language',
191
+ icon: Languages,
192
+ items: [
193
+ {
194
+ kind: 'radio-group',
195
+ id: 'locale',
196
+ value: layoutI18n.locale,
197
+ onValueChange: layoutI18n.onLocaleChange,
198
+ options: layoutI18n.locales.map((code) => {
199
+ const meta = getLocaleMeta(code);
200
+ return {
201
+ id: code,
202
+ value: code,
203
+ label: meta.native,
204
+ icon: <LanguageFlag code={code} rounded className="h-3 w-4 shrink-0" />,
205
+ };
206
+ }),
207
+ },
208
+ ],
209
+ });
210
+ }
211
+
212
+ // App-provided account links (from header.groups).
213
+ if (accountLinks.length > 0) {
214
+ items.push({ kind: 'separator', id: 'sep-links' });
215
+ for (const link of accountLinks) {
216
+ items.push({
217
+ kind: 'item',
218
+ id: link.href!,
219
+ label: link.label,
220
+ icon: link.icon,
221
+ href: link.href,
222
+ });
223
+ }
224
+ }
225
+
226
+ // Log out (destructive). Let the menu close normally (no preventDefault) —
227
+ // the logout confirm dialog should appear over a CLOSED menu, not under a
228
+ // still-open one.
229
+ items.push({ kind: 'separator', id: 'sep-logout' });
230
+ items.push({
231
+ kind: 'item',
232
+ id: 'logout',
233
+ label: signOutLabel,
234
+ icon: LogOut,
235
+ variant: 'destructive',
236
+ onSelect: () => handleLogout(),
237
+ });
238
+
239
+ return items;
240
+ }, [headerLabelText, t, layoutI18n, accountLinks, signOutLabel, handleLogout]);
241
+
191
242
  const triggerButton = (
192
243
  <Button
193
244
  type="button"
194
245
  variant="ghost"
195
246
  aria-label={content.isAccountCompact ? account.displayName : undefined}
196
247
  className={triggerClassName}
197
- onClick={header?.accountAction === 'dialog' ? openProfileDialog : onTriggerInteract}
248
+ onClick={header?.accountAction === 'dialog' ? openSettingsDialog : onTriggerInteract}
198
249
  >
199
250
  <Avatar className={avatarClass}>
200
251
  <AvatarImage src={account.avatarUrl} alt={account.displayName} />
@@ -209,7 +260,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
209
260
  content.isAccountCompact ? 'px-0 pb-0' : 'px-2 pb-2',
210
261
  );
211
262
 
212
- // Dialog mode: simple button that opens the global ProfileDialog
263
+ // Dialog mode: simple button that opens the global SettingsDialog
213
264
  if (header?.accountAction === 'dialog') {
214
265
  return (
215
266
  <div className={wrapperClass}>
@@ -220,33 +271,17 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
220
271
 
221
272
  return (
222
273
  <div className={wrapperClass}>
223
- <DropdownMenu
274
+ <MenuBuilder
275
+ items={menuItems}
224
276
  open={isAccountMenuOpen}
225
277
  onOpenChange={setIsAccountMenuOpen}
278
+ side={dropdownSide}
279
+ align="start"
280
+ sideOffset={8}
281
+ contentClassName={dropdownContentClass}
226
282
  >
227
- <DropdownMenuTrigger asChild>
228
- {triggerButton}
229
- </DropdownMenuTrigger>
230
-
231
- <DropdownMenuContent
232
- side={dropdownSide}
233
- align="start"
234
- sideOffset={8}
235
- className={dropdownContentClass}
236
- >
237
- {headerLabel}
238
- {accountLinksBlock}
239
-
240
- <DropdownMenuSeparator />
241
- <DropdownMenuItem
242
- onSelect={onLogoutSelect}
243
- className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive focus:bg-destructive/10 focus:text-destructive"
244
- >
245
- <LogOut className="h-4 w-4 shrink-0" />
246
- <span className="truncate">{signOutLabel}</span>
247
- </DropdownMenuItem>
248
- </DropdownMenuContent>
249
- </DropdownMenu>
283
+ {triggerButton}
284
+ </MenuBuilder>
250
285
  </div>
251
286
  );
252
287
  }
@@ -33,17 +33,26 @@ export function useAuthGuard({
33
33
  const router = useRouter();
34
34
  const [isRedirecting, setIsRedirecting] = useState(false);
35
35
 
36
+ // Auth state lives in the browser (storage), so the server always renders
37
+ // unauthenticated → the preloader. Keep the first client render identical to
38
+ // the server (preloader) until mounted, then switch to the real auth result.
39
+ // Without this, the SSR preloader vs the hydrated shell is a hydration
40
+ // mismatch (logged as "Invalid HTML tag nesting" / regenerated tree).
41
+ const [mounted, setMounted] = useState(false);
42
+ useEffect(() => setMounted(true), []);
43
+
36
44
  useEffect(() => {
37
- if (!requireAuth) return;
45
+ if (!mounted || !requireAuth) return;
38
46
  if (!authLoading && !isAuthenticated && !isRedirecting) {
39
47
  const currentUrl = window.location.pathname + window.location.search;
40
48
  saveRedirectUrl(currentUrl);
41
49
  setIsRedirecting(true);
42
50
  router.push(authPath);
43
51
  }
44
- }, [requireAuth, isAuthenticated, authLoading, isRedirecting, router, saveRedirectUrl, authPath]);
52
+ }, [mounted, requireAuth, isAuthenticated, authLoading, isRedirecting, router, saveRedirectUrl, authPath]);
45
53
 
46
- const isLoading = requireAuth && (authLoading || isRedirecting || !isAuthenticated);
54
+ const isLoading =
55
+ !mounted || (requireAuth && (authLoading || isRedirecting || !isAuthenticated));
47
56
  const loadingText = isRedirecting ? 'Redirecting to login...' : 'Authenticating...';
48
57
 
49
58
  return {
@@ -7,7 +7,7 @@
7
7
  import type { ReactNode } from 'react';
8
8
  import type { LucideIcon } from 'lucide-react';
9
9
 
10
- import type { AppLayoutPublicChrome } from '../AppLayout/AppLayout';
10
+ import type { SettingsDialogProps } from '../SettingsLayout/types';
11
11
  import type { LayoutVisualConfig } from '../types';
12
12
  import type { UserMenuConfig } from '../types';
13
13
 
@@ -223,6 +223,11 @@ export interface PrivateLayoutProps {
223
223
  * embeds where there's no real session. Default `true` (guard on).
224
224
  */
225
225
  requireAuth?: boolean;
226
- /** Reserved for `AppLayout` passthrough (`publicChrome`); unused in this layout. */
227
- publicChrome?: AppLayoutPublicChrome;
226
+ /**
227
+ * Mount the global SettingsDialog (Claude-style master/detail settings modal,
228
+ * hash-URL driven, openable via `useSettingsDialog()`). Pass a config object
229
+ * (even `{}`) to enable it with the built-in sections; omit to not mount it.
230
+ * Coexists with the legacy ProfileDialog — they are independent.
231
+ */
232
+ settings?: SettingsDialogProps;
228
233
  }