@djangocfg/layouts 2.1.607 → 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.
- package/README.md +4 -0
- package/package.json +12 -12
- package/src/components/account/AccountInline.tsx +122 -0
- package/src/components/account/AccountMenu.test.tsx +110 -0
- package/src/components/account/AccountMenu.tsx +117 -0
- package/src/components/account/accountContext.ts +14 -0
- package/src/components/account/index.ts +13 -0
- package/src/components/account/triggers.tsx +100 -0
- package/src/{layouts/SettingsLayout/hooks → components/account}/useAccountMenuItems.tsx +45 -17
- package/src/components/account/useAccountModel.tsx +76 -0
- package/src/components/errors/AppErrorFallback.test.tsx +33 -0
- package/src/components/errors/AppErrorFallback.tsx +42 -0
- package/src/components/errors/ErrorBoundary.tsx +4 -41
- package/src/components/errors/ErrorLayout.tsx +17 -1
- package/src/components/errors/README.md +6 -1
- package/src/components/errors/chunkReload.test.ts +89 -0
- package/src/components/errors/index.ts +2 -0
- package/src/layouts/AppLayout/BaseApp.tsx +10 -55
- package/src/layouts/AppLayout/index.ts +1 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
- package/src/layouts/PrivateLayout/README.md +22 -6
- package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +176 -11
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +85 -25
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
- package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
- package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -5
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/context.tsx +15 -3
- package/src/layouts/PrivateLayout/geometry.test.ts +19 -7
- package/src/layouts/PrivateLayout/geometry.ts +30 -10
- package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +2 -2
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/index.ts +2 -0
- package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
- package/src/layouts/PrivateLayout/types.ts +27 -2
- package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
- package/src/layouts/PublicLayout/components/index.ts +2 -2
- package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
- package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
- package/src/shared/ProjectContext.tsx +18 -0
- package/src/testing/MockAuthProvider.tsx +5 -2
- /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
|
@@ -1,53 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* User
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 '
|
|
4
|
-
export type { UserAvatarProps } from '
|
|
3
|
+
export { UserAvatar } from '../../../components/account/UserAvatar';
|
|
4
|
+
export type { UserAvatarProps } from '../../../components/account/UserAvatar';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useEffect, useRef, useState, type RefObject } from 'react';
|
|
4
4
|
|
|
5
5
|
import { useScroll } from '@djangocfg/ui-core/hooks';
|
|
6
6
|
|
|
@@ -11,6 +11,10 @@ export interface UseNavbarScrollOptions {
|
|
|
11
11
|
transparent?: boolean;
|
|
12
12
|
/** scrollY threshold for hide + transparent triggers. @default 40 */
|
|
13
13
|
transparentThreshold?: number;
|
|
14
|
+
/** Scroll container to follow instead of the window (shells where the document never scrolls). */
|
|
15
|
+
target?: RefObject<HTMLElement | null>;
|
|
16
|
+
/** When false the hide still happens under `prefers-reduced-motion` (the caller drops the animation). @default true */
|
|
17
|
+
respectReducedMotion?: boolean;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export interface UseNavbarScrollReturn {
|
|
@@ -51,12 +55,12 @@ const DIRECTION_COMMIT_PX = 8;
|
|
|
51
55
|
* renders.
|
|
52
56
|
*/
|
|
53
57
|
export function useNavbarScroll(options: UseNavbarScrollOptions = {}): UseNavbarScrollReturn {
|
|
54
|
-
const { hideNavOnScroll = false, transparent = false, transparentThreshold = 40 } = options;
|
|
58
|
+
const { hideNavOnScroll = false, transparent = false, transparentThreshold = 40, target, respectReducedMotion = true } = options;
|
|
55
59
|
|
|
56
60
|
const active = hideNavOnScroll || transparent;
|
|
57
61
|
// Subscribe to the shared store only when the feature is on. The `y` this
|
|
58
62
|
// returns is already rAF-coalesced; we run cheap arithmetic on each change.
|
|
59
|
-
const { y } = useScroll(active ?
|
|
63
|
+
const { y } = useScroll(active ? target : NEVER_TARGET);
|
|
60
64
|
|
|
61
65
|
const [hidden, setHidden] = useState(false);
|
|
62
66
|
const [scrolled, setScrolled] = useState(false);
|
|
@@ -84,13 +88,15 @@ export function useNavbarScroll(options: UseNavbarScrollOptions = {}): UseNavbar
|
|
|
84
88
|
const prefersReducedMotion =
|
|
85
89
|
typeof window.matchMedia === 'function' &&
|
|
86
90
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
87
|
-
const hideEnabled = hideNavOnScroll && !prefersReducedMotion;
|
|
91
|
+
const hideEnabled = hideNavOnScroll && !(respectReducedMotion && prefersReducedMotion);
|
|
88
92
|
|
|
89
93
|
// iOS Safari rubber-banding reports negative scrollY at the top and values
|
|
90
94
|
// past maxScroll at the bottom while the page bounces back; feeding those
|
|
91
95
|
// into the integrator makes the bar twitch on every overscroll pull.
|
|
92
|
-
const
|
|
93
|
-
const maxScroll =
|
|
96
|
+
const el = target?.current;
|
|
97
|
+
const maxScroll = el
|
|
98
|
+
? Math.max(0, el.scrollHeight - el.clientHeight)
|
|
99
|
+
: Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
94
100
|
const clampedY = Math.min(Math.max(0, y), maxScroll);
|
|
95
101
|
|
|
96
102
|
if (transparent) {
|
|
@@ -134,7 +140,7 @@ export function useNavbarScroll(options: UseNavbarScrollOptions = {}): UseNavbar
|
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
lastY.current = clampedY;
|
|
137
|
-
}, [y, active, hideNavOnScroll, transparent, transparentThreshold]);
|
|
143
|
+
}, [y, active, hideNavOnScroll, transparent, transparentThreshold, target, respectReducedMotion]);
|
|
138
144
|
|
|
139
145
|
return { hidden, scrolled };
|
|
140
146
|
}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { ChevronDown } from 'lucide-react';
|
|
4
3
|
import React from 'react';
|
|
5
4
|
|
|
6
|
-
import { useAuth } from '@djangocfg/api/auth';
|
|
7
|
-
import { Avatar, AvatarFallback, AvatarImage, Button, MenuBuilder } from '@djangocfg/ui-core/components';
|
|
8
5
|
import type { MenuItem } from '@djangocfg/ui-core/components';
|
|
9
6
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
10
7
|
|
|
11
|
-
import {
|
|
8
|
+
import { AccountAvatarTrigger, AccountFooterTrigger, AccountMenu } from '../../../components/account';
|
|
12
9
|
|
|
13
10
|
export interface AccountMenuButtonProps {
|
|
14
11
|
/** `row`: avatar, name and chevron (a sidebar footer; the menu opens up). `avatar`: the avatar alone (a header; the menu opens down). */
|
|
@@ -26,67 +23,29 @@ export interface AccountMenuButtonProps {
|
|
|
26
23
|
}
|
|
27
24
|
|
|
28
25
|
/**
|
|
29
|
-
* The ready user menu:
|
|
30
|
-
*
|
|
31
|
-
* `PrivateLayout`'s sidebar footer builds on the same `useAccountMenuItems`.
|
|
26
|
+
* The ready user menu: the shared `AccountMenu` with a row or avatar trigger. Drop it into any chrome; renders
|
|
27
|
+
* nothing for a signed-out visitor.
|
|
32
28
|
*/
|
|
33
|
-
export const AccountMenuButton: React.FC<AccountMenuButtonProps> = ({
|
|
34
|
-
variant = 'row',
|
|
35
|
-
extra,
|
|
36
|
-
plan,
|
|
37
|
-
className,
|
|
38
|
-
classNames,
|
|
39
|
-
}) => {
|
|
40
|
-
const { user } = useAuth();
|
|
41
|
-
const items = useAccountMenuItems({ extra });
|
|
42
|
-
const [open, setOpen] = React.useState(false);
|
|
43
|
-
|
|
44
|
-
if (!user) return null;
|
|
45
|
-
|
|
46
|
-
const name = user.display_username || user.email || 'User';
|
|
47
|
-
const initial = (name.charAt(0) || '?').toUpperCase();
|
|
29
|
+
export const AccountMenuButton: React.FC<AccountMenuButtonProps> = ({ variant = 'row', extra, plan, className, classNames }) => {
|
|
48
30
|
const isRow = variant === 'row';
|
|
49
|
-
|
|
50
31
|
return (
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
32
|
+
<AccountMenu
|
|
33
|
+
plan={plan}
|
|
34
|
+
extra={extra}
|
|
35
|
+
signIn="none"
|
|
55
36
|
side={isRow ? 'top' : 'bottom'}
|
|
56
37
|
align={isRow ? 'start' : 'end'}
|
|
57
|
-
sideOffset={8}
|
|
58
|
-
contentClassName="w-60 p-1.5"
|
|
59
38
|
>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<AvatarImage src={user.avatar ?? ''} alt={name} />
|
|
72
|
-
<AvatarFallback className="text-xs font-medium">{initial}</AvatarFallback>
|
|
73
|
-
</Avatar>
|
|
74
|
-
{isRow && (
|
|
75
|
-
<>
|
|
76
|
-
<span className={cn('flex min-w-0 items-baseline gap-1', classNames?.name)}>
|
|
77
|
-
<span className="truncate text-sm">{name}</span>
|
|
78
|
-
{plan ? <span className="shrink-0 text-xs text-muted-foreground">· {plan}</span> : null}
|
|
79
|
-
</span>
|
|
80
|
-
<ChevronDown
|
|
81
|
-
className={cn(
|
|
82
|
-
'ml-auto size-4 shrink-0 opacity-50 transition-transform duration-150 group-data-[state=open]:rotate-180',
|
|
83
|
-
classNames?.chevron,
|
|
84
|
-
)}
|
|
85
|
-
aria-hidden
|
|
86
|
-
/>
|
|
87
|
-
</>
|
|
88
|
-
)}
|
|
89
|
-
</Button>
|
|
90
|
-
</MenuBuilder>
|
|
39
|
+
{isRow ? (
|
|
40
|
+
<AccountFooterTrigger
|
|
41
|
+
className={cn('h-9', className)}
|
|
42
|
+
avatarClassName={classNames?.avatar}
|
|
43
|
+
nameClassName={classNames?.name}
|
|
44
|
+
chevronClassName={classNames?.chevron}
|
|
45
|
+
/>
|
|
46
|
+
) : (
|
|
47
|
+
<AccountAvatarTrigger className={className} />
|
|
48
|
+
)}
|
|
49
|
+
</AccountMenu>
|
|
91
50
|
);
|
|
92
51
|
};
|
|
@@ -11,8 +11,8 @@ export type {
|
|
|
11
11
|
UseSettingsSectionsResult,
|
|
12
12
|
} from './useSettingsSections';
|
|
13
13
|
|
|
14
|
-
export { useAccountMenuItems } from '
|
|
15
|
-
export type { UseAccountMenuItemsOptions } from '
|
|
14
|
+
export { useAccountMenuItems } from '../../../components/account/useAccountMenuItems';
|
|
15
|
+
export type { UseAccountMenuItemsOptions } from '../../../components/account/useAccountMenuItems';
|
|
16
16
|
|
|
17
17
|
export { useSettingsSectionState } from './useSettingsSectionState';
|
|
18
18
|
export type { SettingsSectionState } from './useSettingsSectionState';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project name `BaseApp` already receives (`project` prop), exposed to descendants so shells can use it as the
|
|
3
|
+
* default product name. Outside `BaseApp` (stories, tests) the hook returns `undefined` and never throws.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
const ProjectNameContext = React.createContext<string | undefined>(undefined);
|
|
11
|
+
|
|
12
|
+
export function ProjectNameProvider({ value, children }: { value?: string; children: React.ReactNode }) {
|
|
13
|
+
return <ProjectNameContext.Provider value={value?.trim() || undefined}>{children}</ProjectNameContext.Provider>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useProjectName(): string | undefined {
|
|
17
|
+
return React.useContext(ProjectNameContext);
|
|
18
|
+
}
|
|
@@ -20,6 +20,8 @@ export interface MockAuthProviderProps {
|
|
|
20
20
|
isAuthenticated?: boolean;
|
|
21
21
|
/** Override `isAdminUser`. Defaults to `false`. */
|
|
22
22
|
isAdminUser?: boolean;
|
|
23
|
+
/** Override resolved auth routes (e.g. `defaultCallback`, the private landing page). */
|
|
24
|
+
routes?: Partial<{ auth: string; defaultCallback: string; defaultAuthCallback: string }>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const noopAsync = async () => {};
|
|
@@ -54,6 +56,7 @@ export const MockAuthProvider: React.FC<MockAuthProviderProps> = ({
|
|
|
54
56
|
isLoading = false,
|
|
55
57
|
isAuthenticated,
|
|
56
58
|
isAdminUser = false,
|
|
59
|
+
routes,
|
|
57
60
|
}) => {
|
|
58
61
|
const [user, setUser] = useState<UserProfile | null>(initialUser);
|
|
59
62
|
|
|
@@ -62,7 +65,7 @@ export const MockAuthProvider: React.FC<MockAuthProviderProps> = ({
|
|
|
62
65
|
isLoading,
|
|
63
66
|
isAuthenticated: isAuthenticated ?? Boolean(user),
|
|
64
67
|
isAdminUser,
|
|
65
|
-
routes: { auth: '/auth', defaultCallback: '/', defaultAuthCallback: '/auth' },
|
|
68
|
+
routes: { auth: '/auth', defaultCallback: '/', defaultAuthCallback: '/auth', ...routes },
|
|
66
69
|
|
|
67
70
|
loadCurrentProfile: noopAsync,
|
|
68
71
|
checkAuthAndRedirect: noopAsync,
|
|
@@ -93,7 +96,7 @@ export const MockAuthProvider: React.FC<MockAuthProviderProps> = ({
|
|
|
93
96
|
return next;
|
|
94
97
|
},
|
|
95
98
|
uploadAvatar: async () => user ?? buildMockUser(),
|
|
96
|
-
}), [user, isLoading, isAuthenticated, isAdminUser]);
|
|
99
|
+
}), [user, isLoading, isAuthenticated, isAdminUser, routes]);
|
|
97
100
|
|
|
98
101
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
99
102
|
};
|
|
File without changes
|