@djangocfg/layouts 2.1.592 → 2.1.594

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 (38) 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/components/PrivateSidebarAccount.tsx +10 -51
  14. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
  15. package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
  16. package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
  17. package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
  18. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
  19. package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
  20. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
  21. package/src/layouts/PublicLayout/shared/index.ts +1 -1
  22. package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
  23. package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
  24. package/src/layouts/SettingsLayout/SettingsDialog.tsx +20 -5
  25. package/src/layouts/SettingsLayout/SettingsForm.tsx +6 -2
  26. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
  27. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +1 -1
  28. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +1 -1
  29. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +14 -29
  30. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
  31. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  32. package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
  33. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
  34. package/src/layouts/SettingsLayout/index.ts +3 -0
  35. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +1 -1
  36. package/src/layouts/SettingsLayout/store.ts +31 -1
  37. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
  38. package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@djangocfg/ui-core/components';
6
+
7
+ import { TwoFactorSetup } from '../../../../snippets/TwoFactor';
8
+ import { useSettingsCopy } from '../../i18n';
9
+
10
+ export interface TwoFactorSetupDialogProps {
11
+ open: boolean;
12
+ onOpenChange: (open: boolean) => void;
13
+ /** Runs after the user dismisses the backup codes. */
14
+ onComplete: () => void;
15
+ }
16
+
17
+ /**
18
+ * Enabling 2FA is one focused task, so it gets its own dialog over Settings instead of
19
+ * replacing the Security block inline. One exit (close); no "skip" — that belongs to the
20
+ * sign-in flow, not to a deliberate opt-in. Content mounts only while open, so each
21
+ * opening starts a fresh setup and an unfinished one is dropped on close.
22
+ */
23
+ export const TwoFactorSetupDialog: React.FC<TwoFactorSetupDialogProps> = ({ open, onOpenChange, onComplete }) => {
24
+ const copy = useSettingsCopy().security;
25
+
26
+ return (
27
+ <Dialog open={open} onOpenChange={onOpenChange}>
28
+ <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-1.5rem)] max-w-md flex-col items-center overflow-y-auto bg-card p-6">
29
+ <DialogTitle className="sr-only">{copy.setupTitle}</DialogTitle>
30
+ <DialogDescription className="sr-only">{copy.setupHint}</DialogDescription>
31
+ {open && <TwoFactorSetup onComplete={onComplete} />}
32
+ </DialogContent>
33
+ </Dialog>
34
+ );
35
+ };
@@ -10,3 +10,6 @@ export type {
10
10
  UseSettingsSectionsArgs,
11
11
  UseSettingsSectionsResult,
12
12
  } from './useSettingsSections';
