@djangocfg/layouts 2.1.606 → 2.1.608

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 (58) hide show
  1. package/README.md +4 -0
  2. package/package.json +12 -12
  3. package/src/components/account/AccountInline.tsx +122 -0
  4. package/src/components/account/AccountMenu.test.tsx +110 -0
  5. package/src/components/account/AccountMenu.tsx +117 -0
  6. package/src/components/account/accountContext.ts +14 -0
  7. package/src/components/account/index.ts +13 -0
  8. package/src/components/account/triggers.tsx +100 -0
  9. package/src/{layouts/SettingsLayout/hooks → components/account}/useAccountMenuItems.tsx +45 -17
  10. package/src/components/account/useAccountModel.tsx +76 -0
  11. package/src/components/errors/AppErrorFallback.test.tsx +33 -0
  12. package/src/components/errors/AppErrorFallback.tsx +42 -0
  13. package/src/components/errors/ErrorBoundary.tsx +4 -41
  14. package/src/components/errors/ErrorLayout.tsx +17 -1
  15. package/src/components/errors/README.md +6 -1
  16. package/src/components/errors/chunkReload.test.ts +89 -0
  17. package/src/components/errors/index.ts +2 -0
  18. package/src/layouts/AppLayout/BaseApp.tsx +10 -55
  19. package/src/layouts/AppLayout/index.ts +1 -0
  20. package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +17 -11
  21. package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +1 -1
  22. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +1 -4
  23. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +17 -3
  24. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.tsx +21 -25
  25. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +2 -2
  26. package/src/layouts/AuthLayout/styles/oauth.css +45 -0
  27. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
  28. package/src/layouts/PrivateLayout/README.md +23 -5
  29. package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
  30. package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +230 -0
  31. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +86 -19
  32. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
  33. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
  34. package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
  35. package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
  36. package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
  37. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
  38. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
  39. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +13 -8
  40. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +2 -2
  41. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
  42. package/src/layouts/PrivateLayout/context.tsx +15 -3
  43. package/src/layouts/PrivateLayout/geometry.test.ts +44 -0
  44. package/src/layouts/PrivateLayout/geometry.ts +40 -16
  45. package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
  46. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +2 -2
  47. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
  48. package/src/layouts/PrivateLayout/index.ts +2 -0
  49. package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
  50. package/src/layouts/PrivateLayout/types.ts +27 -2
  51. package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
  52. package/src/layouts/PublicLayout/components/index.ts +2 -2
  53. package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
  54. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
  55. package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
  56. package/src/shared/ProjectContext.tsx +18 -0
  57. package/src/testing/MockAuthProvider.tsx +5 -2
  58. /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
