@djangocfg/layouts 2.1.356 → 2.1.358

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 (83) hide show
  1. package/package.json +21 -19
  2. package/src/configurator/private/schema.ts +12 -0
  3. package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
  4. package/src/layouts/AppLayout/AppLayout.tsx +35 -15
  5. package/src/layouts/AppLayout/BaseApp.tsx +2 -2
  6. package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
  7. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
  8. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
  9. package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
  10. package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
  11. package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
  12. package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
  13. package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
  14. package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
  15. package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
  16. package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
  17. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
  18. package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
  19. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
  20. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
  21. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
  22. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
  23. package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
  24. package/src/layouts/AuthLayout/context.tsx +35 -13
  25. package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
  26. package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
  27. package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
  28. package/src/layouts/AuthLayout/shells/context.tsx +16 -5
  29. package/src/layouts/PrivateLayout/PrivateLayout.tsx +45 -248
  30. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +113 -430
  31. package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +82 -105
  32. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +168 -0
  33. package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
  34. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
  35. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
  36. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
  37. package/src/layouts/PrivateLayout/components/index.ts +4 -0
  38. package/src/layouts/PrivateLayout/context.tsx +211 -0
  39. package/src/layouts/PrivateLayout/density.ts +48 -0
  40. package/src/layouts/PrivateLayout/hooks/index.ts +14 -0
  41. package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
  42. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +110 -0
  43. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
  44. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
  45. package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +21 -0
  46. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
  47. package/src/layouts/PrivateLayout/index.ts +2 -2
  48. package/src/layouts/PrivateLayout/types.ts +193 -0
  49. package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
  50. package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
  51. package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
  52. package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +8 -8
  53. package/src/layouts/ProfileLayout/ProfileForm/index.tsx +148 -0
  54. package/src/layouts/ProfileLayout/README.md +118 -0
  55. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
  56. package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
  57. package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
  58. package/src/layouts/ProfileLayout/components/EditableField.tsx +1 -1
  59. package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
  60. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
  61. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +35 -0
  62. package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
  63. package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
  64. package/src/layouts/ProfileLayout/components/index.ts +5 -2
  65. package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
  66. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +48 -0
  67. package/src/layouts/ProfileLayout/index.ts +7 -3
  68. package/src/layouts/ProfileLayout/types.ts +47 -0
  69. package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
  70. package/src/layouts/PublicLayout/components/index.ts +4 -0
  71. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
  72. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
  73. package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
  74. package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
  75. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
  76. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
  77. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
  78. package/src/layouts/_components/index.ts +2 -6
  79. package/src/layouts/index.ts +9 -4
  80. package/src/layouts/ProfileLayout/ProfileLayout.tsx +0 -284
  81. package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
  82. package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
  83. /package/src/layouts/{_components → PublicLayout/components}/UserAvatar.tsx +0 -0
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { type ReactNode } from 'react';
3
+ import React, { memo, type ReactNode } from 'react';
4
4
 
5
5
  import { Link } from '@djangocfg/ui-core/components';
6
6
 
@@ -9,7 +9,7 @@ interface NavBrandProps {
9
9
  brandHref?: string;
10
10
  }
11
11
 