13
+
14
+ export { useAccountMenuItems } from './useAccountMenuItems';
15
+ export type { UseAccountMenuItemsOptions } from './useAccountMenuItems';
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { LogOut, Settings as SettingsIcon } from 'lucide-react';
4
+ import { useMemo } from 'react';
5
+
6
+ import { useAuth } from '@djangocfg/api/auth';
7
+ import { useAppT } from '@djangocfg/i18n';
8
+ import type { MenuItem } from '@djangocfg/ui-core/components';
9
+
10
+ import { useLogout } from '../../../hooks';
11
+ import { useSettingsDialogStore } from '../store';
12
+
13
+ export interface UseAccountMenuItemsOptions {
14
+ /** Items placed between the settings shortcuts and Sign out (language, app links). */
15
+ extra?: MenuItem[];
16
+ /** Label above the items; defaults to the signed-in user's email. */
17
+ label?: string | null;
18
+ }
19
+
20
+ /**
21
+ * The user menu every host shares: email, Settings, a shortcut per Settings section (built-in and the host's),
22
+ * then Sign out. Feed it to `MenuBuilder` with any trigger, so a site's own chrome gets the same menu as
23
+ * `PrivateLayout`'s sidebar footer instead of a hand-rolled copy.
24
+ */
25
+ export function useAccountMenuItems({ extra, label }: UseAccountMenuItemsOptions = {}): MenuItem[] {
26
+ const { user } = useAuth();
27
+ const t = useAppT();
28
+ const handleLogout = useLogout();
29
+ const sections = useSettingsDialogStore((s) => s.sections);
30
+ const headerLabel = label === undefined ? (user?.email ?? null) : label;
31
+ const signOutLabel = t('layouts.profile.signOut');
32
+ const settingsLabel = t('layouts.profile.settings') || 'Settings';
33
+
34
+ return useMemo<MenuItem[]>(() => {
35
+ const items: MenuItem[] = [];
36
+ if (headerLabel) items.push({ kind: 'label', id: 'email', label: headerLabel });
37
+
38
+ items.push({
39
+ kind: 'item',
40
+ id: 'settings',
41
+ label: settingsLabel,
42
+ icon: SettingsIcon,
43
+ shortcut: '⇧⌘,',
44
+ onSelect: () => useSettingsDialogStore.getState().open(),
45
+ });
46
+ for (const section of sections) {
47
+ items.push({
48
+ kind: 'item',
49
+ id: `settings-${section.id}`,
50
+ label: section.label,
51
+ icon: section.icon,
52
+ onSelect: () => useSettingsDialogStore.getState().open(section.id),
53
+ });
54
+ }
55
+
56
+ if (extra?.length) items.push(...extra);
57
+
58
+ items.push({ kind: 'separator', id: 'sep-logout' });
59
+ items.push({
60
+ kind: 'item',
61
+ id: 'logout',
62
+ label: signOutLabel,
63
+ icon: LogOut,
64
+ variant: 'destructive',
65
+ onSelect: () => handleLogout(),
66
+ });
67
+ return items;
68
+ }, [headerLabel, settingsLabel, sections, extra, signOutLabel, handleLogout]);
69
+ }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { useCallback, useMemo } from 'react';
17
17
 
18
- import { useSettingsDialogStore } from '../store';
18
+ import { useSettingsDialogStore, type SettingsMenuSection } from '../store';
19
19
 