@@ -0,0 +1,213 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from 'react';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { PrivateLayout } from './PrivateLayout';
7
+
8
+ vi.mock('next/navigation', () => ({ usePathname: () => '/', useRouter: () => ({ push: () => {}, replace: () => {} }), useSearchParams: () => new URLSearchParams() }));
9
+
10
+ // PrivateLayout lazy-loads the settings dialog without its own Suspense; keep the test to the shell.
11
+ vi.mock('../SettingsLayout/SettingsDialog', () => ({ SettingsDialog: () => null }));
12
+ let currentUser: { email: string; display_username: string; avatar: null } | null = { email: 'a@b.c', display_username: 'Mark', avatar: null };
13
+ vi.mock('@djangocfg/api/auth', async () => ({
14
+ AuthContext: (await import('react')).createContext(undefined),
15
+ useAuth: () => ({ user: currentUser }),
16
+ }));
17
+ vi.mock('./hooks/useAuthGuard', () => ({ useAuthGuard: () => ({ isLoading: false, isForbidden: false, loadingText: '' }) }));
18
+
19
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ let phone = true;
22
+ let host: HTMLElement | null = null;
23
+ let root: Root | null = null;
24
+
25
+ beforeAll(() => {
26
+ window.matchMedia = ((query: string) => ({
27
+ // A phone has no precise hover pointer; a desktop does.
28
+ matches: query.includes('hover: hover') ? !phone : phone,
29
+ media: query,
30
+ onchange: null,
31
+ addEventListener: () => {},
32
+ removeEventListener: () => {},
33
+ addListener: () => {},
34
+ removeListener: () => {},
35
+ dispatchEvent: () => false,
36
+ })) as typeof window.matchMedia;
37
+ });
38
+
39
+ afterEach(() => {
40
+ act(() => root?.unmount());
41
+ host?.remove();
42
+ host = root = null;
43
+ phone = true;
44
+ currentUser = { email: 'a@b.c', display_username: 'Mark', avatar: null };
45
+ document.cookie = 'sidebar_state=; path=/; max-age=0';
46
+ });
47
+
48
+ async function mount(props: Partial<React.ComponentProps<typeof PrivateLayout>>) {
49
+ host = document.createElement('div');
50
+ document.body.appendChild(host);
51
+ root = createRoot(host);
52
+ await act(async () =>
53
+ root!.render(
54
+ <PrivateLayout
55
+ requireAuth={false}
56
+ sidebar={{ groups: [{ label: 'A', items: [{ label: 'Home', href: '/' }] }] }}
57
+ header={{ title: 'Acme' }}
58
+ {...props}
59
+ >
60
+ <p>page</p>
61
+ </PrivateLayout>,
62
+ ),
63
+ );
64
+ return document.querySelector('[data-sidebar="topbar"]');
65
+ }
66
+
67
+ describe('PrivateLayout topBar', () => {
68
+ it('title falls back to header.title', async () => {
69
+ expect((await mount({ topBar: { account: false } }))?.textContent).toBe('Acme');
70
+ });
71
+
72
+ it('topBar.title overrides it and topBar.actions render on the right', async () => {
73
+ const bar = (await mount({ topBar: { title: 'Calls', actions: <button>New</button> } }))!;
74
+ expect(bar.textContent).toBe('CallsNewM');
75
+ expect(bar.querySelector('[data-sidebar="topbar-actions"]')!.firstElementChild?.textContent).toBe('New');
76
+ });
77
+
78
+ it('desktop, sidebar pinned open: no bar, the account stays in the sidebar footer', async () => {
79
+ phone = false;
80
+ document.cookie = 'sidebar_state=true; path=/';
81
+ await mount({ topBar: { title: 'Calls', actions: <button>New</button> } });
82
+ expect(document.querySelector('[data-sidebar="topbar"]')).toBeNull();
83
+ expect(document.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
84
+ expect(document.body.textContent).toContain('Mark');
85
+ });
86
+
87
+ it('desktop, sidebar collapsed: the same bar with title, actions and avatar', async () => {
88
+ phone = false;
89
+ document.cookie = 'sidebar_state=false; path=/';
90
+ const bar = (await mount({ topBar: { title: 'Calls', actions: <button>New</button> } }))!;
91
+ expect(bar.children[1].textContent).toBe('Calls');
92
+ expect(bar.querySelector('[data-sidebar="topbar-actions"]')!.children[1].getAttribute('data-sidebar')).toBe('topbar-account');
93
+ });
94
+
95
+ describe('account', () => {
96
+ it('avatar present when signed in', async () => {
97
+ const bar = (await mount({}))!;
98
+ expect(bar.querySelector('[data-sidebar="topbar-account"]')).not.toBeNull();
99
+ });
100
+
101
+ it('no avatar without a session', async () => {
102
+ currentUser = null;
103
+ const bar = (await mount({}))!;
104
+ expect(bar.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
105
+ });
106
+
107
+ it('no avatar with topBar.account === false', async () => {
108
+ const bar = (await mount({ topBar: { account: false } }))!;
109
+ expect(bar.querySelector('[data-sidebar="topbar-account"]')).toBeNull();
110
+ });
111
+
112
+ it('bar avatar and sidebar footer open the same menu items', async () => {
113
+ const labels = async (el: Element) => {
114
+ await act(async () => {
115
+ el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }));
116
+ el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }));
117
+ (el as HTMLElement).click();
118
+ });
119
+ const items = [...document.querySelectorAll('[role="menuitem"]')].map((i) => i.textContent);
120
+ await act(async () => {
121
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
122
+ });
123
+ return items;
124
+ };
125
+ const bar = (await mount({}))!;
126
+ const fromBar = await labels(bar.querySelector('[data-sidebar="topbar-account"]')!);
127
+ expect(fromBar.length).toBeGreaterThan(0);
128
+ await act(async () => root?.unmount());
129
+ host?.remove();
130
+ phone = false;
131
+ document.cookie = 'sidebar_state=true; path=/';
132
+ await mount({});
133
+ const footer = [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Mark'))!;
134
+ expect(await labels(footer)).toEqual(fromBar);
135
+ });
136
+
137
+ async function openDrawer() {
138
+ await act(async () => {
139
+ (document.querySelector('[data-sidebar="trigger"]') as HTMLElement).click();
140
+ });
141
+ return document.querySelector('[data-sidebar="sidebar"][data-mobile="true"]') ?? document.body;
142
+ }
143
+
144
+ it('phone drawer has no footer account while the bar carries the avatar', async () => {
145
+ await mount({});
146
+ const drawer = await openDrawer();
147
+ expect(drawer.textContent).toContain('Home');
148
+ expect(document.body.textContent).not.toContain('Mark');
149
+ });
150
+
151
+ it('phone drawer shows the footer account again with topBar.account === false', async () => {
152
+ await mount({ topBar: { account: false } });
153
+ await openDrawer();
154
+ expect(document.body.textContent).toContain('Mark');
155
+ });
156
+ });
157
+
158
+ describe('toggle', () => {
159
+ it('no tooltip and no native title on desktop or phone; aria-label stays', async () => {
160
+ for (const isPhone of [false, true]) {
161
+ phone = isPhone;
162
+ document.cookie = 'sidebar_state=false; path=/';
163
+ const bar = (await mount({}))!;
164
+ const trigger = bar.querySelector<HTMLElement>('[data-sidebar="trigger"]')!;
165
+ expect(trigger.hasAttribute('data-state')).toBe(false);
166
+ expect(trigger.hasAttribute('title')).toBe(false);
167
+ expect(trigger.getAttribute('aria-label')).toBe('Open sidebar');
168
+ await act(async () => {
169
+ trigger.focus();
170
+ trigger.dispatchEvent(new FocusEvent('focus'));
171
+ });
172
+ expect(document.querySelector('[role="tooltip"]')).toBeNull();
173
+ await act(async () => root?.unmount());
174
+ host?.remove();
175
+ host = root = null;
176
+ }
177
+ });
178
+ });
179
+
180
+ describe('hover-open', () => {
181
+ const enter = (el: Element) =>
182
+ act(async () => {
183
+ const ev = new Event('pointerenter', { bubbles: false }) as Event & { pointerType?: string };
184
+ ev.pointerType = 'mouse';
185
+ el.dispatchEvent(ev);
186
+ // React's onPointerEnter listens to pointerover.
187
+ const over = new Event('pointerover', { bubbles: true }) as Event & { pointerType?: string };
188
+ over.pointerType = 'mouse';
189
+ el.dispatchEvent(over);
190
+ });
191
+
192
+ it('the bar toggle does not open the hover overlay; the left edge zone does', async () => {
193
+ vi.useFakeTimers();
194
+ try {
195
+ phone = false;
196
+ document.cookie = 'sidebar_state=false; path=/';
197
+ const bar = (await mount({}))!;
198
+ const trigger = bar.querySelector('[data-sidebar="trigger"]')!;
199
+ await enter(trigger);
200
+ await act(async () => { vi.advanceTimersByTime(1000); });
201
+ expect(document.querySelector('[data-sidebar="peek"]')).toBeNull();
202
+
203
+ const edge = document.querySelector('[data-sidebar="peek-edge"]')!;
204
+ expect(edge).not.toBeNull();
205
+ await enter(edge);
206
+ await act(async () => { vi.advanceTimersByTime(1000); });
207
+ expect(document.querySelector('[data-sidebar="peek"]')).not.toBeNull();
208
+ } finally {
209
+ vi.useRealTimers();
210
+ }
211
+ });
212
+ });
213
+ });
@@ -21,7 +21,7 @@ interface SidebarItemBase {
21
21
  badge?: string | number;
22
22
  /** Visual style of `badge`: `'count'` (default, neutral pill) or `'pill'` (accent-tinted, e.g. "lite"/"new"). */
23
23
  badgeVariant?: 'count' | 'pill';
24
- /** Collapsed rail: shown in tooltip; defaults to `label`. */
24
+ /** @deprecated No effect: a collapsed sidebar is hidden, not an icon rail, and rows show no tooltip. */
25
25
  tooltip?: string;
26
26
  }