12
- export function NavBrand({ brand, brandHref = '/' }: NavBrandProps) {
12
+ function NavBrandRaw({ brand, brandHref = '/' }: NavBrandProps) {
13
13
  if (brand == null || brand === '' || brand === false) return null;
14
14
 
15
15
  if (typeof brand === 'string') {
@@ -25,3 +25,5 @@ export function NavBrand({ brand, brandHref = '/' }: NavBrandProps) {
25
25
 
26
26
  return <>{brand}</>;
27
27
  }
28
+
29
+ export const NavBrand = memo(NavBrandRaw);
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { ChevronDown } from 'lucide-react';
4
- import React from 'react';
4
+ import React, { memo } from 'react';
5
5
 
6
6
  import { Button, Link } from '@djangocfg/ui-core/components';
7
7
  import { cn } from '@djangocfg/ui-core/lib';
@@ -48,7 +48,7 @@ function subMenuLinkCls(active: boolean) {
48
48
  const popoverCls =
49
49
  'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]';
50
50
 
51
- export function NavDesktopItems({
51
+ function NavDesktopItemsRaw({
52
52
  items,
53
53
  maxVisible,
54
54
  isActivePath,
@@ -245,3 +245,43 @@ export function NavDesktopItems({
245
245
  </div>
246
246
  );
247
247
  }
248
+
249
+ /**
250
+ * Shallow equality for NavigationItem arrays. Compares href, label, external
251
+ * and one-level-deep sub-items so that nav label/badge changes re-render,
252
+ * but object-reference churn from parent is ignored.
253
+ */
254
+ function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
255
+ if (a === b) return true;
256
+ if (a.length !== b.length) return false;
257
+ return a.every((ai, i) => {
258
+ const bi = b[i];
259
+ if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
260
+ if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
261
+ if (ai.items) {
262
+ return ai.items.every((sub, j) => {
263
+ const bsub = bi.items![j];
264
+ return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
265
+ });
266
+ }
267
+ return true;
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Memoised desktop navigation row. Re-renders only when:
273
+ * - the items array content changes (via navItemsShallowEqual),
274
+ * - dropdown state object changes,
275
+ * - maxVisible or renderDesktopDropdown changes.
276
+ * Parent re-renders with identical navigation data are skipped entirely.
277
+ */
278
+ export const NavDesktopItems = memo(NavDesktopItemsRaw, (prev, next) => {
279
+ return (
280
+ prev.maxVisible === next.maxVisible &&
281
+ prev.isActivePath === next.isActivePath &&
282
+ prev.isGroupActive === next.isGroupActive &&
283
+ prev.dropdown === next.dropdown &&
284
+ prev.renderDesktopDropdown === next.renderDesktopDropdown &&
285
+ navItemsShallowEqual(prev.items, next.items)
286
+ );
287
+ });
@@ -17,7 +17,7 @@ import { useBodyScrollLock, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks'
17
17
  import { cn } from '@djangocfg/ui-core/lib';
18
18
 
19
19
  import { usePathnameWithoutLocale } from '../../../hooks';
20
- import { UserMenu } from '../../_components/UserMenu';
20
+ import { UserMenu } from '../components/UserMenu';
21
21
  import { usePublicLayoutOptional } from '../context';
22
22
  import { useMobileNavPanel } from '../hooks';
23
23
  import { NavControls } from '../primitives/NavControls';
@@ -17,6 +17,7 @@
17
17
 
18
18
  import React, {
19
19
  type ReactNode,
20
+ memo,
20
21
  useEffect,
21
22
  useLayoutEffect,
22
23
  useMemo,
@@ -160,7 +161,7 @@ export interface NavbarActionsContext {
160
161
  controls: ReactNode | null;
161
162
  }
162
163
 
163
- export function NavbarShell(props: NavbarShellProps) {
164
+ function NavbarShellRaw(props: NavbarShellProps) {
164
165
  const context = usePublicLayoutOptional();
165
166
 
166
167
  const {
@@ -348,3 +349,61 @@ export function NavbarShell(props: NavbarShellProps) {
348
349
  </div>
349
350
  );
350
351
  }
352
+
353
+ /**
354
+ * Shallow equality for NavigationItem arrays. Compares href, label, external
355
+ * and one-level-deep sub-items so that nav label/badge changes re-render,
356
+ * but object-reference churn from parent is ignored.
357
+ */
358
+ function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
359
+ if (a === b) return true;
360
+ if (a.length !== b.length) return false;
361
+ return a.every((ai, i) => {
362
+ const bi = b[i];
363
+ if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
364
+ if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
365
+ if (ai.items) {
366
+ return ai.items.every((sub, j) => {
367
+ const bsub = bi.items![j];
368
+ return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
369
+ });
370
+ }
371
+ return true;
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Memoised navbar orchestrator. Re-renders only when any config prop changes
377
+ * (variant, position, nav items, actions, slots, etc.). The heavy inner JSX
378
+ * (brand, desktop nav, actions) is preserved across renders when props are
379
+ * stable. Navigation items are compared via navItemsShallowEqual so that
380
+ * reference churn from parent does not cause a re-render.
381
+ */
382
+ export const NavbarShell = memo(NavbarShellRaw, (prev, next) => {
383
+ return (
384
+ prev.variant === next.variant &&
385
+ prev.position === next.position &&
386
+ prev.brand === next.brand &&
387
+ prev.brandHref === next.brandHref &&
388
+ prev.userMenu === next.userMenu &&
389
+ prev.desktopMaxPrimaryItems === next.desktopMaxPrimaryItems &&
390
+ prev.navLayout === next.navLayout &&
391
+ prev.navbarHeight === next.navbarHeight &&
392
+ prev.insetX === next.insetX &&
393
+ prev.innerPadding === next.innerPadding &&
394
+ prev.hideNavOnScroll === next.hideNavOnScroll &&
395
+ prev.transparent === next.transparent &&
396
+ prev.transparentThreshold === next.transparentThreshold &&
397
+ prev.outerClassName === next.outerClassName &&
398
+ prev.shapeClassName === next.shapeClassName &&
399
+ prev.shapeForState === next.shapeForState &&
400
+ prev.renderActions === next.renderActions &&
401
+ prev.actions === next.actions &&
402
+ prev.actionsLeadingSlot === next.actionsLeadingSlot &&
403
+ prev.actionsTrailingSlot === next.actionsTrailingSlot &&
404
+ prev.controls === next.controls &&
405
+ prev.mobileMenuOpen === next.mobileMenuOpen &&
406
+ prev.onMobileMenuToggle === next.onMobileMenuToggle &&
407
+ navItemsShallowEqual(prev.navigation ?? [], next.navigation ?? [])
408
+ );
409
+ });
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Shared Layout Components
3
+ *
4
+ * Components used across multiple layout types.
3
5
  */
4
6
 
5
7
  export {
@@ -26,9 +28,3 @@ export type {
26
28
  LocaleSwitcherVariant,
27
29
  } from './LocaleSwitcher';
28
30
 
29
- export { UserMenu } from './UserMenu';
30
- export type { UserMenuProps } from './UserMenu';
31
-
32
- export { UserAvatar } from './UserAvatar';
33
- export type { UserAvatarProps } from './UserAvatar';
34
-
@@ -10,9 +10,15 @@
10
10
  // Shared types (universal type system)
11
11
  export * from './types';
12
12
 
13
- // Shared components
14
- export { LocaleSwitcher, LOCALE_LABELS, UserMenu } from './_components';
15
- export type { LocaleSwitcherProps, UserMenuProps } from './_components';
13
+ // Shared components (used by multiple layouts)
14
+ export { LocaleSwitcher, LOCALE_LABELS } from './_components';
15
+ export type { LocaleSwitcherProps } from './_components';
16
+
17
+ // PublicLayout components (re-exported for convenience)
18
+ export { UserMenu } from './PublicLayout/components';
19
+ export type { UserMenuProps } from './PublicLayout/components';
20
+ export { UserAvatar } from './PublicLayout/components';
21
+ export type { UserAvatarProps } from './PublicLayout/components';
16
22
 
17
23
  // Smart layout router
18
24
  export * from './AppLayout';
@@ -25,4 +31,3 @@ export * from './AdminLayout';
25
31
 
26
32
  // Additional layouts
27
33
  export * from './ProfileLayout';
28
-
@@ -1,284 +0,0 @@
1
- 'use client';
2
-
3
- import { LogOut, MoreHorizontal, Trash2 } from 'lucide-react';
4
- import moment from 'moment';
5
- import React, { useCallback, useEffect } from 'react';
6
-
7
- import { useAuth, useDeleteAccount } from '@djangocfg/api/auth';
8
- import { useAppT } from '@djangocfg/i18n';
9
- import {
10
- Button,
11
- DropdownMenu,
12
- DropdownMenuContent,
13
- DropdownMenuItem,
14
- DropdownMenuSeparator,
15
- DropdownMenuTrigger,
16
- Preloader,
17
- Tabs,
18
- TabsContent,
19
- TabsList,
20
- TabsTrigger,
21
- } from '@djangocfg/ui-core/components';
22
-
23
- import {
24
- AvatarSection,
25
- EditableField,
26
- Section,
27
- TwoFactorSection,
28
- } from './components';
29
- import { ProfileProvider, useProfileContext } from './context';
30
-
31
- // ─────────────────────────────────────────────────────────────────────────────
32
- // Slot + Tab types (public API)
33
- // ─────────────────────────────────────────────────────────────────────────────
34
-
35
- export interface ProfileTab {
36
- /** Unique key, used as Tabs value */
37
- value: string;
38
- /** Trigger label */
39
- label: React.ReactNode;
40
- /** Tab panel content */
41
- content: React.ReactNode;
42
- }
43
-
44
- export interface ProfileSlots {
45
- /** Extra items rendered inside the ⋯ dropdown, above the separator before Delete */
46
- headerMenuItems?: React.ReactNode;
47
- /** Rendered next to the user name (e.g. plan badge, role chip) */
48
- headerBadge?: React.ReactNode;
49
- /** Rendered below the avatar row, above the tabs */
50
- headerAfter?: React.ReactNode;
51
- /** Rendered below all tab content */
52
- footer?: React.ReactNode;
53
- }
54
-
55
- export interface ProfileLayoutProps {
56
- onUnauthenticated?: () => void;
57
- title?: string;
58
- enable2FA?: boolean;
59
- enableDeleteAccount?: boolean;
60
- /** Extra tabs appended after built-in Profile / Security tabs */
61
- tabs?: ProfileTab[];
62
- /** Named slots for additional content */
63
- slots?: ProfileSlots;
64
- }
65
-
66
- // ─────────────────────────────────────────────────────────────────────────────
67
- // Header
68
- // ─────────────────────────────────────────────────────────────────────────────
69
-
70
- function ProfileHeader({ slots, enableDeleteAccount }: {
71
- slots?: ProfileSlots;
72
- enableDeleteAccount?: boolean;
73
- }) {
74
- const { labels, onLogout } = useProfileContext();
75
- const { user, logout } = useAuth();
76
- const { deleteAccount } = useDeleteAccount();
77
- const t = useAppT();
78
-
79
- const handleDeleteAccount = useCallback(async () => {
80
- const confirmationWord = t('layouts.profilePage.confirmationWord');
81
- const value = await window.dialog.prompt({
82
- title: t('layouts.profilePage.deleteAccountTitle'),
83
- message: t('layouts.profilePage.deleteAccountDesc'),
84
- placeholder: confirmationWord,
85
- confirmText: t('layouts.profilePage.deleteAccount'),
86
- cancelText: t('layouts.profilePage.cancel'),
87
- variant: 'destructive',
88
- });
89
- if (value?.toUpperCase() !== confirmationWord.toUpperCase()) return;
90
- const result = await deleteAccount();
91
- if (result.success) logout();
92
- }, [t, deleteAccount, logout]);
93
-
94
- if (!user) return null;
95
-
96
- const displayName = user.full_name || user.display_username || user.email;
97
- const memberSince = user.date_joined
98
- ? moment.utc(user.date_joined).local().format('MMMM YYYY')
99
- : null;
100
-
101
- const badge = slots?.headerBadge ?? null;
102
- const menuItems = slots?.headerMenuItems ?? null;
103
- const headerAfter = slots?.headerAfter ?? null;
104
-
105
- return (
106
- <div className="pb-4 md:pb-6 border-b mb-2">
107
- <div className="flex items-center gap-3 md:gap-4">
108
- <AvatarSection />
109
-
110
- <div className="flex-1 min-w-0">
111
- <div className="flex items-center gap-2 flex-wrap">
112
- <h1 className="text-lg md:text-xl font-semibold truncate">{displayName}</h1>
113
- {badge}
114
- </div>
115
- <p className="text-sm text-muted-foreground truncate">{user.email}</p>
116
- {memberSince && (
117
- <p className="text-xs text-muted-foreground/60 mt-0.5">
118
- Member since {memberSince}
119
- </p>
120
- )}
121
- </div>
122
-
123
- <DropdownMenu>
124
- <DropdownMenuTrigger asChild>
125
- <Button variant="ghost" size="icon" className="flex-shrink-0 rounded-full">
126
- <MoreHorizontal className="w-4 h-4" />
127
- </Button>
128
- </DropdownMenuTrigger>
129
- <DropdownMenuContent align="end" className="w-48">
130
- <DropdownMenuItem onClick={onLogout} className="gap-2">
131
- <LogOut className="w-4 h-4" />
132
- {labels.signOut}
133
- </DropdownMenuItem>
134
-
135
- {menuItems && <><DropdownMenuSeparator />{menuItems}</>}
136
-
137
- {enableDeleteAccount && (
138
- <>
139
- <DropdownMenuSeparator />
140
- <DropdownMenuItem
141
- onClick={handleDeleteAccount}
142
- className="gap-2 text-destructive focus:text-destructive"
143
- >
144
- <Trash2 className="w-4 h-4" />
145
- {labels.deleteAccount}
146
- </DropdownMenuItem>
147
- </>
148
- )}
149
- </DropdownMenuContent>
150
- </DropdownMenu>
151
- </div>
152
-
153
- {headerAfter && <div className="mt-4">{headerAfter}</div>}
154
- </div>
155
- );
156
- }
157
-
158
- // ─────────────────────────────────────────────────────────────────────────────
159
- // Built-in tab: Profile
160
- // ─────────────────────────────────────────────────────────────────────────────
161
-
162
- function TabProfile() {
163
- const { labels, onFieldSave } = useProfileContext();
164
- const { user } = useAuth();
165
- if (!user) return null;
166
-
167
- return (
168
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-6 pt-4">
169
- <Section title={labels.personalInfo}>
170
- <EditableField label={labels.firstName} value={user.first_name || ''} placeholder={labels.addFirstName} onSave={(v) => onFieldSave('first_name', v)} />
171
- <EditableField label={labels.lastName} value={user.last_name || ''} placeholder={labels.addLastName} onSave={(v) => onFieldSave('last_name', v)} />
172
- <EditableField label={labels.phone} value={user.phone || ''} placeholder={labels.addPhone} onSave={(v) => onFieldSave('phone', v)} type="phone" />
173
- </Section>
174
-
175
- <Section title={labels.work}>
176
- <EditableField label={labels.company} value={user.company || ''} placeholder={labels.addCompany} onSave={(v) => onFieldSave('company', v)} />
177
- <EditableField label={labels.position} value={user.position || ''} placeholder={labels.addPosition} onSave={(v) => onFieldSave('position', v)} />
178
- </Section>
179
- </div>
180
- );
181
- }
182
-
183
- // ─────────────────────────────────────────────────────────────────────────────
184
- // Built-in tab: Security
185
- // ─────────────────────────────────────────────────────────────────────────────
186
-
187
- function TabSecurity() {
188
- return (
189
- <div className="pt-4 space-y-4">
190
- <TwoFactorSection />
191
- </div>
192
- );
193
- }
194
-
195
- // ─────────────────────────────────────────────────────────────────────────────
196
- // Main content
197
- // ─────────────────────────────────────────────────────────────────────────────
198
-
199
- function ProfileContent({
200
- onUnauthenticated,
201
- enable2FA,
202
- enableDeleteAccount = true,
203
- tabs = [],
204
- slots,
205
- }: ProfileLayoutProps) {
206
- const { labels } = useProfileContext();
207
- const { user, isLoading } = useAuth();
208
-
209
- useEffect(() => {
210
- if (onUnauthenticated && !user && !isLoading) onUnauthenticated();
211
- }, [onUnauthenticated, user, isLoading]);
212
-
213
- if (isLoading) {
214
- return <Preloader variant="fullscreen" text={labels.loading} size="lg" backdrop backdropOpacity={80} />;
215
- }
216
-
217
- if (!user) {
218
- return (
219
- <div className="flex items-center justify-center min-h-screen">
220
- <div className="text-center">
221
- <h1 className="text-2xl font-bold mb-4">{labels.notAuthenticated}</h1>
222
- <p className="text-muted-foreground">{labels.pleaseLogIn}</p>
223
- </div>
224
- </div>
225
- );
226
- }
227
-
228
- // ── Prepare data before render ──────────────────────────────────────────────
229
-
230
- const extraTriggers = tabs.map((tab) => (
231
- <TabsTrigger key={tab.value} value={tab.value}>
232
- {tab.label}
233
- </TabsTrigger>
234
- ));
235
-
236
- const extraPanels = tabs.map((tab) => (
237
- <TabsContent key={tab.value} value={tab.value}>
238
- {tab.content}
239
- </TabsContent>
240
- ));
241
-
242
- const footer = slots?.footer ?? null;
243
-
244
- // ── Render ──────────────────────────────────────────────────────────────────
245
-
246
- return (
247
- <div className="container mx-auto px-4 py-6 md:py-10 max-w-3xl">
248
- <ProfileHeader slots={slots} enableDeleteAccount={enableDeleteAccount} />
249
-
250
- <Tabs defaultValue="profile" className="mt-2">
251
- {/* Underline-style scrollable tabs — mobile friendly */}
252
- <TabsList variant="underline" scrollable>
253
- <TabsTrigger value="profile">{labels.personalInfo}</TabsTrigger>
254
- {enable2FA && <TabsTrigger value="security">{labels.security}</TabsTrigger>}
255
- {extraTriggers}
256
- </TabsList>
257
-
258
- <TabsContent value="profile">
259
- <TabProfile />
260
- </TabsContent>
261
-
262
- {enable2FA && (
263
- <TabsContent value="security">
264
- <TabSecurity />
265
- </TabsContent>
266
- )}
267
-
268
- {extraPanels}
269
- </Tabs>
270
-
271
- {footer && <div className="mt-8">{footer}</div>}
272
- </div>
273
- );
274
- }
275
-
276
- // ─────────────────────────────────────────────────────────────────────────────
277
- // Router + Export
278
- // ─────────────────────────────────────────────────────────────────────────────
279
-
280
- export const ProfileLayout: React.FC<ProfileLayoutProps> = ({ title, ...props }) => (
281
- <ProfileProvider title={title}>
282
- <ProfileContent title={title} {...props} />
283
- </ProfileProvider>
284
- );