20
20
  export interface UseSettingsDialogReturn {
21
21
  /** Whether the dialog is currently open. */
@@ -30,6 +30,8 @@ export interface UseSettingsDialogReturn {
30
30
  toggle: () => void;
31
31
  /** Switch the active section. */
32
32
  setSection: (sectionId: string) => void;
33
+ /** The visible sections of the mounted dialog (built-in and the host's), in rail order: for menu shortcuts, `open(id)`. */
34
+ sections: SettingsMenuSection[];
33
35
  }
34
36
 
35
37
  export function useSettingsDialog(): UseSettingsDialogReturn {
@@ -39,6 +41,7 @@ export function useSettingsDialog(): UseSettingsDialogReturn {
39
41
  const storeClose = useSettingsDialogStore((s) => s.close);
40
42
  const storeToggle = useSettingsDialogStore((s) => s.toggle);
41
43
  const storeSetSection = useSettingsDialogStore((s) => s.setSection);
44
+ const sections = useSettingsDialogStore((s) => s.sections);
42
45
 
43
46
  const open = useCallback((sectionId?: string) => storeOpen(sectionId), [storeOpen]);
44
47
  const close = useCallback(() => storeClose(), [storeClose]);
@@ -46,7 +49,7 @@ export function useSettingsDialog(): UseSettingsDialogReturn {
46
49
  const setSection = useCallback((id: string) => storeSetSection(id), [storeSetSection]);
47
50
 
48
51
  return useMemo(
49
- () => ({ isOpen, activeSection, open, close, toggle, setSection }),
50
- [isOpen, activeSection, open, close, toggle, setSection],
52
+ () => ({ isOpen, activeSection, open, close, toggle, setSection, sections }),
53
+ [isOpen, activeSection, open, close, toggle, setSection, sections],
51
54
  );
52
55
  }
@@ -24,6 +24,8 @@
24
24
  // Top-level components
25
25
  export { SettingsDialog } from './SettingsDialog';
26
26
  export { SettingsForm } from './SettingsForm';
27
+ export { AccountMenuButton } from './components/AccountMenuButton';
28
+ export type { AccountMenuButtonProps } from './components/AccountMenuButton';
27
29
 
28
30
  // Store + public hooks
29
31
  export { useSettingsDialogStore } from './store';
@@ -31,6 +33,7 @@ export {
31
33
  useSettingsDialog,
32
34
  useSettingsUrl,
33
35
  useSettingsSections,
36
+ useAccountMenuItems,
34
37
  } from './hooks';
35
38
  export type {
36
39
  UseSettingsDialogReturn,
@@ -8,7 +8,7 @@ import { ThemeSegmented } from '@djangocfg/ui-core/theme';
8
8
 
9
9
  import { LocaleSwitcherDropdown } from '../../_components/locale-switcher';
10
10
  import { useSettingsCopy } from '../i18n';
11
- import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
11
+ import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
12
12
 
13
13
  /**
14
14
  * Preferences as native SettingRows (Language + Time zone + Theme), so they share the exact
@@ -15,7 +15,17 @@
15
15
 
16
16
  import { create } from 'zustand';
17
17
 
18
+ /** What a menu needs to link to a section; the dialog registers these (host sections are only known to it). */
19
+ export interface SettingsMenuSection {
20
+ id: string;
21
+ label: React.ReactNode;
22
+ icon?: React.ComponentType<{ className?: string }>;
23
+ }
24
+
18
25
  interface SettingsDialogState {
26
+ /** The visible sections of the mounted dialog, in rail order, for menu shortcuts. */
27
+ sections: SettingsMenuSection[];
28
+ setSections: (sections: SettingsMenuSection[]) => void;
19
29
  /** Whether the dialog is visible. */
20
30
  isOpen: boolean;
21
31
  /**
@@ -33,7 +43,27 @@ interface SettingsDialogState {
33
43
  setSection: (sectionId: string) => void;
34
44
  }
35
45
 
36
- export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
46
+ /**
47
+ * Same shortcuts, whatever the identity of the array or of the host's JSX labels. The dialog re-registers on
48
+ * every render of a host that passes `sections` inline; storing a fresh array each time would re-render every
49
+ * menu that reads `sections`, and when that menu shares a parent with the dialog the two feed each other.
50
+ * String labels are compared by value; a node label is trusted to follow its id.
51
+ */
52
+ function sameMenuSections(a: SettingsMenuSection[], b: SettingsMenuSection[]): boolean {
53
+ if (a.length !== b.length) return false;
54
+ return a.every((x, i) => {
55
+ const y = b[i]!;
56
+ const sameLabel = typeof x.label === 'string' || typeof y.label === 'string' ? x.label === y.label : true;
57
+ return x.id === y.id && x.icon === y.icon && sameLabel;
58
+ });
59
+ }
60
+
61
+ export const useSettingsDialogStore = create<SettingsDialogState>((set, get) => ({
62
+ sections: [],
63
+ setSections: (sections) => {
64
+ if (sameMenuSections(get().sections, sections)) return;
65
+ set({ sections });
66
+ },
37
67
  isOpen: false,
38
68
  activeSection: null,
39
69
  open: (sectionId) =>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react';
4
4
 
5
- import { useLayoutI18n } from '../../AppLayout/LayoutI18nProvider';
5
+ import { useLayoutI18n } from '../../../shared/LayoutI18nProvider';
6
6
 
7
7
  import { LocaleSwitcherDialog } from './LocaleSwitcherDialog';
8
8
  import { LocaleSwitcherDropdown } from './LocaleSwitcherDropdown';
@@ -15,7 +15,7 @@
15
15
 
16
16
  import * as React from 'react';
17
17
 
18
- import type { I18nLayoutConfig } from '../types/layout.types';
18
+ import type { I18nLayoutConfig } from '../layouts/types/layout.types';
19
19
 
20
20
  const LayoutI18nContext = React.createContext<I18nLayoutConfig | null>(null);
21
21