@djangocfg/layouts 2.1.425 → 2.1.427

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 (66) hide show
  1. package/package.json +15 -17
  2. package/src/layouts/AppLayout/AppLayout.tsx +0 -7
  3. package/src/layouts/AppLayout/BaseApp.tsx +29 -52
  4. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +6 -4
  5. package/src/layouts/PrivateLayout/PrivateLayout.tsx +7 -3
  6. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +5 -1
  7. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +105 -70
  8. package/src/layouts/PrivateLayout/types.ts +8 -0
  9. package/src/layouts/PublicLayout/components/UserMenu.tsx +68 -113
  10. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +0 -6
  11. package/src/layouts/PublicLayout/navbars/MinimalNavbar/index.ts +1 -1
  12. package/src/layouts/SettingsLayout/README.md +258 -0
  13. package/src/layouts/SettingsLayout/SettingsDialog.tsx +101 -0
  14. package/src/layouts/SettingsLayout/SettingsForm.tsx +100 -0
  15. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +189 -0
  16. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +71 -0
  17. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +57 -0
  18. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +48 -0
  19. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +50 -0
  20. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +77 -0
  21. package/src/layouts/SettingsLayout/components/SettingsTabs.tsx +56 -0
  22. package/src/layouts/{ProfileLayout → SettingsLayout}/components/TwoFactorSection/TwoFactorSection.tsx +84 -130
  23. package/src/layouts/SettingsLayout/components/index.ts +6 -0
  24. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +122 -0
  25. package/src/layouts/SettingsLayout/context/index.ts +2 -0
  26. package/src/layouts/SettingsLayout/hooks/index.ts +12 -0
  27. package/src/layouts/SettingsLayout/hooks/useProfileSave.ts +95 -0
  28. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +52 -0
  29. package/src/layouts/SettingsLayout/hooks/useSettingsSections.ts +123 -0
  30. package/src/layouts/SettingsLayout/hooks/useSettingsUrl.ts +140 -0
  31. package/src/layouts/SettingsLayout/index.ts +67 -0
  32. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +100 -0
  33. package/src/layouts/SettingsLayout/sections/ApiKeysSection.tsx +15 -0
  34. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +57 -0
  35. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +43 -0
  36. package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +15 -0
  37. package/src/layouts/SettingsLayout/sections/builtins.tsx +77 -0
  38. package/src/layouts/SettingsLayout/sections/index.ts +8 -0
  39. package/src/layouts/SettingsLayout/store.ts +47 -0
  40. package/src/layouts/SettingsLayout/types.ts +107 -0
  41. package/src/layouts/index.ts +1 -1
  42. package/src/layouts/types/index.ts +0 -1
  43. package/src/layouts/types/layout.types.ts +0 -4
  44. package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +0 -56
  45. package/src/layouts/ProfileLayout/ProfileDialog/index.ts +0 -4
  46. package/src/layouts/ProfileLayout/ProfileDialog/store.ts +0 -51
  47. package/src/layouts/ProfileLayout/ProfileForm/context.tsx +0 -123
  48. package/src/layouts/ProfileLayout/ProfileForm/index.tsx +0 -147
  49. package/src/layouts/ProfileLayout/README.md +0 -150
  50. package/src/layouts/ProfileLayout/components/ActionButton.tsx +0 -38
  51. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +0 -197
  52. package/src/layouts/ProfileLayout/components/DeleteAccountSection.tsx +0 -44
  53. package/src/layouts/ProfileLayout/components/EditableField.tsx +0 -128
  54. package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +0 -56
  55. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +0 -110
  56. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +0 -35
  57. package/src/layouts/ProfileLayout/components/Section.tsx +0 -22
  58. package/src/layouts/ProfileLayout/components/index.ts +0 -11
  59. package/src/layouts/ProfileLayout/hooks/index.ts +0 -2
  60. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +0 -56
  61. package/src/layouts/ProfileLayout/index.ts +0 -8
  62. package/src/layouts/ProfileLayout/types.ts +0 -48
  63. /package/src/layouts/{ProfileLayout → SettingsLayout}/components/ApiKeySection/context.tsx +0 -0
  64. /package/src/layouts/{ProfileLayout → SettingsLayout}/components/ApiKeySection/index.ts +0 -0
  65. /package/src/layouts/{ProfileLayout → SettingsLayout}/components/AvatarSection.tsx +0 -0
  66. /package/src/layouts/{ProfileLayout → SettingsLayout}/components/TwoFactorSection/index.ts +0 -0
