@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.
- package/package.json +15 -17
- package/src/layouts/AppLayout/AppLayout.tsx +0 -7
- package/src/layouts/AppLayout/BaseApp.tsx +29 -52
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +6 -4
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +7 -3
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +5 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +105 -70
- package/src/layouts/PrivateLayout/types.ts +8 -0
- package/src/layouts/PublicLayout/components/UserMenu.tsx +68 -113
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +0 -6
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/index.ts +1 -1
- package/src/layouts/SettingsLayout/README.md +258 -0
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +101 -0
- package/src/layouts/SettingsLayout/SettingsForm.tsx +100 -0
- package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +189 -0
- package/src/layouts/SettingsLayout/components/SettingsNav.tsx +71 -0
- package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +57 -0
- package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +48 -0
- package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +50 -0
- package/src/layouts/SettingsLayout/components/SettingsShell.tsx +77 -0
- package/src/layouts/SettingsLayout/components/SettingsTabs.tsx +56 -0
- package/src/layouts/{ProfileLayout → SettingsLayout}/components/TwoFactorSection/TwoFactorSection.tsx +84 -130
- package/src/layouts/SettingsLayout/components/index.ts +6 -0
- package/src/layouts/SettingsLayout/context/SettingsContext.tsx +122 -0
- package/src/layouts/SettingsLayout/context/index.ts +2 -0
- package/src/layouts/SettingsLayout/hooks/index.ts +12 -0
- package/src/layouts/SettingsLayout/hooks/useProfileSave.ts +95 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +52 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsSections.ts +123 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsUrl.ts +140 -0
- package/src/layouts/SettingsLayout/index.ts +67 -0
- package/src/layouts/SettingsLayout/sections/AccountSection.tsx +100 -0
- package/src/layouts/SettingsLayout/sections/ApiKeysSection.tsx +15 -0
- package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +57 -0
- package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +43 -0
- package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +15 -0
- package/src/layouts/SettingsLayout/sections/builtins.tsx +77 -0
- package/src/layouts/SettingsLayout/sections/index.ts +8 -0
- package/src/layouts/SettingsLayout/store.ts +47 -0
- package/src/layouts/SettingsLayout/types.ts +107 -0
- package/src/layouts/index.ts +1 -1
- package/src/layouts/types/index.ts +0 -1
- package/src/layouts/types/layout.types.ts +0 -4
- package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +0 -56
- package/src/layouts/ProfileLayout/ProfileDialog/index.ts +0 -4
- package/src/layouts/ProfileLayout/ProfileDialog/store.ts +0 -51
- package/src/layouts/ProfileLayout/ProfileForm/context.tsx +0 -123
- package/src/layouts/ProfileLayout/ProfileForm/index.tsx +0 -147
- package/src/layouts/ProfileLayout/README.md +0 -150
- package/src/layouts/ProfileLayout/components/ActionButton.tsx +0 -38
- package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +0 -197
- package/src/layouts/ProfileLayout/components/DeleteAccountSection.tsx +0 -44
- package/src/layouts/ProfileLayout/components/EditableField.tsx +0 -128
- package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +0 -56
- package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +0 -110
- package/src/layouts/ProfileLayout/components/ProfileTab.tsx +0 -35
- package/src/layouts/ProfileLayout/components/Section.tsx +0 -22
- package/src/layouts/ProfileLayout/components/index.ts +0 -11
- package/src/layouts/ProfileLayout/hooks/index.ts +0 -2
- package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +0 -56
- package/src/layouts/ProfileLayout/index.ts +0 -8
- package/src/layouts/ProfileLayout/types.ts +0 -48
- /package/src/layouts/{ProfileLayout → SettingsLayout}/components/ApiKeySection/context.tsx +0 -0
- /package/src/layouts/{ProfileLayout → SettingsLayout}/components/ApiKeySection/index.ts +0 -0
- /package/src/layouts/{ProfileLayout → SettingsLayout}/components/AvatarSection.tsx +0 -0
- /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,
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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
|
+
};
|