@djangocfg/layouts 2.1.591 → 2.1.593

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 (100) hide show
  1. package/package.json +12 -12
  2. package/src/components/errors/ErrorLayout.tsx +1 -164
  3. package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
  4. package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
  5. package/src/components/errors/errorGlyphs.tsx +167 -0
  6. package/src/layouts/AppLayout/BaseApp.tsx +1 -1
  7. package/src/layouts/AppLayout/index.ts +2 -2
  8. package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
  9. package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
  10. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
  11. package/src/layouts/AuthLayout/layoutContext.ts +25 -0
  12. package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
  13. package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
  14. package/src/layouts/PrivateLayout/README.md +20 -12
  15. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
  16. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
  17. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +41 -112
  18. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
  19. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
  20. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
  21. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
  22. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
  23. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
  24. package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
  25. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
  26. package/src/layouts/PrivateLayout/components/index.ts +0 -4
  27. package/src/layouts/PrivateLayout/context.tsx +10 -115
  28. package/src/layouts/PrivateLayout/geometry.ts +17 -0
  29. package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
  30. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  31. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
  32. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
  33. package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
  34. package/src/layouts/PrivateLayout/types.ts +13 -20
  35. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
  36. package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
  37. package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
  38. package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
  39. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
  40. package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
  41. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
  42. package/src/layouts/PublicLayout/shared/index.ts +1 -1
  43. package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
  44. package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
  45. package/src/layouts/SettingsLayout/README.md +23 -10
  46. package/src/layouts/SettingsLayout/SettingsDialog.tsx +27 -11
  47. package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
  48. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
  49. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
  50. package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
  51. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
  52. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
  53. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
  54. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
  55. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
  56. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
  57. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +63 -131
  58. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
  59. package/src/layouts/SettingsLayout/components/index.ts +0 -1
  60. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
  61. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  62. package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
  63. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
  64. package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
  65. package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
  66. package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
  67. package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
  68. package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
  69. package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
  70. package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
  71. package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
  72. package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
  73. package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
  74. package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
  75. package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
  76. package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
  77. package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
  78. package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
  79. package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
  80. package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
  81. package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
  82. package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
  83. package/src/layouts/SettingsLayout/index.ts +3 -1
  84. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
  85. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
  86. package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
  87. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +13 -5
  88. package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
  89. package/src/layouts/SettingsLayout/sections/index.ts +2 -2
  90. package/src/layouts/SettingsLayout/store.ts +12 -0
  91. package/src/layouts/SettingsLayout/types.ts +12 -4
  92. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
  93. package/src/layouts/types/layout.types.ts +5 -6
  94. package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
  95. package/src/styles/dashboard.css +10 -0
  96. package/src/layouts/PrivateLayout/density.ts +0 -48
  97. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
  98. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
  99. package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
  100. package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
