@djangocfg/layouts 2.1.426 → 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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useMemo, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
6
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
7
|
+
import { Preloader } from '@djangocfg/ui-core/components';
|
|
8
|
+
|
|
9
|
+
import { SettingsShell } from './components';
|
|
10
|
+
import { SettingsProvider } from './context';
|
|
11
|
+
import { buildBuiltinSections, BUILTIN_GROUP } from './sections';
|
|
12
|
+
import type { SettingsFormProps, SettingsSection } from './types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* SettingsForm — the inline (non-dialog) settings surface.
|
|
16
|
+
*
|
|
17
|
+
* Composition:
|
|
18
|
+
* SettingsProvider (resolved sections, active selection, search)
|
|
19
|
+
* SettingsShell (nav rail + detail panel)
|
|
20
|
+
*
|
|
21
|
+
* Built-in sections get their data/labels/save from `useProfileSave` directly
|
|
22
|
+
* (no context wrapper) — see sections/useProfileSave.
|
|
23
|
+
*
|
|
24
|
+
* Selection is uncontrolled by default (local state); pass `activeSection` +
|
|
25
|
+
* `onSectionChange` to control it (the dialog does this, bound to its store).
|
|
26
|
+
* Built-in sections are merged ahead of app `sections`; ids must be unique.
|
|
27
|
+
*/
|
|
28
|
+
export const SettingsForm: React.FC<SettingsFormProps> = ({
|
|
29
|
+
title,
|
|
30
|
+
sections: appSections = [],
|
|
31
|
+
groups,
|
|
32
|
+
builtins = {},
|
|
33
|
+
initialSection,
|
|
34
|
+
searchable = true,
|
|
35
|
+
enableDeleteAccount = true,
|
|
36
|
+
activeSection,
|
|
37
|
+
onSectionChange,
|
|
38
|
+
onUnauthenticated,
|
|
39
|
+
className,
|
|
40
|
+
bare = false,
|
|
41
|
+
}: SettingsFormProps) => {
|
|
42
|
+
const t = useAppT();
|
|
43
|
+
const { user, isLoading } = useAuth();
|
|
44
|
+
|
|
45
|
+
// Built-ins first, then app sections. Memoized so JSX identity is stable.
|
|
46
|
+
const allSections = useMemo<SettingsSection[]>(() => {
|
|
47
|
+
const builtin = buildBuiltinSections({ builtins, enableDeleteAccount });
|
|
48
|
+
return [...builtin, ...appSections];
|
|
49
|
+
}, [builtins, enableDeleteAccount, appSections]);
|
|
50
|
+
|
|
51
|
+
// Ensure the built-in group exists/ordered even if the app passed its own groups.
|
|
52
|
+
const allGroups = useMemo(() => {
|
|
53
|
+
const provided = groups ?? [];
|
|
54
|
+
return provided.some((g) => g.id === BUILTIN_GROUP.id)
|
|
55
|
+
? provided
|
|
56
|
+
: [BUILTIN_GROUP, ...provided];
|
|
57
|
+
}, [groups]);
|
|
58
|
+
|
|
59
|
+
// Uncontrolled selection fallback.
|
|
60
|
+
const [localActive, setLocalActive] = useState<string | null>(initialSection ?? null);
|
|
61
|
+
const isControlled = activeSection !== undefined;
|
|
62
|
+
const resolvedActive = isControlled ? activeSection ?? null : localActive;
|
|
63
|
+
const handleActiveChange = (id: string) => {
|
|
64
|
+
if (!isControlled) setLocalActive(id);
|
|
65
|
+
onSectionChange?.(id);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
if (onUnauthenticated && !user && !isLoading) onUnauthenticated();
|
|
70
|
+
}, [onUnauthenticated, user, isLoading]);
|
|
71
|
+
|
|
72
|
+
if (isLoading) {
|
|
73
|
+
return (
|
|
74
|
+
<Preloader
|
|
75
|
+
variant="fullscreen"
|
|
76
|
+
text={t('ui.states.loading')}
|
|
77
|
+
size="lg"
|
|
78
|
+
backdrop
|
|
79
|
+
backdropOpacity={80}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Default to "Settings" — this surface is broader than the old Profile page.
|
|
85
|
+
const resolvedTitle = title ?? 'Settings';
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<SettingsProvider
|
|
89
|
+
title={resolvedTitle}
|
|
90
|
+
sections={allSections}
|
|
91
|
+
groups={allGroups}
|
|
92
|
+
searchable={searchable}
|
|
93
|
+
activeId={resolvedActive}
|
|
94
|
+
onActiveChange={handleActiveChange}
|
|
95
|
+
initialSection={initialSection}
|
|
96
|
+
>
|
|
97
|
+
<SettingsShell bare={bare} className={className} />
|
|
98
|
+
</SettingsProvider>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, FlaskConical, KeyRound, Loader2, RefreshCw } from 'lucide-react';
|
|
4
|
+
import moment from 'moment';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Badge,
|
|
9
|
+
Button,
|
|
10
|
+
CopyButton,
|
|
11
|
+
Preloader,
|
|
12
|
+
SettingRow,
|
|
13
|
+
SettingsBlock,
|
|
14
|
+
} from '@djangocfg/ui-core/components';
|
|
15
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
16
|
+
|
|
17
|
+
import { ApiKeyProvider, useApiKeyContext } from './context';
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// Helpers
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function formatDate(iso: string | null, fallback: string): string {
|
|
24
|
+
if (!iso) return fallback;
|
|
25
|
+
return moment.utc(iso).local().format('MMM D, YYYY');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns true if the backend already masked the key (contains •). */
|
|
29
|
+
function isMasked(key: string): boolean {
|
|
30
|
+
return key.includes('•');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Inner component (uses context)
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function ApiKeyCard() {
|
|
38
|
+
const {
|
|
39
|
+
labels,
|
|
40
|
+
apiKey,
|
|
41
|
+
reissuedAt,
|
|
42
|
+
createdAt,
|
|
43
|
+
isLoading,
|
|
44
|
+
error,
|
|
45
|
+
isArmed,
|
|
46
|
+
arm,
|
|
47
|
+
disarm,
|
|
48
|
+
regenerate,
|
|
49
|
+
isRegenerating,
|
|
50
|
+
isFresh,
|
|
51
|
+
dismissFresh,
|
|
52
|
+
testKey,
|
|
53
|
+
isTesting,
|
|
54
|
+
testResult,
|
|
55
|
+
} = useApiKeyContext();
|
|
56
|
+
|
|
57
|
+
if (isLoading) {
|
|
58
|
+
return <Preloader variant="inline" className="py-10" />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const masked = apiKey ? isMasked(apiKey) : false;
|
|
62
|
+
const displayKey = apiKey ?? '—';
|
|
63
|
+
|
|
64
|
+
// Title-row state badge (read-only "Active" indicator).
|
|
65
|
+
const titleDescription = (
|
|
66
|
+
<span className="inline-flex items-center gap-2">
|
|
67
|
+
{apiKey && <Badge variant="secondary" className="text-xs">Active</Badge>}
|
|
68
|
+
<span>{labels.description}</span>
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Action buttons (arm/regenerate/test) for the title row.
|
|
73
|
+
const actions = isArmed ? (
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
<Button
|
|
76
|
+
variant="destructive"
|
|
77
|
+
size="sm"
|
|
78
|
+
onClick={regenerate}
|
|
79
|
+
disabled={isRegenerating}
|
|
80
|
+
>
|
|
81
|
+
{isRegenerating
|
|
82
|
+
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{labels.regenerating}</>
|
|
83
|
+
: labels.confirmRegenerate}
|
|
84
|
+
</Button>
|
|
85
|
+
<Button variant="ghost" size="sm" onClick={disarm} disabled={isRegenerating}>
|
|
86
|
+
Cancel
|
|
87
|
+
</Button>
|
|
88
|
+
</div>
|
|
89
|
+
) : (
|
|
90
|
+
<div className="flex items-center gap-2">
|
|
91
|
+
<Button
|
|
92
|
+
variant="outline"
|
|
93
|
+
size="sm"
|
|
94
|
+
onClick={arm}
|
|
95
|
+
disabled={!apiKey || isRegenerating}
|
|
96
|
+
>
|
|
97
|
+
<RefreshCw className="mr-2 h-4 w-4" />
|
|
98
|
+
{labels.regenerate}
|
|
99
|
+
</Button>
|
|
100
|
+
|
|
101
|
+
{/* Test button only when we have a fresh (full) key */}
|
|
102
|
+
{isFresh && (
|
|
103
|
+
<Button
|
|
104
|
+
variant="secondary"
|
|
105
|
+
size="sm"
|
|
106
|
+
onClick={testKey}
|
|
107
|
+
disabled={isTesting}
|
|
108
|
+
>
|
|
109
|
+
{isTesting
|
|
110
|
+
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{labels.testing}</>
|
|
111
|
+
: <><FlaskConical className="mr-2 h-4 w-4" />{labels.test}</>}
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<SettingsBlock>
|
|
119
|
+
<SettingRow
|
|
120
|
+
label={labels.title}
|
|
121
|
+
description={titleDescription}
|
|
122
|
+
action={actions}
|
|
123
|
+
/>
|
|
124
|
+
|
|
125
|
+
{/* Fresh-key banner — subtle, primary-tinted note */}
|
|
126
|
+
{isFresh && (
|
|
127
|
+
<div className="flex items-center gap-2 rounded-control bg-accent px-3 py-2 text-sm text-foreground">
|
|
128
|
+
<KeyRound className="w-4 h-4 flex-shrink-0 text-primary" />
|
|
129
|
+
<span className="flex-1">This is your new API key — copy it now. It will be masked when you leave this page.</span>
|
|
130
|
+
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={dismissFresh}>
|
|
131
|
+
<Check className="w-4 h-4 mr-1" />
|
|
132
|
+
{labels.done}
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{/* Test result banner */}
|
|
138
|
+
{testResult !== null && (
|
|
139
|
+
<div className={cn(
|
|
140
|
+
'flex items-center gap-2 rounded-control px-3 py-2 text-sm',
|
|
141
|
+
testResult
|
|
142
|
+
? 'bg-accent text-foreground'
|
|
143
|
+
: 'bg-destructive/10 text-destructive',
|
|
144
|
+
)}>
|
|
145
|
+
<FlaskConical className="w-4 h-4 flex-shrink-0" />
|
|
146
|
+
<span>{testResult ? labels.testSuccess : labels.testFailed}</span>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* API key value row */}
|
|
151
|
+
{apiKey && (
|
|
152
|
+
<SettingRow label="API key" stacked>
|
|
153
|
+
<div className="flex items-center gap-2">
|
|
154
|
+
<div className="flex-1 min-w-0 rounded-control bg-muted px-3 py-2.5 font-mono text-sm select-all">
|
|
155
|
+
<span className={cn(masked && 'tracking-widest')}>
|
|
156
|
+
{displayKey}
|
|
157
|
+
</span>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Copy only when the key is fresh (full key after regenerate) */}
|
|
161
|
+
{isFresh && <CopyButton value={apiKey} />}
|
|
162
|
+
</div>
|
|
163
|
+
</SettingRow>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{/* Dates */}
|
|
167
|
+
{apiKey && createdAt && (
|
|
168
|
+
<SettingRow label={labels.created} value={formatDate(createdAt, '—')} />
|
|
169
|
+
)}
|
|
170
|
+
{apiKey && (
|
|
171
|
+
<SettingRow label={labels.reissued} value={formatDate(reissuedAt, labels.neverReissued)} />
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{error && (
|
|
175
|
+
<p className="pt-3 text-sm text-destructive">{error}</p>
|
|
176
|
+
)}
|
|
177
|
+
</SettingsBlock>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// Export (wraps with provider)
|
|
183
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export const ApiKeySection: React.FC = () => (
|
|
186
|
+
<ApiKeyProvider>
|
|
187
|
+
<ApiKeyCard />
|
|
188
|
+
</ApiKeyProvider>
|
|
189
|
+
);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import { useSettingsContext } from '../context';
|
|
8
|
+
import { SettingsNavItem } from './SettingsNavItem';
|
|
9
|
+
import { SettingsSearch } from './SettingsSearch';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Left rail: optional title, optional search, then grouped section items.
|
|
13
|
+
* Groups with a label render a small uppercase header; the default (unlabelled)
|
|
14
|
+
* group renders bare. Empty search results show a quiet "no matches" line.
|
|
15
|
+
*/
|
|
16
|
+
export const SettingsNav: React.FC = () => {
|
|
17
|
+
const { title, groups, searchable, activeId, setActive, query } = useSettingsContext();
|
|
18
|
+
|
|
19
|
+
const hasResults = groups.some((g) => g.sections.length > 0);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<nav
|
|
23
|
+
role="tablist"
|
|
24
|
+
aria-orientation="vertical"
|
|
25
|
+
className="flex h-full flex-col gap-3"
|
|
26
|
+
>
|
|
27
|
+
<div className="px-1">
|
|
28
|
+
<h2 className="px-2.5 text-sm font-semibold text-foreground">{title}</h2>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
{searchable && (
|
|
32
|
+
<div className="px-1">
|
|
33
|
+
<SettingsSearch />
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
36
|
+
|
|
37
|
+
<div className="flex-1 space-y-4 overflow-y-auto px-1 pb-2">
|
|
38
|
+
{!hasResults && query && (
|
|
39
|
+
<p className="px-2.5 py-6 text-center text-xs text-muted-foreground">
|
|
40
|
+
No matching settings
|
|
41
|
+
</p>
|
|
42
|
+
)}
|
|
43
|
+
|
|
44
|
+
{groups.map((group) =>
|
|
45
|
+
group.sections.length === 0 ? null : (
|
|
46
|
+
<div key={group.id} className="space-y-0.5">
|
|
47
|
+
{group.label && (
|
|
48
|
+
<div
|
|
49
|
+
className={cn(
|
|
50
|
+
'px-2.5 pb-1 text-[11px] font-medium uppercase tracking-wider',
|
|
51
|
+
'text-muted-foreground/70',
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
{group.label}
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
{group.sections.map((section) => (
|
|
58
|
+
<SettingsNavItem
|
|
59
|
+
key={section.id}
|
|
60
|
+
section={section}
|
|
61
|
+
active={section.id === activeId}
|
|
62
|
+
onSelect={setActive}
|
|
63
|
+
/>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
),
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</nav>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import type { SettingsSection } from '../types';
|
|
8
|
+
|
|
9
|
+
interface SettingsNavItemProps {
|
|
10
|
+
section: SettingsSection;
|
|
11
|
+
active: boolean;
|
|
12
|
+
onSelect: (id: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One row in the settings nav rail: icon + label, with a trailing badge slot.
|
|
17
|
+
* Active state uses the semantic `accent` token (theme-correct in dark/light)
|
|
18
|
+
* rather than a hard-coded colour, matching the rest of the design system.
|
|
19
|
+
*/
|
|
20
|
+
export const SettingsNavItem: React.FC<SettingsNavItemProps> = ({
|
|
21
|
+
section,
|
|
22
|
+
active,
|
|
23
|
+
onSelect,
|
|
24
|
+
}) => {
|
|
25
|
+
const Icon = section.icon;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
role="tab"
|
|
31
|
+
aria-selected={active}
|
|
32
|
+
onClick={() => onSelect(section.id)}
|
|
33
|
+
className={cn(
|
|
34
|
+
'group flex w-full items-center gap-2.5 rounded-control px-2.5 py-2 text-left text-sm',
|
|
35
|
+
'outline-none transition-colors',
|
|
36
|
+
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
|
37
|
+
active
|
|
38
|
+
// Solid raised surface (popover-level) — pops off the muted rail in
|
|
39
|
+
// BOTH themes (on light the rail-vs-accent gap is only ~2% and
|
|
40
|
+
// invisible; an elevated surface fixes that). No shadow — flat fill.
|
|
41
|
+
? 'bg-popover text-foreground font-medium'
|
|
42
|
+
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{Icon && (
|
|
46
|
+
<Icon
|
|
47
|
+
className={cn(
|
|
48
|
+
'size-4 shrink-0 transition-colors',
|
|
49
|
+
active ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground',
|
|
50
|
+
)}
|
|
51
|
+
/>
|
|
52
|
+
)}
|
|
53
|
+
<span className="flex-1 truncate">{section.label}</span>
|
|
54
|
+
{section.badge && <span className="shrink-0">{section.badge}</span>}
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { ScrollArea } from '@djangocfg/ui-core/components';
|
|
6
|
+
|
|
7
|
+
import { useSettingsContext } from '../context';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Right side: the active section's detail. Renders a header (title + optional
|
|
11
|
+
* description) pinned above an independently scrolling body. The header falls
|
|
12
|
+
* back to the section's nav label when no explicit `title` is given.
|
|
13
|
+
*
|
|
14
|
+
* Only the active section's `content` is in the tree, so heavy panels don't
|
|
15
|
+
* render until selected (and unmount when the dialog closes — Radix default).
|
|
16
|
+
*/
|
|
17
|
+
export const SettingsPanel: React.FC = () => {
|
|
18
|
+
const { active } = useSettingsContext();
|
|
19
|
+
|
|
20
|
+
if (!active) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex h-full items-center justify-center p-10">
|
|
23
|
+
<p className="text-sm text-muted-foreground">No section selected</p>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const heading = active.title ?? active.label;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
32
|
+
<header className="shrink-0 border-b border-border px-6 py-4 md:px-8 md:py-5">
|
|
33
|
+
<h1 className="text-lg font-semibold tracking-tight text-foreground">{heading}</h1>
|
|
34
|
+
{active.description && (
|
|
35
|
+
<p className="mt-1 text-sm text-muted-foreground">{active.description}</p>
|
|
36
|
+
)}
|
|
37
|
+
</header>
|
|
38
|
+
|
|
39
|
+
<ScrollArea className="min-h-0 flex-1">
|
|
40
|
+
{/* key forces a fresh subtree per section: resets scroll + transient
|
|
41
|
+
form state when switching, which is the expected settings UX. */}
|
|
42
|
+
<div key={active.id} className="px-6 py-6 md:px-8">
|
|
43
|
+
{active.content}
|
|
44
|
+
</div>
|
|
45
|
+
</ScrollArea>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
import { Search, X } from 'lucide-react';
|
|
8
|
+
|
|
9
|
+
import { useSettingsContext } from '../context';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Search box atop the nav rail. Filters sections by label + keywords (logic in
|
|
13
|
+
* useSettingsSections). Mirrors the Claude.ai settings search affordance: a
|
|
14
|
+
* quiet, rounded field with a leading magnifier and a clear button.
|
|
15
|
+
*/
|
|
16
|
+
export const SettingsSearch: React.FC = () => {
|
|
17
|
+
const { query, setQuery } = useSettingsContext();
|
|
18
|
+
const t = useAppT();
|
|
19
|
+
const placeholder = t('ui.select.search') || 'Search';
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="relative">
|
|
23
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
24
|
+
<input
|
|
25
|
+
type="text"
|
|
26
|
+
value={query}
|
|
27
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
28
|
+
placeholder={placeholder}
|
|
29
|
+
aria-label={placeholder}
|
|
30
|
+
className={cn(
|
|
31
|
+
'rounded-control h-9 w-full border border-border bg-muted/40 pl-8 pr-8 text-sm',
|
|
32
|
+
'text-foreground placeholder:text-muted-foreground',
|
|
33
|
+
'outline-none transition-[border-color,background-color,box-shadow]',
|
|
34
|
+
// Crisp thin accent outline (matches ui-core Input), no blurry halo.
|
|
35
|
+
'focus-visible:border-ring focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring',
|
|
36
|
+
)}
|
|
37
|
+
/>
|
|
38
|
+
{query && (
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={() => setQuery('')}
|
|
42
|
+
aria-label="Clear search"
|
|
43
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-foreground"
|
|
44
|
+
>
|
|
45
|
+
<X className="size-3.5" />
|
|
46
|
+
</button>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { useIsMobile } from '@djangocfg/ui-core/hooks';
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
|
|
8
|
+
import { useSettingsContext } from '../context';
|
|
9
|
+
import { SettingsNav } from './SettingsNav';
|
|
10
|
+
import { SettingsPanel } from './SettingsPanel';
|
|
11
|
+
import { SettingsTabs } from './SettingsTabs';
|
|
12
|
+
|
|
13
|
+
interface SettingsShellProps {
|
|
14
|
+
/** Drop the outer rounding/border when embedded in a bare dialog. */
|
|
15
|
+
bare?: boolean;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Master/detail shell that reshapes for mobile (Claude settings pattern):
|
|
21
|
+
* - desktop (≥md): vertical nav rail on the left + detail panel on the right.
|
|
22
|
+
* - mobile (<md): a horizontal scrollable tab strip on top + panel below.
|
|
23
|
+
*
|
|
24
|
+
* The breakpoint is read with `useIsMobile` (SSR-safe; false on the server,
|
|
25
|
+
* re-syncs on mount) rather than CSS, because the two layouts use genuinely
|
|
26
|
+
* different nav components (vertical grouped rail vs. flat horizontal strip),
|
|
27
|
+
* not just a flex-direction flip.
|
|
28
|
+
*
|
|
29
|
+
* Height-bounded by its container (the dialog sets max-height); the panel
|
|
30
|
+
* scrolls independently inside it.
|
|
31
|
+
*/
|
|
32
|
+
export const SettingsShell: React.FC<SettingsShellProps> = ({ bare = false, className }) => {
|
|
33
|
+
const isMobile = useIsMobile();
|
|
34
|
+
const { title } = useSettingsContext();
|
|
35
|
+
|
|
36
|
+
const frame = cn(
|
|
37
|
+
'flex h-full min-h-0 w-full flex-col overflow-hidden',
|
|
38
|
+
!bare && 'rounded-[var(--radius-dialog)] border border-border bg-background',
|
|
39
|
+
className,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// ── Mobile: title header + tab strip on top, panel below ──
|
|
43
|
+
if (isMobile) {
|
|
44
|
+
return (
|
|
45
|
+
<div className={frame}>
|
|
46
|
+
<div className="shrink-0 bg-muted">
|
|
47
|
+
{/* Title row — `pr-12` leaves room for the dialog's corner close button. */}
|
|
48
|
+
<div className="px-4 pb-2 pt-3.5 pr-12">
|
|
49
|
+
<h2 className="text-base font-semibold tracking-tight text-foreground">{title}</h2>
|
|
50
|
+
</div>
|
|
51
|
+
{/* Tabs on their own row (below the header), so nothing overlaps the X. */}
|
|
52
|
+
<div className="border-b border-border px-1.5 pb-2">
|
|
53
|
+
<SettingsTabs />
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
<section className="min-h-0 flex-1 bg-card">
|
|
57
|
+
<SettingsPanel />
|
|
58
|
+
</section>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Desktop: rail + panel ──
|
|
64
|
+
return (
|
|
65
|
+
<div className={cn(frame, 'flex-row')}>
|
|
66
|
+
{/* Nav rail — recessed surface, one notch below the content panel. */}
|
|
67
|
+
<aside className="shrink-0 border-r border-border bg-muted p-3 md:w-52">
|
|
68
|
+
<SettingsNav />
|
|
69
|
+
</aside>
|
|
70
|
+
|
|
71
|
+
{/* Detail panel — the lighter, primary surface. */}
|
|
72
|
+
<section className="min-h-0 flex-1 bg-card">
|
|
73
|
+
<SettingsPanel />
|
|
74
|
+
</section>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import { useSettingsContext } from '../context';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mobile nav — a horizontal, scrollable strip of section chips (Claude's
|
|
11
|
+
* phone settings pattern). Shown instead of the vertical rail below `md`.
|
|
12
|
+
* Groups are flattened (a phone strip has no room for group headers); the
|
|
13
|
+
* active chip is a solid pill.
|
|
14
|
+
*/
|
|
15
|
+
export const SettingsTabs: React.FC = () => {
|
|
16
|
+
const { groups, activeId, setActive } = useSettingsContext();
|
|
17
|
+
const sections = groups.flatMap((g) => g.sections);
|
|
18
|
+
const activeRef = React.useRef<HTMLButtonElement>(null);
|
|
19
|
+
|
|
20
|
+
// Keep the active chip in view when it changes (e.g. deep-link).
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
activeRef.current?.scrollIntoView({ inline: 'center', block: 'nearest' });
|
|
23
|
+
}, [activeId]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
role="tablist"
|
|
28
|
+
aria-orientation="horizontal"
|
|
29
|
+
className="flex gap-1 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
30
|
+
>
|
|
31
|
+
{sections.map((section) => {
|
|
32
|
+
const active = section.id === activeId;
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
key={section.id}
|
|
36
|
+
ref={active ? activeRef : undefined}
|
|
37
|
+
type="button"
|
|
38
|
+
role="tab"
|
|
39
|
+
aria-selected={active}
|
|
40
|
+
onClick={() => setActive(section.id)}
|
|
41
|
+
// Text-only chips on mobile (no icons — keeps the strip compact, like Claude).
|
|
42
|
+
className={cn(
|
|
43
|
+
'rounded-control inline-flex shrink-0 items-center px-3 py-1.5 text-sm whitespace-nowrap',
|
|
44
|
+
'outline-none transition-colors focus-visible:ring-1 focus-visible:ring-ring',
|
|
45
|
+
active
|
|
46
|
+
? 'bg-popover font-medium text-foreground'
|
|
47
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{section.label}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|