@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
@@ -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
+ };