27
27
 
@@ -128,7 +128,7 @@ export interface SidebarConfig<Id extends string = string> {
128
128
  groupLabelStyle?: SidebarGroupLabelStyle;
129
129
  /** Featured CTA tile rendered below all groups, above `menuEnd`. */
130
130
  featured?: SidebarFeaturedConfig;
131
- /** @deprecated No effect: rows are one desktop size and one 44px touch size. */
131
+ /** @deprecated No effect: rows are one desktop size (36px) and one phone size (40px with a 44px hit area). */
132
132
  density?: 'comfortable' | 'default' | 'compact';
133
133
  }
134
134
 
@@ -172,6 +172,13 @@ export interface HeaderConfig {
172
172
  * Takes priority over `brand`, `title`, `brandIcon`, `brandLetter`.
173
173
  */
174
174
  switcher?: SidebarBrandSwitcherConfig;
175
+ /**
176
+ * Where the product name (sidebar header brand, phone drawer header, and the default top bar title) links to,
177
+ * through the router. Resolution: `header.href` > `sidebar.homeHref` > the auth route `routes.defaultCallback` configured on
178
+ * `BaseApp` (`auth.routes`, default `/private`) > `/` (outside the auth provider). A custom `brand` node is rendered as given (never
179
+ * wrapped in a second link); a string `brand` is linkified.
180
+ */
181
+ href?: string;
175
182
  /** Custom header brand node (same idea as PublicNavbar `brand`). */
176
183
  brand?: ReactNode;
177
184
  /** Shown next to the logo when the sidebar is expanded */
@@ -209,12 +216,30 @@ export interface HeaderConfig {
209
216
  // PrivateLayout Props
210
217
  // ============================================================================
211
218
 
219
+ /**
220
+ * Top bar content. One bar at every width, shown whenever the sidebar is not visible (always on a phone; on desktop
221
+ * while the sidebar is collapsed; never while it is docked). The sidebar toggle is always on the left and not configurable.
222
+ */
223
+ export interface PrivateTopBarConfig {
224
+ /** Truncated next to the toggle. Defaults to `header.title` (the product name). */
225
+ title?: ReactNode;
226
+ /** Right end of the bar, vertically centred. Keep it to a couple of compact controls. */
227
+ actions?: ReactNode;
228
+ /**
229
+ * Account avatar at the right end of the bar for a signed-in user (the same menu as the sidebar footer). Default
230
+ * `true`. `false` removes the avatar from the bar and keeps the account in the phone drawer's footer.
231
+ */
232
+ account?: boolean;
233
+ }
234
+
212
235
  export interface PrivateLayoutProps {
213
236
  children: ReactNode;
214
237
  /** Sidebar configuration */
215
238
  sidebar?: SidebarConfig;
216
239
  /** Title + account links (no top navbar — title is used in the sidebar chrome) */
217
240
  header?: HeaderConfig;
241
+ /** Top bar content (title override, right-hand actions, account avatar). The bar shows only while the sidebar is not visible. */
242
+ topBar?: PrivateTopBarConfig;
218
243
  /**
219
244
  * Path for active nav highlighting. With `@djangocfg/nextjs` i18n routing, pass `usePathname()` from
220
245
  * `@djangocfg/nextjs/i18n/navigation` (no `/[locale]` segment). If omitted, uses `next/navigation` (includes locale).
@@ -1,53 +1,16 @@
1
1
  /**
2
- * User Menu Component for Layouts
3
- *
4
- * Flexible user menu component with group-based structure (like footer)
5
- * Uses useAuth() hook for authentication state
2
+ * User menu for the public navbar and its phone drawer. A thin wrapper over the shared account menu
3
+ * (`components/account`): `desktop` is the avatar dropdown, `mobile` the inline list. Props are unchanged.
6
4
  *
7
5
  * @example
8
- * ```tsx
9
- * import { Settings, LogOut, User } from 'lucide-react';
10
- *
11
- * <UserMenu
12
- * groups={[
13
- * {
14
- * title: 'Account',
15
- * items: [
16
- * { label: 'Profile', href: '/profile', icon: Settings },
17
- * { label: 'Dashboard', href: '/dashboard', icon: User }
18
- * ]
19
- * },
20
- * {
21
- * items: [
22
- * { label: 'Sign Out', onClick: () => logout(), icon: LogOut, variant: 'destructive' }
23
- * ]
24
- * }
25
- * ]}
26
- * authPath="/auth"
27
- * />
28
- * ```
6
+ * <UserMenu groups={[{ title: 'Account', items: [{ label: 'Profile', href: '/profile' }] }]} authPath="/auth" />
29
7
  */
30
8
 
31
9
  'use client';
32
10
 
33
- import { ArrowRight, Languages, LogOut } from 'lucide-react';
34
- import { Link } from '@djangocfg/ui-core/components';
35
- import React, { useMemo } from 'react';
36
-
37
- import { useAuth } from '@djangocfg/api/auth';
38
- import { useAppT } from '@djangocfg/i18n';
39
-
40
- import { useLogout } from '../../../hooks';
41
- import {
42
- Button,
43
- LanguageFlag,
44
- MenuBuilder,
45
- } from '@djangocfg/ui-core/components';
46
- import type { MenuItem } from '@djangocfg/ui-core/components';
47
-
48
- import { LOCALE_LABELS } from '../../_components/LocaleSwitcher';
49
- import { UserAvatar } from './UserAvatar';
11
+ import React from 'react';
50
12
 
13
+ import { AccountAvatarTrigger, AccountInline, AccountMenu } from '../../../components/account';
51
14
  import type { UserMenuGroup, UserMenuLocaleConfig } from '../../types';
52
15
 
53
16
  export interface UserMenuProps {
@@ -61,270 +24,34 @@ export interface UserMenuProps {
61
24
  disableAuth?: boolean;
62
25
  /**
63
26
  * Language switching inside this menu (desktop: submenu; mobile: button row).
64
- * Prefer this over nesting `<LocaleSwitcher />` in the same dropdown — nested menus close each other.
27
+ * Prefer this over nesting `<LocaleSwitcher />` in the same dropdown: nested menus close each other.
65
28
  */
66
29
  i18n?: UserMenuLocaleConfig;
67
30
  }
68
31
 
69
- export function UserMenu({
70
- variant = 'desktop',
71
- groups,
72
- authPath,
73
- disableAuth = false,
74
- i18n,
75
- }: UserMenuProps) {
76
- const { user, isAuthenticated, routes } = useAuth();
77
- const resolvedAuthPath = authPath ?? routes.auth;
78
- const handleLogout = useLogout();
32
+ export function UserMenu({ variant = 'desktop', groups, authPath, disableAuth = false, i18n }: UserMenuProps) {
79
33
  const [mounted, setMounted] = React.useState(false);
80
- const t = useAppT();
81
-
82
- const labels = useMemo(() => ({
83
- signIn: t('layouts.profile.login'),
84
- signOut: t('layouts.profile.signOut'),
85
- userMenu: t('layouts.profile.userMenu'),
86
- language: t('layouts.profile.language'),
87
- }), [t]);
88
-
89
- React.useEffect(() => {
90
- setMounted(true);
91
- }, []);
92
-
93
- /** Profile links only; sign out rendered separately so we can insert locale UI between. */
94
- const profileGroups: UserMenuGroup[] = React.useMemo(() => {
95
- if (groups && groups.length > 0) return [...groups];
96
- return [];
97
- }, [groups]);
98
-
99
- const signOutItem = React.useMemo(
100
- () => ({
101
- label: labels.signOut,
102
- onClick: handleLogout,
103
- icon: LogOut,
104
- variant: 'destructive' as const,
105
- }),
106
- [handleLogout, labels.signOut],
107
- );
108
-
109
- /** Prepared locale UI data — avoid `i18n && i18n.locales.length` chains in JSX. */
110
- const localeMenu = React.useMemo(() => {
111
- const codes = i18n?.locales;
112
- if (!i18n || !codes?.length) return null;
113
- return {
114
- codes,
115
- current: i18n.locale,
116
- onChange: i18n.onLocaleChange,
117
- };
118
- }, [i18n]);
119
-
120
- const localeLabel = (code: string) => LOCALE_LABELS[code] || code.toUpperCase();
121
-
122
- if (!mounted) {
123
- return null;
124
- }
125
-
126
- if (!isAuthenticated || !user) {
127
- // Guest user - show sign in button
128
- if (variant === 'mobile') {
129
- return (
130
- <div className="pt-4 border-t border-border/50">
131
- <Link
132
- href={resolvedAuthPath}
133
- className="group flex items-center justify-between rounded-control px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
134
- >
135
- <span>{labels.signIn}</span>
136
- <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
137
- </Link>
138
- </div>
139
- );
140
- }
141
- if (disableAuth) {
142
- return (
143
- <Button variant="default" size="sm" className="h-9 rounded-full px-5" disabled>
144
- {labels.signIn}
145
- </Button>
146
- );
147
- }
148
- return (
149
- <Link href={resolvedAuthPath}>
150
- <Button variant="default" size="sm" className="h-9 rounded-full px-5">
151
- {labels.signIn}
152
- </Button>
153
- </Link>
154
- );
155
- }
156
-
157
- // Authenticated user
158
- const displayName = user.display_username || user.email || 'User';
159
- const userAvatar = user.avatar || '';
34
+ React.useEffect(() => setMounted(true), []);
35
+ if (!mounted) return null;
160
36
 
161
37
  if (variant === 'mobile') {
162
- return (
163
- <div className="space-y-3">
164
- <div className="flex items-center gap-3 px-4 py-3 rounded-[var(--radius-popover)] bg-accent/30">
165
- <UserAvatar src={userAvatar} name={displayName} size="md" />
166
- <div className="flex-1 min-w-0">
167
- <p className="text-sm font-semibold text-foreground truncate">
168
- {displayName}
169
- </p>
170
- <p className="text-xs text-muted-foreground truncate">
171
- {user.email}
172
- </p>
173
- </div>
174
- </div>
175
- <div className="space-y-1">
176
- {profileGroups.map((group, groupIndex) => (
177
- <div key={groupIndex}>
178
- {group.title && (
179
- <div className="px-4 pt-2 pb-1">
180
- <p className="text-[11px] font-normal text-muted-foreground/70">
181
- {group.title}
182
- </p>
183
- </div>
184
- )}
185
- {group.items.map((item, itemIndex) => {
186
- const Icon = item.icon;
187
- const isDestructive = item.variant === 'destructive';
188
- const baseClasses = `flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-sm transition-colors w-full text-left ${
189
- isDestructive
190
- ? 'text-destructive hover:bg-destructive/10'
191
- : 'text-foreground hover:bg-accent hover:text-accent-foreground'
192
- }`;
193
-
194
- if (item.onClick) {
195
- return (
196
- <button
197
- key={itemIndex}
198
- onClick={item.onClick}
199
- className={baseClasses}
200
- >
201
- {Icon && <Icon className="h-4 w-4" />}
202
- {item.label}
203
- </button>
204
- );
205
- }
206
-
207
- if (item.href) {
208
- return (
209
- <Link key={itemIndex} href={item.href} className={baseClasses}>
210
- {Icon && <Icon className="h-4 w-4" />}
211
- {item.label}
212
- </Link>
213
- );
214
- }
215
-
216
- return null;
217
- })}
218
- </div>
219
- ))}
220
- {localeMenu && (
221
- <div className="border-t border-border/50 px-4 pt-3">
222
- <p className="text-[11px] font-normal text-muted-foreground/70">
223
- {labels.language}
224
- </p>
225
- <div className="mt-2 flex flex-wrap gap-2">
226
- {localeMenu.codes.map((code) => {
227
- const active = code === localeMenu.current;
228
- return (
229
- <button
230
- key={code}
231
- type="button"
232
- onClick={() => localeMenu.onChange(code)}
233
- className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm font-medium transition-colors ${
234
- active
235
- ? 'border-primary bg-accent text-foreground'
236
- : 'border-border/60 text-muted-foreground hover:bg-accent hover:text-foreground'
237
- }`}
238
- >
239
- <LanguageFlag code={code} className="h-3 w-4" rounded />
240
- {localeLabel(code)}
241
- </button>
242
- );
243
- })}
244
- </div>
245
- </div>
246
- )}
247
- <div>
248
- <button
249
- type="button"
250
- onClick={signOutItem.onClick}
251
- className="flex w-full items-center gap-3 rounded-sm px-4 py-3 text-left text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
252
- >
253
- <LogOut className="h-4 w-4" />
254
- {signOutItem.label}
255
- </button>
256
- </div>
257
- </div>
258
- </div>
259
- );
38
+ return <AccountInline groups={groups} authPath={authPath} i18n={i18n} />;
260
39
  }
261
40
 
262
- // Desktop variant — declarative menu via the shared MenuBuilder.
263
- const menuItems: MenuItem[] = [
264
- {
265
- kind: 'label',
266
- id: 'identity',
267
- label: (
268
- <div className="flex flex-col space-y-1">
269
- <p className="text-sm font-medium leading-none">{displayName}</p>
270
- <p className="text-xs leading-none text-muted-foreground">{user.email}</p>
271
- </div>
272
- ),
273
- },
274
- // App-provided link groups (each its own section with a trailing separator).
275
- ...profileGroups.map((group, i): MenuItem => ({
276
- kind: 'section',
277
- id: `group-${i}`,
278
- label: group.title,
279
- items: group.items.map((item, j): MenuItem => ({
280
- kind: 'item',
281
- id: item.href ?? `${i}-${j}`,
282
- label: item.label,
283
- icon: item.icon,
284
- href: item.href,
285
- onSelect: item.onClick ? () => item.onClick!() : undefined,
286
- variant: item.variant === 'destructive' ? 'destructive' : 'default',
287
- })),
288
- })),
289
- // Language submenu — radio group of locales (flag + label + checkmark).
290
- ...(localeMenu
291
- ? [{
292
- kind: 'submenu' as const,
293
- id: 'language',
294
- label: labels.language,
295
- icon: Languages,
296
- items: [{
297
- kind: 'radio-group' as const,
298
- id: 'locale',
299
- value: localeMenu.current,
300
- onValueChange: localeMenu.onChange,
301
- options: localeMenu.codes.map((code) => ({
302
- id: code,
303
- value: code,
304
- label: localeLabel(code),
305
- icon: <LanguageFlag code={code} className="h-3 w-4 shrink-0" rounded />,
306
- })),
307
- }],
308
- }]
309
- : []),
310
- { kind: 'separator', id: 'sep-logout' },
311
- {
312
- kind: 'item',
313
- id: 'signout',
314
- label: signOutItem.label,
315
- icon: LogOut,
316
- variant: 'destructive',
317
- onSelect: () => signOutItem.onClick(),
318
- },
319
- ];
320
-
321
41
  return (
322
- <MenuBuilder items={menuItems} align="end" contentClassName="w-56">
323
- <Button variant="ghost" size="icon" className="rounded-full p-0">
324
- <UserAvatar src={userAvatar} name={displayName} size="sm" />
325
- <span className="sr-only">{labels.userMenu}</span>
326
- </Button>
327
- </MenuBuilder>
42
+ <AccountMenu
43
+ groups={groups}
44
+ i18n={i18n}
45
+ authPath={authPath}
46
+ settings={false}
47
+ identity="card"
48
+ signIn={disableAuth ? 'disabled' : 'button'}
49
+ side="bottom"
50
+ align="end"
51
+ sideOffset={4}
52
+ contentClassName="w-56"
53
+ >
54
+ <AccountAvatarTrigger ring />
55
+ </AccountMenu>
328
56
  );
329
57
  }
330
-
@@ -1,4 +1,4 @@
1
1
  export { UserMenu } from './UserMenu';
2
2
  export type { UserMenuProps } from './UserMenu';
3
- export { UserAvatar } from './UserAvatar';
4
- export type { UserAvatarProps } from './UserAvatar';
3
+ export { UserAvatar } from '../../../components/account/UserAvatar';
4
+ export type { UserAvatarProps } from '../../../components/account/UserAvatar';