@@ -30,7 +30,7 @@
30
30
 
31
31
  'use client';
32
32
 
33
- import { ArrowRight, Globe, LogOut } from 'lucide-react';
33
+ import { ArrowRight, Languages, LogOut } from 'lucide-react';
34
34
  import { Link } from '@djangocfg/ui-core/components';
35
35
  import React, { useMemo } from 'react';
36
36
 
@@ -40,18 +40,10 @@ import { useAppT } from '@djangocfg/i18n';
40
40
  import { useLogout } from '../../../hooks';
41
41
  import {
42
42
  Button,
43
- DropdownMenu,
44
- DropdownMenuContent,
45
- DropdownMenuGroup,
46
- DropdownMenuItem,
47
- DropdownMenuLabel,
48
- DropdownMenuSeparator,
49
- DropdownMenuSub,
50
- DropdownMenuSubContent,
51
- DropdownMenuSubTrigger,
52
- DropdownMenuTrigger,
53
43
  LanguageFlag,
44
+ MenuBuilder,
54
45
  } from '@djangocfg/ui-core/components';
46
+ import type { MenuItem } from '@djangocfg/ui-core/components';
55
47
 
56
48
  import { LOCALE_LABELS } from '../../_components/LocaleSwitcher';
57
49
  import { UserAvatar } from './UserAvatar';
@@ -124,8 +116,6 @@ export function UserMenu({
124
116
  };
125
117
  }, [i18n]);
126
118
 
127
- const hasProfileGroups = profileGroups.length > 0;
128
-
129
119
  const localeLabel = (code: string) => LOCALE_LABELS[code] || code.toUpperCase();
130
120
 