@@ -0,0 +1,109 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type {
4
+ PublicDesktopDropdownRenderer,
5
+ PublicNavbarHeight,
6
+ PublicNavbarInsetX,
7
+ PublicNavbarPosition,
8
+ PublicNavbarVariant,
9
+ PublicNavLayout,
10
+ } from '../navbarTypes';
11
+ import type { NavigationItem, UserMenuConfig } from '../../types';
12
+ import type { NavAction } from '../primitives/NavActionItem';
13
+
14
+ export interface ShapeInputs {
15
+ /** true at scrollY >= threshold */
16
+ scrolled: boolean;
17
+ /** Matches `transparent` flag at the source. */
18
+ transparent: boolean;
19
+ /**
20
+ * true while the mobile drawer is open. A `transparent` bar MUST go opaque
21
+ * here regardless of scroll position: an open drawer means the brand + close
22
+ * button sit over page content, and a see-through bar leaves them floating on
23
+ * whatever is scrolled behind — the "navbar lost its background" bug.
24
+ */
25
+ menuOpen: boolean;
26
+ }
27
+
28
+ export interface NavbarShellProps {
29
+ // ── Surface identity (reported to context) ────────────────────────────────
30
+ variant: PublicNavbarVariant;
31
+ position: PublicNavbarPosition;
32
+
33
+ // ── Content ───────────────────────────────────────────────────────────────
34
+ brand?: ReactNode;
35
+ brandHref?: string;
36
+ navigation?: NavigationItem[];
37
+ userMenu?: UserMenuConfig;
38
+ renderDesktopDropdown?: PublicDesktopDropdownRenderer;
39
+ /**
40
+ * Hard cap for how many items to try to fit on desktop. Useful when a
41
+ * consumer wants to force a compact primary row even if more would fit.
42
+ * Defaults to the full list length (let the responsive measurer decide).
43
+ */
44
+ desktopMaxPrimaryItems?: number;
45
+
46
+ // ── Layout ────────────────────────────────────────────────────────────────
47
+ navLayout?: PublicNavLayout;
48
+ navbarHeight?: PublicNavbarHeight;
49
+ /** Horizontal inset between outer edge and the inner row.
50
+ * @default 'sm' */
51
+ insetX?: PublicNavbarInsetX;
52
+ /** Full override for the row container (escape hatch).
53
+ * When set, wins over `insetX`. */
54
+ innerPadding?: string;
55
+
56
+ // ── Scroll behavior ───────────────────────────────────────────────────────
57
+ hideNavOnScroll?: boolean;
58
+ transparent?: boolean;
59
+ transparentThreshold?: number;
60
+
61
+ // ── Chrome (per-variant) ──────────────────────────────────────────────────
62
+ /** Classes on the outer wrapper (fixed/sticky, top offset, outer x-padding). */
63
+ outerClassName?: string;
64
+ /** Classes for the `<nav>` element defining the shape (rounding, border, shadow). */
65
+ shapeClassName?: string;
66
+ /** Surface classes (bg, backdrop-blur). Driven by `shapeForState`. */
67
+ shapeForState?: (inputs: ShapeInputs) => string;
68
+
69
+ /**
70
+ * Replaces the default actions column (UserMenu + mobile-toggle).
71
+ * Variant owns the mobile toggle too. Receives helpers so it can build the
72
+ * right-hand side however it wants.
73
+ */
74
+ renderActions?: (ctx: NavbarActionsContext) => ReactNode;
75
+
76
+ // ── Default-actions slots (only used when `renderActions` is NOT provided) ─
77
+ /** Typed CTA pills (Book a demo / Get started / …) before UserMenu. */
78
+ actions?: NavAction[];
79
+ /** Arbitrary ReactNode between actions and UserMenu. */
80
+ actionsLeadingSlot?: ReactNode;
81
+ /** Arbitrary ReactNode after the mobile toggle. */
82
+ actionsTrailingSlot?: ReactNode;
83
+
84
+ // ── Theme + locale controls (rendered next to UserMenu on desktop) ────────
85
+ /** Toggle individual controls. Locale switcher also requires `i18n`. */
86
+ controls?: {
87
+ /** Light / system / dark pill. @default false */
88
+ showThemeSwitcher?: boolean;
89
+ /** Locale dropdown. Requires `i18n`. @default false */
90
+ showLocaleSwitcher?: boolean;
91
+ };
92
+
93
+ // ── Props override (used by variants that proxy ctx differently) ──────────
94
+ mobileMenuOpen?: boolean;
95
+ onMobileMenuToggle?: () => void;
96
+ }
97
+
98
+ export interface NavbarActionsContext {
99
+ userMenu?: UserMenuConfig;
100
+ mobileMenuOpen: boolean;
101
+ toggleMobileMenu: () => void;
102
+ toggleMobileLabel: string;
103
+ navLayout: PublicNavLayout;
104
+ /**
105
+ * Pre-built theme/locale controls node. `null` when both toggles are off.
106
+ * Variants with a custom `renderActions` can choose where to place it.
107
+ */
108
+ controls: ReactNode | null;
109
+ }
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ import type { PublicNavbarHeight, PublicNavbarInsetX } from '../navbarTypes';
6
+
7
+ /**
8
+ * Vertical padding, per breakpoint. Two independent scales rather than one with
9
+ * `sm:` steps inside it: the mobile row's tallest element is the 44px burger,
10
+ * so padding there is pure added height, while the desktop row needs the extra
11
+ * air to keep the link list off the panel edge.
12
+ */
13
+ export const heightClsDesktop: Record<PublicNavbarHeight, string> = {
14
+ sm: 'py-2',
15
+ md: 'py-3.5',
16
+ lg: 'py-5',
17
+ };
18
+
19
+ export const heightClsMobile: Record<PublicNavbarHeight, string> = {
20
+ sm: 'py-1.5',
21
+ md: 'py-2',
22
+ lg: 'py-3',
23
+ };
24
+
25
+ /**
26
+ * Token → Tailwind class for the inner-row horizontal padding.
27
+ *
28
+ * Deliberately ASYMMETRIC at every breakpoint (`pl` > `pr`) to correct an
29
+ * optical imbalance, not a measured one. On the left the gutter meets the
30
+ * brand's glyphs directly; on the right it meets a button — an icon square on
31
+ * phones, the Login pill on desktop — whose box carries its own internal
32
+ * padding around the glyph. Equal `px` therefore *looks* lopsided, with a
33
+ * conspicuous moat to the right of the last control.
34
+ *
35
+ * It also stacks with the navbar shell's own outer gutter (`px-3 sm:px-4
36
+ * lg:px-6`), which is why the right side stays small in absolute terms: at
37
+ * `lg` the symmetric version put ~48px between the Login button and the
38
+ * viewport edge.
39
+ */
40
+ /** Desktop (`lg`+) horizontal inset. Right stays trimmed — see the note above. */
41
+ export const insetXClsDesktop: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
42
+ none: '',
43
+ xs: 'lg:pl-4 lg:pr-1.5',
44
+ sm: 'lg:pl-6 lg:pr-2',
45
+ md: 'lg:pl-8 lg:pr-3',
46
+ lg: 'lg:pl-12 lg:pr-5',
47
+ };
48
+
49
+ /** Mobile horizontal inset — tighter, and asymmetric for the same reason. */
50
+ export const insetXClsMobile: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
51
+ none: '',
52
+ xs: 'pl-3 pr-1',
53
+ sm: 'pl-4 pr-1.5',
54
+ md: 'pl-5 pr-2',
55
+ lg: 'pl-6 pr-3',
56
+ };
57
+
58
+ /**
59
+ * Resolves the inset token into one class string covering both breakpoints.
60
+ *
61
+ * A caller may also pass raw Tailwind instead of a token; that escape hatch is
62
+ * applied verbatim to both rows, since we cannot know how to split it.
63
+ */
64
+ export function resolveInsetX(value: PublicNavbarInsetX | undefined): string {
65
+ const token = (value ?? 'sm') as keyof typeof insetXClsMobile;
66
+ if (!(token in insetXClsMobile)) return value as string;
67
+ return cn(insetXClsMobile[token], insetXClsDesktop[token]);
68
+ }
69
+
@@ -1,6 +1,6 @@
1
1
  # SettingsLayout
2
2
 
3
- A Claude.ai-style **settings surface**: a searchable, grouped section rail on the
3
+ A Claude.ai-style **settings surface**: a grouped section rail (search appears from 10 sections, `searchable`) on the
4
4
  left and a scrollable detail panel on the right. Available as a **global dialog**
5
5
  (URL-hash driven, zustand-backed, openable from anywhere) or an **inline form**.
6
6
 
@@ -127,7 +127,7 @@ ordering/labelling, or just name an ad-hoc `group` on a section:
127
127
 
128
128
  ```ts
129
129
  const groups = [
130
- { id: 'general', label: 'General', order: 0 },
130
+ { id: 'general', order: 0 },
131
131
  { id: 'workspace', label: 'Workspace', order: 1 },
132
132
  ];
133
133
  ```
@@ -139,16 +139,19 @@ toggled via `builtins`:
139
139
 
140
140
  | id | flag | default | content |
141
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) |
142
+ | `account` | `builtins.account` | `true` | avatar, inline-editable profile/work fields, **Security** block (two-factor auth; hide with `builtins.security: false`) and a **collapsible Danger zone** (delete account) |
143
+ | `general` | `builtins.general` | `true` | **General** block: **Theme** (`ThemeSegmented`), **Time zone** (`TimezoneSelect` → `useTimezone`, sent as `X-Timezone`) and **Language** (shown when a layout i18n context exists); host rows from `generalRows` follow in a second block ("Other") |
144
144
  | `api-keys` | `builtins.apiKeys` | `true` | API key display / regenerate / test (row-style) |
145
145
 
146
- Built-ins are merged **ahead of** app sections. They reuse the localized labels
146
+ `generalRows` (on `SettingsDialog` / `SettingsForm` / `PrivateLayout settings`) takes
147
+ your own `SettingRow`s for preferences only the host knows (e.g. a product-specific toggle); the layout adds none itself.
148
+
149
+ Rail order: **Account** (opens first), **Preferences** (id `general`), then Platform → API keys. Built-ins are merged **ahead of** app sections. They reuse the localized labels
147
150
  and save/validation logic from `ProfileProvider`, so behaviour stays in one place.
148
151
  The built-in bodies are built from the ui-core `SettingRow`/`SettingsBlock`
149
152
  primitives (see below) — flat rows with hairline dividers, no card frames.
150
153
 