131
121
  if (!mounted) {
@@ -269,107 +259,72 @@ export function UserMenu({
269
259
  );
270
260
  }
271
261
 
272
- // Desktop variant
273
- return (
274
- <DropdownMenu>
275
- <DropdownMenuTrigger asChild>
276
- <Button variant="ghost" size="icon" className="rounded-full p-0">
277
- <UserAvatar src={userAvatar} name={displayName} size="sm" />
278
- <span className="sr-only">{labels.userMenu}</span>
279
- </Button>
280
- </DropdownMenuTrigger>
281
- <DropdownMenuContent align="end" className="w-56">
282
- <DropdownMenuLabel>
283
- <div className="flex flex-col space-y-1">
284
- <p className="text-sm font-medium leading-none">{displayName}</p>
285
- <p className="text-xs leading-none text-muted-foreground">
286
- {user.email}
287
- </p>
288
- </div>
289
- </DropdownMenuLabel>
290
- <DropdownMenuSeparator />
291
- {profileGroups.map((group, groupIndex) => (
292
- <React.Fragment key={groupIndex}>
293
- {groupIndex > 0 && <DropdownMenuSeparator />}
294
- <DropdownMenuGroup>
295
- {group.title && (
296
- <DropdownMenuLabel className="text-[11px] font-normal text-muted-foreground/70 py-1">
297
- {group.title}
298
- </DropdownMenuLabel>
299
- )}
300
- {group.items.map((item, itemIndex) => {
301
- const Icon = item.icon;
302
- const isDestructive = item.variant === 'destructive';
303
-
304
- if (item.onClick) {
305
- return (
306
- <DropdownMenuItem
307
- key={itemIndex}
308
- onClick={item.onClick}
309
- className={isDestructive ? 'text-destructive focus:text-destructive' : ''}
310
- >
311
- {Icon && <Icon className="mr-2 h-4 w-4" />}
312
- <span>{item.label}</span>
313
- </DropdownMenuItem>
314
- );
315
- }
316
-
317
- if (item.href) {
318
- return (
319
- <DropdownMenuItem key={itemIndex} asChild>
320
- <Link
321
- href={item.href}
322
- className={`flex items-center ${isDestructive ? 'text-destructive' : ''}`}
323
- >
324
- {Icon && <Icon className="mr-2 h-4 w-4" />}
325
- <span>{item.label}</span>
326
- </Link>
327
- </DropdownMenuItem>
328
- );
329
- }
262
+ // Desktop variant — declarative menu via the shared MenuBuilder.
263
+ const menuItems: MenuItem[] = [
264
+ {
265
+ kind: 'label',
266
+ id: 'identity',
267
+ label: (
268
+ <div className="flex flex-col space-y-1">
269
+ <p className="text-sm font-medium leading-none">{displayName}</p>
270
+ <p className="text-xs leading-none text-muted-foreground">{user.email}</p>
271
+ </div>
272
+ ),
273
+ },
274
+ // App-provided link groups (each its own section with a trailing separator).
275
+ ...profileGroups.map((group, i): MenuItem => ({
276
+ kind: 'section',
277
+ id: `group-${i}`,
278
+ label: group.title,
279
+ items: group.items.map((item, j): MenuItem => ({
280
+ kind: 'item',
281
+ id: item.href ?? `${i}-${j}`,
282
+ label: item.label,
283
+ icon: item.icon,
284
+ href: item.href,
285
+ onSelect: item.onClick ? () => item.onClick!() : undefined,
286
+ variant: item.variant === 'destructive' ? 'destructive' : 'default',
287
+ })),
288
+ })),
289
+ // Language submenu — radio group of locales (flag + label + checkmark).
290
+ ...(localeMenu
291
+ ? [{
292
+ kind: 'submenu' as const,
293
+ id: 'language',
294
+ label: labels.language,
295
+ icon: Languages,
296
+ items: [{
297
+ kind: 'radio-group' as const,
298
+ id: 'locale',
299
+ value: localeMenu.current,
300
+ onValueChange: localeMenu.onChange,
301
+ options: localeMenu.codes.map((code) => ({
302
+ id: code,
303
+ value: code,
304
+ label: localeLabel(code),
305
+ icon: <LanguageFlag code={code} className="h-3 w-4 shrink-0" rounded />,
306
+ })),
307
+ }],
308
+ }]
309
+ : []),
310
+ { kind: 'separator', id: 'sep-logout' },
311
+ {
312
+ kind: 'item',
313
+ id: 'signout',
314
+ label: signOutItem.label,
315
+ icon: LogOut,
316
+ variant: 'destructive',
317
+ onSelect: () => signOutItem.onClick(),
318
+ },
319
+ ];
330
320
 
331
- return null;
332
- })}
333
- </DropdownMenuGroup>
334
- </React.Fragment>
335
- ))}
336
- {localeMenu && (
337
- <>
338
- {hasProfileGroups && <DropdownMenuSeparator />}
339
- <DropdownMenuSub>
340
- <DropdownMenuSubTrigger className="cursor-default">
341
- <Globe className="mr-2 h-4 w-4" />
342
- <span>{labels.language}</span>
343
- </DropdownMenuSubTrigger>
344
- <DropdownMenuSubContent>
345
- {localeMenu.codes.map((code) => (
346
- <DropdownMenuItem
347
- key={code}
348
- onSelect={() => {
349
- localeMenu.onChange(code);
350
- }}
351
- className={code === localeMenu.current ? 'bg-accent' : ''}
352
- >
353
- <LanguageFlag code={code} className="mr-2 h-3 w-4" rounded />
354
- {localeLabel(code)}
355
- </DropdownMenuItem>
356
- ))}
357
- </DropdownMenuSubContent>
358
- </DropdownMenuSub>
359
- </>
360
- )}
361
- <DropdownMenuSeparator />
362
- <DropdownMenuGroup>
363
- <DropdownMenuItem
364
- onClick={signOutItem.onClick}
365
- className="text-destructive focus:text-destructive"
366
- >
367
- <LogOut className="mr-2 h-4 w-4" />
368
- <span>{signOutItem.label}</span>
369
- </DropdownMenuItem>
370
- </DropdownMenuGroup>
371
- </DropdownMenuContent>
372
- </DropdownMenu>
321
+ return (
322
+ <MenuBuilder items={menuItems} align="end" contentClassName="w-56">
323
+ <Button variant="ghost" size="icon" className="rounded-full p-0">
324
+ <UserAvatar src={userAvatar} name={displayName} size="sm" />
325
+ <span className="sr-only">{labels.userMenu}</span>
326
+ </Button>
327
+ </MenuBuilder>
373
328
  );
374
329
  }
375
330
 
@@ -30,12 +30,6 @@ import type { NavigationItem, UserMenuConfig } from '../../../types';
30
30
 
31
31
  import { MinimalMobileDrawer } from './MinimalMobileDrawer';
32
32
 
33
- /**
34
- * @deprecated Use `NavAction` from `@djangocfg/layouts`. Kept as an alias so
35
- * existing imports keep working.
36
- */
37
- export type MinimalNavbarAction = NavAction;
38
-
39
33
  export interface MinimalNavbarConfig {
40
34
  brand?: ReactNode;
41
35
  /** @default '/' */
@@ -1,3 +1,3 @@
1
1
  export { MinimalNavbar } from './MinimalNavbar';
2
- export type { MinimalNavbarConfig, MinimalNavbarProps, MinimalNavbarAction } from './MinimalNavbar';
2
+ export type { MinimalNavbarConfig, MinimalNavbarProps } from './MinimalNavbar';
3
3
  export { MinimalMobileDrawer } from './MinimalMobileDrawer';
@@ -0,0 +1,258 @@
1
+ # SettingsLayout
2
+
3
+ A Claude.ai-style **settings surface**: a searchable, grouped section rail on the
4
+ left and a scrollable detail panel on the right. Available as a **global dialog**
5
+ (URL-hash driven, zustand-backed, openable from anywhere) or an **inline form**.
6
+
7
+ Designed to eventually supersede [`ProfileLayout`](../ProfileLayout/README.md).
8
+ The two coexist for now — `SettingsLayout` is built in parallel and does not touch
9
+ the old component.
10
+
11
+ **Desktop** — master/detail (nav rail + scrolling panel):
12
+
13
+ ```
14
+ ┌─ Settings ───────────────────────────────────┐
15
+ │ ⌕ Search │ Account ✕ │
16
+ │ GENERAL │ ─────────────────────── │
17
+ │ Account ◀ │ PERSONAL INFORMATION │
18
+ │ Security │ Avatar ( J ) │
19
+ │ API keys │ First name [ Jane ] │
20
+ │ WORKSPACE │ Last name [Cooper] │
21
+ │ Usage │ … │
22
+ └────────────────────┴───────────────────────────┘
23
+ ```
24
+
25
+ **Mobile** (`<md`) — title header + horizontal tab strip + panel
26
+ (Claude phone pattern):
27
+
28
+ ```
29
+ ┌─ Settings ✕ ─┐
30
+ │ [Account] Security API keys Usage … │
31
+ │ ───────────────────────────────────────── │
32
+ │ PERSONAL INFORMATION │
33
+ │ … │
34
+ └────────────────────────────────────────────┘
35
+ ```
36
+
37
+ The two layouts are switched with `useIsMobile` (see Responsive below) because
38
+ they use genuinely different nav components — a vertical grouped rail vs. a flat
39
+ horizontal tab strip — not just a flex-direction flip.
40
+
41
+ ## Quick start
42
+
43
+ ### Global dialog (recommended)
44
+
45
+ Mount it once near the root of the authenticated app (PrivateLayout does this for
46
+ you via its `settings` prop), then open it from anywhere.
47
+
48
+ ```tsx
49
+ // 1. Mount once — either let PrivateLayout do it…
50
+ <PrivateLayout settings={{ sections: appSections, builtins: { security: true } }}>
51
+ {children}
52
+ </PrivateLayout>
53
+
54
+ // …or mount <SettingsDialog/> yourself.
55
+ <SettingsDialog sections={appSections} builtins={{ security: true }} />
56
+
57
+ // 2. Open from any component:
58
+ const settings = useSettingsDialog();
59
+ <button onClick={() => settings.open()}>Settings</button>
60
+ <button onClick={() => settings.open('billing')}>Billing</button>
61
+
62
+ // 3. …or just navigate to the hash:
63
+ // #settings → opens
64
+ // #settings/api-keys → opens on the API-keys section
65
+ ```
66
+
67
+ ### Inline page
68
+
69
+ ```tsx
70
+ <SettingsForm sections={appSections} builtins={{ security: true }} />
71
+ ```
72
+
73
+ ## URL sync (`#settings/<section>`)
74
+
75
+ When `syncUrl` is on (default for `SettingsDialog`), the dialog two-way-binds to
76
+ the URL hash:
77
+
78
+ | URL | Result |
79
+ | -------------------- | --------------------------------------- |
80
+ | `#settings` | open, first / last-active section |
81
+ | `#settings/usage` | open, **usage** section active |
82
+ | no / other hash | closed |
83
+
84
+ - **Deep-link / refresh** reopens on the same section.
85
+ - **Back button** closes the dialog (the open state is a history entry).
86
+ - Switching section **pushes** a new hash, so it's shareable.
87
+
88
+ It's powered by ui-core's framework-agnostic `useLocation` (a `useSyncExternalStore`
89
+ over patched `history` + `hashchange`) — **no `next/router` import**, works in any
90
+ adapter. An unknown section id in the URL falls back gracefully (dialog opens on
91
+ the first section instead of selecting nothing).
92
+
93
+ Disable with `syncUrl={false}` for imperative-only use (e.g. inside Storybook,
94
+ where the preview iframe URL shouldn't be hijacked).
95
+
96
+ ## Sections
97
+
98
+ A section is a plain config object — apps extend the surface by appending to an
99
+ array. The shape mirrors the old `ProfileTab` (`{ id, label, content }`) so
100
+ migrating a tab is a near-rename.
101
+
102
+ ```ts
103
+ interface SettingsSection {
104
+ id: string; // unique; also the URL hash segment
105
+ label: React.ReactNode; // nav label
106
+ icon?: React.ComponentType; // lucide icon (optional)
107
+ group?: string; // group id; default group when omitted
108
+ title?: React.ReactNode; // panel heading (defaults to label)
109
+ description?: React.ReactNode; // panel sub-heading
110
+ content: React.ReactNode; // the panel body
111
+ keywords?: string[]; // extra search terms
112
+ order?: number; // sort within group
113
+ badge?: React.ReactNode; // trailing nav accessory
114
+ hidden?: boolean; // hide from rail, still reachable by id
115
+ }
116
+ ```
117
+
118
+ Only the **active** section's `content` is mounted. Switching sections remounts
119
+ the panel subtree (resets scroll + transient form state) — the expected settings
120
+ UX. Closing the dialog unmounts everything (Radix default), so heavy panels cost
121
+ nothing while closed.
122
+
123
+ ### Groups
124
+
125
+ Sections cluster into labelled groups in the rail. Declare them for
126
+ ordering/labelling, or just name an ad-hoc `group` on a section:
127
+
128
+ ```ts
129
+ const groups = [
130
+ { id: 'general', label: 'General', order: 0 },
131
+ { id: 'workspace', label: 'Workspace', order: 1 },
132
+ ];
133
+ ```
134
+
135
+ ### Built-in sections
136
+
137
+ `SettingsLayout` ships the proven ProfileLayout pieces as default sections,
138
+ toggled via `builtins`:
139
+
140
+ | id | flag | default | content |
141
+ | ----------- | -------------------- | ------- | ------------------------------------ |
142
+ | `account` | `builtins.account` | `true` | avatar + inline-editable profile/work fields, Preferences (Language dropdown + `ThemeSegmented`), and a **collapsible Danger zone** (delete account) |
143
+ | `security` | `builtins.security` | `false` | two-factor auth (row-style) |
144
+ | `api-keys` | `builtins.apiKeys` | `true` | API key display / regenerate / test (row-style) |
145
+
146
+ Built-ins are merged **ahead of** app sections. They reuse the localized labels
147
+ and save/validation logic from `ProfileProvider`, so behaviour stays in one place.
148
+ The built-in bodies are built from the ui-core `SettingRow`/`SettingsBlock`
149
+ primitives (see below) — flat rows with hairline dividers, no card frames.
150
+
151
+ > The `security` / `api-keys` bodies hit the real backend (2FA status, API key
152
+ > retrieval). With no backend (e.g. Storybook) they show an error/empty state —
153
+ > that's expected; gate them with `builtins` where there's no API.
154
+
155
+ ## Row primitives live in ui-core
156
+
157
+ The building blocks for section bodies are **universal** and live in
158
+ `@djangocfg/ui-core/components`, not here — so any settings-like surface in the
159
+ product can reuse them:
160
+
161
+ ```tsx
162
+ import { SettingRow, SettingsBlock } from '@djangocfg/ui-core/components';
163
+ import { ThemeSegmented } from '@djangocfg/ui-core/theme';
164
+ ```
165
+
166
+ **`SettingRow`** — one `label ↔ control` line with a hairline divider. Props-driven
167
+ control modes (precedence top→bottom):
168
+
169
+ | Mode | Props | Renders |
170
+ | --- | --- | --- |
171
+ | Editable value | `value` + `editable` + `onSave` (`type='text'\|'phone'`) | inline-edit chip → ui-core `Input` (`sm`) on click |
172
+ | Read-only value | `value` | a value chip |
173
+ | Toggle | `toggle` + `checked` + `onToggle` | ui-core `Switch` |
174
+ | Navigation | `navigation` (+ `onClick`) | full-row button with a trailing chevron |
175
+ | Action | `action={<Button/>}` | right-aligned node |
176
+ | Custom | `children` | anything (e.g. `ThemeSegmented`) |
177
+
178
+ **`SettingsBlock`** — a titled cluster of rows (`title` renders as a small uppercase
179
+ header). Pass `collapsible` (+ `defaultOpen`) to make the whole block expandable
180
+ (used for the Danger zone).
181
+
182
+ ```tsx
183
+ <SettingsBlock title="Personal information">
184
+ <SettingRow label="First name" value={user.firstName} editable onSave={save} />
185
+ <SettingRow label="Notifications" toggle checked={on} onToggle={setOn} />
186
+ <SettingRow label="Manage memory" navigation onClick={openMemory} />
187
+ <SettingRow label="Theme"><ThemeSegmented /></SettingRow>
188
+ </SettingsBlock>
189
+
190
+ <SettingsBlock title="Danger zone" collapsible>
191
+ <SettingRow label="Delete account" action={<DeleteButton />} />
192
+ </SettingsBlock>
193
+ ```
194
+
195
+ > These were promoted out of this package into ui-core during the Claude-style
196
+ > redesign so they're reusable everywhere and benefit from the global Input
197
+ > focus/token system. See ui-core `styles/README.md` for the token + focus rules.
198
+
199
+ ## Architecture
200
+
201
+ Decomposed so it's safe to grow:
202
+
203
+ ```
204
+ SettingsLayout/
205
+ ├── index.ts public barrel
206
+ ├── types.ts SettingsSection / Group / Content / props
207
+ ├── store.ts zustand: { isOpen, activeSection, open/close/setSection }
208
+ ├── SettingsDialog.tsx global modal (store + URL sync → SettingsForm)
209
+ ├── SettingsForm.tsx inline shell (ProfileProvider → SettingsProvider → Shell)
210
+ ├── context/
211
+ │ └── SettingsContext.tsx resolved sections + active + search, shared by shell parts
212
+ ├── hooks/
213
+ │ ├── useSettingsUrl.ts hash ⇄ store two-way bind
214
+ │ ├── useSettingsDialog.ts public open/close API for consumers
215
+ │ └── useSettingsSections.ts pure: merge/order/group/filter sections
216
+ ├── components/
217
+ │ ├── SettingsShell.tsx desktop rail+panel vs mobile tabs+panel (useIsMobile)
218
+ │ ├── SettingsNav.tsx desktop rail: title + search + grouped items
219
+ │ ├── SettingsNavItem.tsx one rail row
220
+ │ ├── SettingsTabs.tsx mobile horizontal tab strip (text chips, auto-scroll active)
221
+ │ ├── SettingsSearch.tsx filter box
222
+ │ └── SettingsPanel.tsx header + independently-scrolling body
223
+ └── sections/
224
+ ├── builtins.tsx account/security/api-keys factory
225
+ ├── AccountSection.tsx avatar + fields + preferences + danger zone (row-style)
226
+ ├── PreferencesRows.tsx Language dropdown + ThemeSegmented as SettingRows
227
+ ├── DeleteAccountRow.tsx compact destructive row (inside collapsible Danger zone)
228
+ ├── SecuritySection.tsx 2FA wrapper
229
+ └── ApiKeysSection.tsx API-keys wrapper
230
+ ```
231
+
232
+ > The row primitives (`SettingRow` / `SettingsBlock`) live in **ui-core**, not
233
+ > here — section bodies import them. See "Row primitives live in ui-core" above.
234
+
235
+ - **Selection** is controlled by the dialog (bound to the store) or uncontrolled
236
+ (local state) for the inline form — `SettingsProvider` hides the difference.
237
+ - **Search** filters the rail by label + `keywords`; picking a section clears it.
238
+ - **Responsive** is JS-driven via ui-core's SSR-safe `useIsMobile` (false on the
239
+ server, re-syncs on mount). Desktop (`≥md`): vertical grouped rail + panel.
240
+ Mobile (`<md`): title header + horizontal tab strip + panel; the dialog goes
241
+ near-fullscreen. A breakpoint hook (not CSS) is used because the two layouts
242
+ use different nav components, not just a different flex direction.
243
+
244
+ ## Customising the shell
245
+
246
+ For a bespoke layout, compose the parts yourself with `SettingsProvider` +
247
+ `useSettingsContext`, or reuse `SettingsShell`, `SettingsNav`, `SettingsTabs`,
248
+ `SettingsPanel` directly. The built-in section bodies (`AccountSection`, etc.) are
249
+ exported too; the row primitives come from `@djangocfg/ui-core/components`.
250
+
251
+ ## Migrating from ProfileLayout
252
+
253
+ 1. A `ProfileTab` `{ value, label, content }` → a `SettingsSection`
254
+ `{ id, label, content }` (rename `value` → `id`; add `icon`/`group`/`keywords`
255
+ as desired).
256
+ 2. `useProfileDialogStore.open({ initialTab })` → `useSettingsDialog().open(id)`.
257
+ 3. Mount `<SettingsDialog/>` instead of `<ProfileDialog/>` (or pass `settings` to
258
+ PrivateLayout). The account button can be repointed to `useSettingsDialog()`.
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+
3
+ import React, { useCallback, useMemo } from 'react';
4
+
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogTitle,
10
+ } from '@djangocfg/ui-core/components';
11
+ import { useIsMobile } from '@djangocfg/ui-core/hooks';
12
+ import { cn } from '@djangocfg/ui-core/lib';
13
+
14
+ import { SettingsForm } from './SettingsForm';
15
+ import { useSettingsUrl } from './hooks/useSettingsUrl';
16
+ import { buildBuiltinSections } from './sections';
17
+ import { useSettingsDialogStore } from './store';
18
+ import type { SettingsDialogProps } from './types';
19
+
20
+ /**
21
+ * SettingsDialog — the globally-mounted settings modal.
22
+ *
23
+ * Mount once (PrivateLayout does this). Open it from anywhere via
24
+ * `useSettingsDialog().open()` or by navigating to `#settings/<section>`.
25
+ *
26
+ * Reads transient state (open + active section) from the zustand store and
27
+ * binds it to the controlled <SettingsForm>. When `syncUrl` (default) it also
28
+ * two-way-binds that state to the URL hash via useSettingsUrl.
29
+ */
30
+ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
31
+ syncUrl = true,
32
+ urlKey = 'settings',
33
+ title,
34
+ sections: appSections = [],
35
+ groups,
36
+ builtins = {},
37
+ initialSection,
38
+ searchable = true,
39
+ enableDeleteAccount = true,
40
+ }) => {
41
+ const isOpen = useSettingsDialogStore((s) => s.isOpen);
42
+ const activeSection = useSettingsDialogStore((s) => s.activeSection);
43
+ const close = useSettingsDialogStore((s) => s.close);
44
+ const setSection = useSettingsDialogStore((s) => s.setSection);
45
+ const isMobile = useIsMobile();
46
+
47
+ // Set of valid ids, so a bad URL hash falls back gracefully instead of
48
+ // selecting a non-existent section. Cheap to recompute; built-ins are pure.
49
+ const knownIds = useMemo(() => {
50
+ const builtin = buildBuiltinSections({ builtins, enableDeleteAccount });
51
+ const ids = new Set<string>();
52
+ [...builtin, ...appSections].forEach((s) => ids.add(s.id));
53
+ return ids;
54
+ }, [builtins, enableDeleteAccount, appSections]);
55
+
56
+ const resolveSection = useCallback(
57
+ (id: string | null) => (id && knownIds.has(id) ? id : null),
58
+ [knownIds],
59
+ );
60
+
61
+ useSettingsUrl({ urlKey, enabled: syncUrl, resolveSection });
62
+
63
+ const resolvedTitle = title ?? 'Settings';
64
+
65
+ return (
66
+ <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
67
+ <DialogContent
68
+ // Desktop: centered card with breathing room (capped, gap to edges).
69
+ // Mobile: near-fullscreen sheet (small inset) so the phone settings get
70
+ // the whole screen, like Claude. Elevated `bg-card` lifts it off the page.
71
+ className={cn(
72
+ 'flex max-w-none flex-col gap-0 overflow-hidden border border-border bg-card p-0',
73
+ isMobile
74
+ ? 'h-[calc(100dvh-2rem)] w-[calc(100vw-1.5rem)]'
75
+ : 'h-[min(620px,calc(100vh-6rem))] w-[min(900px,calc(100vw-4rem))]',
76
+ )}
77
+ >
78
+ {/* Accessible label — the visible heading lives in the nav rail. */}
79
+ <DialogTitle className="sr-only">
80
+ {typeof resolvedTitle === 'string' ? resolvedTitle : 'Settings'}
81
+ </DialogTitle>
82
+ <DialogDescription className="sr-only">
83
+ Application settings
84
+ </DialogDescription>
85
+
86
+ <SettingsForm
87
+ bare
88
+ title={resolvedTitle}
89
+ sections={appSections}
90
+ groups={groups}
91
+ builtins={builtins}
92
+ initialSection={initialSection}
93
+ searchable={searchable}
94
+ enableDeleteAccount={enableDeleteAccount}
95
+ activeSection={activeSection ?? undefined}
96
+ onSectionChange={setSection}
97
+ />
98
+ </DialogContent>
99
+ </Dialog>
100
+ );
101
+ };