151
- > The `security` / `api-keys` bodies hit the real backend (2FA status, API key
154
+ > The Account security block and `api-keys` body hit the real backend (2FA status, API key
152
155
  > retrieval). With no backend (e.g. Storybook) they show an error/empty state —
153
156
  > that's expected; gate them with `builtins` where there's no API.
154
157
 
@@ -215,17 +218,18 @@ SettingsLayout/
215
218
  │ └── useSettingsSections.ts pure: merge/order/group/filter sections
216
219
  ├── components/
217
220
  │ ├── SettingsShell.tsx desktop rail+panel vs mobile tabs+panel (useIsMobile)
218
- │ ├── SettingsNav.tsx desktop rail: title + search + grouped items
221
+ │ ├── SettingsShell.tsx header bar + rail + panel (desktop), tabs + panel (mobile)
222
+ │ ├── SettingsNav.tsx desktop rail: search + grouped items
219
223
  │ ├── SettingsNavItem.tsx one rail row
220
224
  │ ├── SettingsTabs.tsx mobile horizontal tab strip (text chips, auto-scroll active)
221
225
  │ ├── SettingsSearch.tsx filter box
222
226
  │ └── SettingsPanel.tsx header + independently-scrolling body
223
227
  └── sections/
224
- ├── builtins.tsx account/security/api-keys factory
225
- ├── AccountSection.tsx avatar + fields + preferences + danger zone (row-style)
228
+ ├── builtins.tsx general/account/api-keys factory
229
+ ├── GeneralSection.tsx Appearance + Language + host `generalRows`
230
+ ├── AccountSection.tsx avatar + fields + 2FA + danger zone (row-style)
226
231
  ├── PreferencesRows.tsx Language dropdown + ThemeSegmented as SettingRows
227
232
  ├── DeleteAccountRow.tsx compact destructive row (inside collapsible Danger zone)
228
- ├── SecuritySection.tsx 2FA wrapper
229
233
  └── ApiKeysSection.tsx API-keys wrapper
230
234
  ```
231
235
 
@@ -256,3 +260,12 @@ exported too; the row primitives come from `@djangocfg/ui-core/components`.
256
260
  2. `useProfileDialogStore.open({ initialTab })` → `useSettingsDialog().open(id)`.
257
261
  3. Mount `<SettingsDialog/>` instead of `<ProfileDialog/>` (or pass `settings` to
258
262
  PrivateLayout). The account button can be repointed to `useSettingsDialog()`.
263
+
264
+ ## Localization
265
+
266
+ Built-in text (rail labels, Preferences, Security, API key) comes from the
267
+ self-contained dictionaries in `i18n/` (17 locales, `useSettingsCopy`), following the
268
+ `@djangocfg/i18n` provider the host mounts, with English as the fallback. Widgets a host adds as sections (for example `mcpSettingsSection()` from `@djangocfg/widget-mcp`)
269
+ follow the same provider. Text the host passes in (`sections`, `groups`, `generalRows`, titles) is the
270
+ host's to translate; profile field labels still come from the central `layouts.profilePage.*`
271
+ catalogue. A new built-in string goes into every `i18n/locales/*.ts` (`tsc` names a missing key).
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
- import React, { useCallback, useMemo } from 'react';
3
+ import React, { useCallback, useEffect, useMemo } from 'react';
4
4
 
5
+ import { useAuth } from '@djangocfg/api/auth';
5
6
  import {
6
7
  Dialog,
7
8
  DialogContent,
@@ -13,6 +14,7 @@ import { cn } from '@djangocfg/ui-core/lib';
13
14
 
14
15
  import { SettingsForm } from './SettingsForm';
15
16
  import { useSettingsUrl } from './hooks/useSettingsUrl';
17
+ import { useSettingsCopy } from './i18n';
16
18
  import { buildBuiltinSections } from './sections';
17
19
  import { useSettingsDialogStore } from './store';
18
20
  import type { SettingsDialogProps } from './types';
@@ -34,8 +36,9 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
34
36
  sections: appSections = [],
35
37
  groups,
36
38
  builtins = {},
39
+ generalRows,
37
40
  initialSection,
38
- searchable = true,
41
+ searchable = 'auto',
39
42
  enableDeleteAccount = true,
40
43
  }) => {
41
44
  const isOpen = useSettingsDialogStore((s) => s.isOpen);
@@ -43,39 +46,51 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
43
46
  const close = useSettingsDialogStore((s) => s.close);
44
47
  const setSection = useSettingsDialogStore((s) => s.setSection);
45
48
  const isMobile = useIsMobile();
49
+ const copy = useSettingsCopy();
50
+ // Settings are about the signed-in user: for a guest the panels are empty, so `#settings` opens nothing until sign-in.
51
+ const { isAuthenticated } = useAuth();
46
52
 
47
53
  // Set of valid ids, so a bad URL hash falls back gracefully instead of
48
54
  // selecting a non-existent section. Cheap to recompute; built-ins are pure.
49
55
  const knownIds = useMemo(() => {
50
- const builtin = buildBuiltinSections({ builtins, enableDeleteAccount });
56
+ const builtin = buildBuiltinSections({ builtins, enableDeleteAccount, copy });
51
57
  const ids = new Set<string>();
52
58
  [...builtin, ...appSections].forEach((s) => ids.add(s.id));
53
59
  return ids;
54
- }, [builtins, enableDeleteAccount, appSections]);
60
+ }, [builtins, enableDeleteAccount, appSections, copy]);
61
+
62
+ // Menus (the account menu) link to these; only this component knows the host's sections.
63
+ const setMenuSections = useSettingsDialogStore((s) => s.setSections);
64
+ useEffect(() => {
65
+ const all = [...buildBuiltinSections({ builtins, enableDeleteAccount, copy }), ...appSections];
66
+ setMenuSections(all.filter((s) => !s.hidden).map(({ id, label, icon }) => ({ id, label, icon })));
67
+ }, [builtins, enableDeleteAccount, appSections, copy, setMenuSections]);
55
68
 
56
69
  const resolveSection = useCallback(
57
70
  (id: string | null) => (id && knownIds.has(id) ? id : null),
58
71
  [knownIds],
59
72
  );
60
73
 
61
- useSettingsUrl({ urlKey, enabled: syncUrl, resolveSection });
74
+ useSettingsUrl({ urlKey, enabled: syncUrl && isAuthenticated, resolveSection });
62
75
 
63
- const resolvedTitle = title ?? 'Settings';
76
+ const resolvedTitle = title ?? copy.nav.title;
64
77
 
65
78
  return (
66
- <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
79
+ <Dialog open={isOpen && isAuthenticated} onOpenChange={(open) => !open && close()}>
67
80
  <DialogContent
68
- // Desktop: centered card with breathing room (capped, gap to edges).
81
+ // Desktop: large centred card, proportional to the viewport.
69
82
  // Mobile: near-fullscreen sheet (small inset) so the phone settings get
70
83
  // the whole screen, like Claude. Elevated `bg-card` lifts it off the page.
71
84
  className={cn(
72
- 'flex max-w-none flex-col gap-0 overflow-hidden border border-border bg-card p-0',
85
+ 'flex max-w-none flex-col gap-0 overflow-hidden border border-foreground/5 bg-card p-0',
73
86
  isMobile
74
87
  ? 'h-[calc(100dvh-2rem)] w-[calc(100vw-1.5rem)]'
75
- : 'h-[min(620px,calc(100vh-6rem))] w-[min(900px,calc(100vw-4rem))]',
88
+ // Claude proportions: 1024×800 at most, 1.28:1 at every size (ui-core
89
+ // DialogContent derives the width from the viewport height).
90
+ : '[--aspect-dialog:1.28] [--width-dialog:1024px]',
76
91
  )}
77
92
  >
78
- {/* Accessible label — the visible heading lives in the nav rail. */}
93
+ {/* Accessible label — the visible heading is the shell header bar. */}
79
94
  <DialogTitle className="sr-only">
80
95
  {typeof resolvedTitle === 'string' ? resolvedTitle : 'Settings'}
81
96
  </DialogTitle>
@@ -89,6 +104,7 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
89
104
  sections={appSections}
90
105
  groups={groups}
91
106
  builtins={builtins}
107
+ generalRows={generalRows}
92
108
  initialSection={initialSection}
93
109
  searchable={searchable}
94
110
  enableDeleteAccount={enableDeleteAccount}
@@ -8,7 +8,8 @@ import { Preloader } from '@djangocfg/ui-core/components';
8
8
 
9
9
  import { SettingsShell } from './components';
10
10
  import { SettingsProvider } from './context';
11
- import { buildBuiltinSections, BUILTIN_GROUP } from './sections';
11
+ import { useSettingsCopy } from './i18n';
12
+ import { buildBuiltinSections, BUILTIN_GROUP, PLATFORM_GROUP } from './sections';
12
13
  import type { SettingsFormProps, SettingsSection } from './types';
13
14
 
14
15
  /**
@@ -30,8 +31,9 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
30
31
  sections: appSections = [],
31
32
  groups,
32
33
  builtins = {},
34
+ generalRows,
33
35
  initialSection,
34
- searchable = true,
36
+ searchable = 'auto',
35
37
  enableDeleteAccount = true,
36
38
  activeSection,
37
39
  onSectionChange,
@@ -40,21 +42,23 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
40
42
  bare = false,
41
43
  }: SettingsFormProps) => {
42
44
  const t = useAppT();
45
+ const copy = useSettingsCopy();
43
46
  const { user, isLoading } = useAuth();
44
47
 
45
48
  // Built-ins first, then app sections. Memoized so JSX identity is stable.
46
49
  const allSections = useMemo<SettingsSection[]>(() => {
47
- const builtin = buildBuiltinSections({ builtins, enableDeleteAccount });
50
+ const builtin = buildBuiltinSections({ builtins, enableDeleteAccount, generalRows, copy });
48
51
  return [...builtin, ...appSections];
49
- }, [builtins, enableDeleteAccount, appSections]);
52
+ }, [builtins, enableDeleteAccount, generalRows, appSections, copy]);
50
53
 
51
54
  // Ensure the built-in group exists/ordered even if the app passed its own groups.
52
55
  const allGroups = useMemo(() => {
53
56
  const provided = groups ?? [];
54
- return provided.some((g) => g.id === BUILTIN_GROUP.id)
55
- ? provided
56
- : [BUILTIN_GROUP, ...provided];
57
- }, [groups]);
57
+ const missing = [BUILTIN_GROUP, { ...PLATFORM_GROUP, label: copy.nav.platform }].filter(
58
+ (b) => !provided.some((g) => g.id === b.id),
59
+ );
60
+ return [...missing, ...provided];
61
+ }, [groups, copy]);
58
62
 
59
63
  // Uncontrolled selection fallback.
60
64
  const [localActive, setLocalActive] = useState<string | null>(initialSection ?? null);
@@ -82,7 +86,7 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
82
86
  }
83
87
 
84
88
  // Default to "Settings" — this surface is broader than the old Profile page.
85
- const resolvedTitle = title ?? 'Settings';
89
+ const resolvedTitle = title ?? copy.nav.title;
86
90
 
87
91
  return (
88
92
  <SettingsProvider
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import { ChevronDown } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import { useAuth } from '@djangocfg/api/auth';
7
+ import { Avatar, AvatarFallback, AvatarImage, Button, MenuBuilder } from '@djangocfg/ui-core/components';
8
+ import type { MenuItem } from '@djangocfg/ui-core/components';
9
+ import { cn } from '@djangocfg/ui-core/lib';
10
+
11
+ import { useAccountMenuItems } from '../hooks/useAccountMenuItems';
12
+
13
+ export interface AccountMenuButtonProps {
14
+ /** `row`: avatar, name and chevron (a sidebar footer; the menu opens up). `avatar`: the avatar alone (a header; the menu opens down). */
15
+ variant?: 'row' | 'avatar';
16
+ /** Items between the Settings shortcuts and Sign out (language, app links). */
17
+ extra?: MenuItem[];
18
+ /** Small muted text after the name (a plan). */
19
+ plan?: string | null;
20
+ className?: string;
21
+ }
22
+
23
+ /**
24
+ * The ready user menu: trigger button plus the shared menu (email, Settings, a shortcut per
25
+ * Settings section, Sign out). Drop it into any chrome; renders nothing for a signed-out visitor.
26
+ * `PrivateLayout`'s sidebar footer builds on the same `useAccountMenuItems`.
27
+ */
28
+ export const AccountMenuButton: React.FC<AccountMenuButtonProps> = ({
29
+ variant = 'row',
30
+ extra,
31
+ plan,
32
+ className,
33
+ }) => {
34
+ const { user } = useAuth();
35
+ const items = useAccountMenuItems({ extra });
36
+ const [open, setOpen] = React.useState(false);
37
+
38
+ if (!user) return null;
39
+
40
+ const name = user.display_username || user.email || 'User';
41
+ const initial = (name.charAt(0) || '?').toUpperCase();
42
+ const isRow = variant === 'row';
43
+
44
+ return (
45
+ <MenuBuilder
46
+ items={items}
47
+ open={open}
48
+ onOpenChange={setOpen}
49
+ side={isRow ? 'top' : 'bottom'}
50
+ align={isRow ? 'start' : 'end'}
51
+ sideOffset={8}
52
+ contentClassName="w-60 p-1.5"
53
+ >
54
+ <Button
55
+ type="button"
56
+ variant="ghost"
57
+ aria-label={name}
58
+ className={cn(
59
+ 'group min-w-0 select-none gap-2 rounded-lg font-normal focus-visible:ring-0',
60
+ isRow ? 'h-9 w-full justify-start px-2 text-left' : 'size-9 p-0',
61
+ className,
62
+ )}
63
+ >
64
+ <Avatar className="size-6 shrink-0">
65
+ <AvatarImage src={user.avatar ?? ''} alt={name} />
66
+ <AvatarFallback className="text-xs font-medium">{initial}</AvatarFallback>
67
+ </Avatar>
68
+ {isRow && (
69
+ <>
70
+ <span className="flex min-w-0 items-baseline gap-1">
71
+ <span className="truncate text-sm">{name}</span>
72
+ {plan ? <span className="shrink-0 text-xs text-muted-foreground">· {plan}</span> : null}
73
+ </span>
74
+ <ChevronDown
75
+ className="ml-auto size-4 shrink-0 opacity-50 transition-transform duration-150 group-data-[state=open]:rotate-180"
76
+ aria-hidden
77
+ />
78
+ </>
79
+ )}
80
+ </Button>
81
+ </MenuBuilder>
82
+ );
83
+ };
@@ -13,6 +13,7 @@ import {
13
13
  } from '@djangocfg/ui-core/components';
14
14
  import { cn } from '@djangocfg/ui-core/lib';
15
15
 
16
+ import { useSettingsLocale } from '../../i18n';
16
17
  import { ApiKeyProvider, useApiKeyContext } from './context';
17
18
 
18
19
  // ─────────────────────────────────────────────────────────────────────────────
@@ -26,12 +27,12 @@ import { ApiKeyProvider, useApiKeyContext } from './context';
26
27
  //
27
28
  // A bare `new Date(iso)` would read a timestamp without a zone designator as
28
29
  // LOCAL time; the API sends UTC, so the `Z` is appended when it is missing.
29
- function formatDate(iso: string | null, fallback: string): string {
30
+ function formatDate(iso: string | null, fallback: string, locale: string): string {
30
31
  if (!iso) return fallback;
31
32
  const utc = /(?:Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`;
32
33
  const date = new Date(utc);
33
34
  if (Number.isNaN(date.getTime())) return fallback;
34
- return new Intl.DateTimeFormat('en-US', {
35
+ return new Intl.DateTimeFormat(locale, {
35
36
  year: 'numeric',
36
37
  month: 'short',
37
38
  day: 'numeric',
@@ -48,6 +49,7 @@ function isMasked(key: string): boolean {
48
49
  // ─────────────────────────────────────────────────────────────────────────────
49
50
 
50
51
  function ApiKeyCard() {
52
+ const locale = useSettingsLocale();
51
53
  const {
52
54
  labels,
53
55
  apiKey,
@@ -79,7 +81,7 @@ function ApiKeyCard() {
79
81
  // SettingRow renders `description` inside a block, so this is valid.
80
82
  const titleDescription = (
81
83
  <div className="flex flex-wrap items-center gap-2">
82
- {apiKey && <Badge variant="secondary" className="text-xs">Active</Badge>}
84
+ {apiKey && <Badge variant="secondary" className="text-xs">{labels.active}</Badge>}
83
85
  <span>{labels.description}</span>
84
86
  </div>
85
87
  );
@@ -127,7 +129,7 @@ function ApiKeyCard() {
127
129
  {isFresh && (
128
130
  <div className="flex items-center gap-2 rounded-control bg-accent px-3 py-2 text-sm text-foreground">
129
131
  <KeyRound className="w-4 h-4 flex-shrink-0 text-primary" />
130
- <span className="flex-1">This is your new API key — copy it now. It will be masked when you leave this page.</span>
132
+ <span className="flex-1">{labels.freshKey}</span>
131
133
  <Button variant="ghost" size="sm" className="h-7 px-2" onClick={dismissFresh}>
132
134
  <Check className="w-4 h-4 mr-1" />
133
135
  {labels.done}
@@ -150,7 +152,7 @@ function ApiKeyCard() {
150
152
 
151
153
  {/* API key value row */}
152
154
  {apiKey && (
153
- <SettingRow label="API key" stacked>
155
+ <SettingRow label={labels.keyRow} stacked>
154
156
  <div className="flex items-center gap-2">
155
157
  <div className="flex-1 min-w-0 rounded-control bg-muted px-3 py-2.5 font-mono text-sm select-all">
156
158
  <span className={cn(masked && 'tracking-widest')}>
@@ -181,10 +183,10 @@ function ApiKeyCard() {
181
183
 
182
184
  {/* Dates */}
183
185
  {apiKey && createdAt && (
184
- <SettingRow label={labels.created} value={formatDate(createdAt, '—')} />
186
+ <SettingRow label={labels.created} value={formatDate(createdAt, '—', locale)} />
185
187
  )}
186
188
  {apiKey && (
187
- <SettingRow label={labels.reissued} value={formatDate(reissuedAt, labels.neverReissued)} />
189
+ <SettingRow label={labels.reissued} value={formatDate(reissuedAt, labels.neverReissued, locale)} />
188
190
  )}
189
191
 
190
192
  {error && (
@@ -5,45 +5,9 @@ import React, { createContext, useCallback, useContext, useMemo, useState } from
5
5
  import { useApiKey } from '@djangocfg/api/hooks';
6
6
  import { toast } from '@djangocfg/ui-core/hooks';
7
7
 
8
- // ─────────────────────────────────────────────────────────────────────────────
9
- // Labels (hardcoded English — same pattern as TwoFactorSection)
10
- // ─────────────────────────────────────────────────────────────────────────────
8
+ import { useSettingsCopy, type SettingsCopy } from '../../i18n';
11
9
 
12
- export interface ApiKeyLabels {
13
- title: string;
14
- description: string;
15
- created: string;
16
- reissued: string;
17
- neverReissued: string;
18
- regenerate: string;
19
- confirmRegenerate: string;
20
- regenerating: string;
21
- regenerated: string;
22
- failedToRegenerate: string;
23
- done: string;
24
- test: string;
25
- testing: string;
26
- testSuccess: string;
27
- testFailed: string;
28
- }
29
-
30
- const LABELS: ApiKeyLabels = {
31
- title: 'API Key',
32
- description: 'Your personal API key for programmatic access',
33
- created: 'Created',
34
- reissued: 'Last reissued',
35
- neverReissued: 'Never reissued',
36
- regenerate: 'Regenerate API Key',
37
- confirmRegenerate: 'Click to confirm',
38
- regenerating: 'Regenerating…',
39
- regenerated: 'API key regenerated',
40
- failedToRegenerate: 'Failed to regenerate API key',
41
- done: 'Done',
42
- test: 'Test API Key',
43
- testing: 'Testing…',
44
- testSuccess: 'API key is valid',
45
- testFailed: 'API key test failed',
46
- };
10
+ export type ApiKeyLabels = SettingsCopy['apiKey'];
47
11
 
48
12
  // ─────────────────────────────────────────────────────────────────────────────
49
13
  // Context
@@ -83,6 +47,7 @@ export const useApiKeyContext = (): ApiKeyContextValue => {
83
47
  export const ApiKeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
84
48
  const { apiKey, reissuedAt, createdAt, isLoading, isRegenerating, isTesting, regenerate, testKey, refresh } = useApiKey();
85
49
 
50
+ const LABELS = useSettingsCopy().apiKey;
86
51
  const [isArmed, setIsArmed] = useState(false);
87
52
  const [error, setError] = useState<string | null>(null);
88
53
  const [testResult, setTestResult] = useState<boolean | null>(null);
@@ -109,7 +74,7 @@ export const ApiKeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
109
74
  setError(LABELS.failedToRegenerate);
110
75
  toast.error(LABELS.failedToRegenerate);
111
76
  }
112
- }, [regenerate]);
77
+ }, [regenerate, LABELS]);
113
78
 
114
79
  const handleTest = useCallback(async () => {
115
80
  if (!apiKey) return;
@@ -126,7 +91,7 @@ export const ApiKeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
126
91
  setTestResult(false);
127
92
  toast.error(LABELS.testFailed);
128
93
  }
129
- }, [apiKey, testKey]);
94
+ }, [apiKey, testKey, LABELS]);
130
95
 
131
96
  const value = useMemo<ApiKeyContextValue>(() => ({
132
97
  labels: LABELS,
@@ -146,7 +111,7 @@ export const ApiKeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
146
111
  isTesting,
147
112
  testResult,
148
113
  }), [
149
- apiKey, reissuedAt, createdAt, isLoading, error,
114
+ LABELS, apiKey, reissuedAt, createdAt, isLoading, error,
150
115
  isArmed, arm, disarm, handleRegenerate, isRegenerating, isFresh, dismissFresh,
151
116
  handleTest, isTesting, testResult,
152
117
  ]);