@djangocfg/layouts 2.1.591 → 2.1.593
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -12
- package/src/components/errors/ErrorLayout.tsx +1 -164
- package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
- package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
- package/src/components/errors/errorGlyphs.tsx +167 -0
- package/src/layouts/AppLayout/BaseApp.tsx +1 -1
- package/src/layouts/AppLayout/index.ts +2 -2
- package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
- package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
- package/src/layouts/AuthLayout/layoutContext.ts +25 -0
- package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
- package/src/layouts/PrivateLayout/README.md +20 -12
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +41 -112
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
- package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
- package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
- package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
- package/src/layouts/PrivateLayout/components/index.ts +0 -4
- package/src/layouts/PrivateLayout/context.tsx +10 -115
- package/src/layouts/PrivateLayout/geometry.ts +17 -0
- package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
- package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
- package/src/layouts/PrivateLayout/types.ts +13 -20
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
- package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
- package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
- package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
- package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
- package/src/layouts/PublicLayout/shared/index.ts +1 -1
- package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
- package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
- package/src/layouts/SettingsLayout/README.md +23 -10
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +27 -11
- package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
- package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
- package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
- package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
- package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
- package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
- package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
- package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
- package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +63 -131
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
- package/src/layouts/SettingsLayout/components/index.ts +0 -1
- package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
- package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
- package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
- package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
- package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
- package/src/layouts/SettingsLayout/index.ts +3 -1
- package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
- package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
- package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
- package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +13 -5
- package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
- package/src/layouts/SettingsLayout/sections/index.ts +2 -2
- package/src/layouts/SettingsLayout/store.ts +12 -0
- package/src/layouts/SettingsLayout/types.ts +12 -4
- package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
- package/src/layouts/types/layout.types.ts +5 -6
- package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
- package/src/styles/dashboard.css +10 -0
- package/src/layouts/PrivateLayout/density.ts +0 -48
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
- package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
- package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
'use client';
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { ChevronDown, Languages } from 'lucide-react';
|
|
15
15
|
import { Link } from '@djangocfg/ui-core/components';
|
|
16
16
|
import React, { memo } from 'react';
|
|
17
17
|
|
|
@@ -29,12 +29,12 @@ import type { MenuItem } from '@djangocfg/ui-core/components';
|
|
|
29
29
|
import { cn, isDev } from '@djangocfg/ui-core/lib';
|
|
30
30
|
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
31
31
|
|
|
32
|
-
import {
|
|
33
|
-
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
32
|
+
import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
|
|
34
33
|
import { LucideIcon as LucideIconRender } from '../../../components';
|
|
35
34
|
import { getLocaleMeta } from '../../_components/locale-switcher/localeMeta';
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
35
|
+
import { usePeekPin } from '../hooks';
|
|
36
|
+
import { usePrivateLayoutContext } from '../context';
|
|
37
|
+
import { useAccountMenuItems } from '../../SettingsLayout/hooks';
|
|
38
38
|
|
|
39
39
|
import type { HeaderConfig } from '../types';
|
|
40
40
|
|
|
@@ -52,14 +52,12 @@ interface AccountView {
|
|
|
52
52
|
|
|
53
53
|
function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
54
54
|
const { user } = useAuth();
|
|
55
|
-
const handleLogout = useLogout();
|
|
56
55
|
const t = useAppT();
|
|
57
56
|
const layoutI18n = useLayoutI18nOptional();
|
|
58
57
|
const { setOpen: setSidebarOpen } = useSidebar();
|
|
59
|
-
const {
|
|
58
|
+
const { isMobile } = usePrivateLayoutContext();
|
|
60
59
|
const [isAccountMenuOpen, setIsAccountMenuOpen] = React.useState(false);
|
|
61
|
-
|
|
62
|
-
const signOutLabel = t('layouts.profile.signOut');
|
|
60
|
+
usePeekPin(isAccountMenuOpen);
|
|
63
61
|
|
|
64
62
|
const accountLinks = React.useMemo(() => {
|
|
65
63
|
if (!header?.groups?.length) return [];
|
|
@@ -91,43 +89,15 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
91
89
|
};
|
|
92
90
|
}, [user, header?.userPlan]);
|
|
93
91
|
|
|
94
|
-
const onTriggerInteract = React.useCallback(() => {
|
|
95
|
-
if (content.isAccountCompact) setSidebarOpen(true);
|
|
96
|
-
}, [content.isAccountCompact, setSidebarOpen]);
|
|
97
|
-
|
|
98
92
|
const onSecondaryExpand = React.useCallback(() => {
|
|
99
93
|
setSidebarOpen(true);
|
|
100
94
|
}, [setSidebarOpen]);
|
|
101
95
|
|
|
102
96
|
const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
|
|
103
97
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
const openSettingsDialog = React.useCallback(() => {
|
|
107
|
-
useSettingsDialogStore.getState().open();
|
|
108
|
-
}, []);
|
|
109
|
-
|
|
110
|
-
// ── Declarative account menu (Claude-style) — fed to MenuBuilder ──
|
|
111
|
-
const menuItems = React.useMemo<MenuItem[]>(() => {
|
|
98
|
+
// Language submenu and the app's account links sit between the shared Settings shortcuts and Sign out.
|
|
99
|
+
const extraItems = React.useMemo<MenuItem[]>(() => {
|
|
112
100
|
const items: MenuItem[] = [];
|
|
113
|
-
|
|
114
|
-
// Email header.
|
|
115
|
-
if (headerLabelText) {
|
|
116
|
-
items.push({ kind: 'label', id: 'email', label: headerLabelText });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Settings → opens the global SettingsDialog (⌘, like Claude).
|
|
120
|
-
items.push({
|
|
121
|
-
kind: 'item',
|
|
122
|
-
id: 'settings',
|
|
123
|
-
label: t('layouts.profile.settings') || 'Settings',
|
|
124
|
-
icon: SettingsIcon,
|
|
125
|
-
shortcut: '⇧⌘,',
|
|
126
|
-
onSelect: () => useSettingsDialogStore.getState().open(),
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// Language → submenu with a radio group of locales (flag + native name,
|
|
130
|
-
// checkmark on the active one). Only when an i18n layout context is present.
|
|
131
101
|
if (layoutI18n && layoutI18n.locales.length > 1) {
|
|
132
102
|
items.push({
|
|
133
103
|
kind: 'submenu',
|
|
@@ -153,36 +123,17 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
153
123
|
],
|
|
154
124
|
});
|
|
155
125
|
}
|
|
156
|
-
|
|
157
|
-
// App-provided account links (from header.groups).
|
|
158
126
|
if (accountLinks.length > 0) {
|
|
159
127
|
items.push({ kind: 'separator', id: 'sep-links' });
|
|
160
128
|
for (const link of accountLinks) {
|
|
161
|
-
items.push({
|
|
162
|
-
kind: 'item',
|
|
163
|
-
id: link.href!,
|
|
164
|
-
label: link.label,
|
|
165
|
-
icon: link.icon,
|
|
166
|
-
href: link.href,
|
|
167
|
-
});
|
|
129
|
+
items.push({ kind: 'item', id: link.href!, label: link.label, icon: link.icon, href: link.href });
|
|
168
130
|
}
|
|
169
131
|
}
|
|
170
|
-
|
|
171
|
-
// Log out (destructive). Let the menu close normally (no preventDefault) —
|
|
172
|
-
// the logout confirm dialog should appear over a CLOSED menu, not under a
|
|
173
|
-
// still-open one.
|
|
174
|
-
items.push({ kind: 'separator', id: 'sep-logout' });
|
|
175
|
-
items.push({
|
|
176
|
-
kind: 'item',
|
|
177
|
-
id: 'logout',
|
|
178
|
-
label: signOutLabel,
|
|
179
|
-
icon: LogOut,
|
|
180
|
-
variant: 'destructive',
|
|
181
|
-
onSelect: () => handleLogout(),
|
|
182
|
-
});
|
|
183
|
-
|
|
184
132
|
return items;
|
|
185
|
-
}, [
|
|
133
|
+
}, [t, layoutI18n, accountLinks]);
|
|
134
|
+
|
|
135
|
+
// Shared with every host: email, Settings, section shortcuts, Sign out (logout confirm opens over a CLOSED menu).
|
|
136
|
+
const menuItems = useAccountMenuItems({ extra: extraItems, label: headerLabelText });
|
|
186
137
|
|
|
187
138
|
// Hide entirely in production when there's no user (auth still loading or
|
|
188
139
|
// /me failed and the parent guard hasn't redirected yet). In dev keep a
|
|
@@ -201,80 +152,57 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
201
152
|
const secondary = header?.footerSecondaryAction;
|
|
202
153
|
|
|
203
154
|
const triggerClassName = cn(
|
|
204
|
-
'group h-
|
|
205
|
-
|
|
155
|
+
'group h-8 min-w-0 max-w-full justify-start gap-2 rounded-lg px-2 text-left font-normal select-none',
|
|
156
|
+
// Full-width row unless a secondary action shares the footer.
|
|
157
|
+
!secondary && 'w-full',
|
|
158
|
+
'hover:bg-sidebar-accent/70 hover:text-sidebar-foreground',
|
|
206
159
|
// No focus ring on the menu trigger — the open menu is the affordance.
|
|
207
160
|
'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
|
208
|
-
|
|
161
|
+
isMobile && 'h-11',
|
|
209
162
|
);
|
|
210
163
|
|
|
211
|
-
const secondaryButton = secondary
|
|
164
|
+
const secondaryButton = secondary ? (
|
|
212
165
|
<SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
|
|
213
166
|
) : null;
|
|
214
167
|
|
|
215
|
-
const dropdownContentClass =
|
|
216
|
-
'p-1.5',
|
|
217
|
-
content.isAccountCompact ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
|
|
218
|
-
);
|
|
168
|
+
const dropdownContentClass = 'w-60 p-1.5';
|
|
219
169
|
// Always open UPWARD over the avatar (Claude pattern). The footer sits at the
|
|
220
170
|
// bottom of the rail in both expanded and collapsed states, so `top` reads
|
|
221
171
|
// correctly either way (vs. `right`, which floated off to the side).
|
|
222
172
|
const dropdownSide = 'top' as const;
|
|
223
|
-
const avatarClass = cn(
|
|
224
|
-
'h-9 w-9 shrink-0 border border-transparent transition-colors',
|
|
225
|
-
'group-hover:border-sidebar-border/70',
|
|
226
|
-
);
|
|
173
|
+
const avatarClass = cn('shrink-0', isMobile ? 'size-7' : 'size-6');
|
|
227
174
|
|
|
228
|
-
|
|
175
|
+
// Like Claude: "Name · Plan ⌄". The email lives in the menu header.
|
|
176
|
+
const expandedMeta = (
|
|
229
177
|
<>
|
|
230
|
-
<span className="flex min-w-0
|
|
231
|
-
<span className="truncate text-
|
|
232
|
-
{account.displayName}
|
|
233
|
-
</span>
|
|
178
|
+
<span className="flex min-w-0 items-baseline gap-1 text-left">
|
|
179
|
+
<span className="truncate text-base text-sidebar-foreground">{account.displayName}</span>
|
|
234
180
|
{account.plan ? (
|
|
235
|
-
<span className="
|
|
236
|
-
{account.plan}
|
|
237
|
-
</span>
|
|
238
|
-
) : null}
|
|
239
|
-
</span>
|
|
240
|
-
<span className="flex shrink-0 items-center gap-1.5">
|
|
241
|
-
{secondaryButton}
|
|
242
|
-
{header?.accountAction !== 'dialog' ? (
|
|
243
|
-
<ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
|
|
181
|
+
<span className="shrink-0 text-xs text-sidebar-foreground/50">· {account.plan}</span>
|
|
244
182
|
) : null}
|
|
245
183
|
</span>
|
|
184
|
+
<ChevronDown
|
|
185
|
+
className={cn(
|
|
186
|
+
'size-4 shrink-0 text-sidebar-foreground/40 transition-transform duration-150 group-data-[state=open]:rotate-180',
|
|
187
|
+
!secondary && 'ml-auto',
|
|
188
|
+
)}
|
|
189
|
+
aria-hidden
|
|
190
|
+
/>
|
|
246
191
|
</>
|
|
247
192
|
);
|
|
248
193
|
|
|
249
194
|
const triggerButton = (
|
|
250
|
-
<Button
|
|
251
|
-
type="button"
|
|
252
|
-
variant="ghost"
|
|
253
|
-
aria-label={content.isAccountCompact ? account.displayName : undefined}
|
|
254
|
-
className={triggerClassName}
|
|
255
|
-
onClick={header?.accountAction === 'dialog' ? openSettingsDialog : onTriggerInteract}
|
|
256
|
-
>
|
|
195
|
+
<Button type="button" variant="ghost" className={triggerClassName}>
|
|
257
196
|
<Avatar className={avatarClass}>
|
|
258
197
|
<AvatarImage src={account.avatarUrl} alt={account.displayName} />
|
|
259
|
-
<AvatarFallback className="text-
|
|
198
|
+
<AvatarFallback className="text-xs font-medium">{userInitial}</AvatarFallback>
|
|
260
199
|
</Avatar>
|
|
261
200
|
{expandedMeta}
|
|
262
201
|
</Button>
|
|
263
202
|
);
|
|
264
203
|
|
|
265
|
-
const wrapperClass =
|
|
266
|
-
'w-full min-w-0'
|
|
267
|
-
content.isAccountCompact ? 'px-0 pb-0' : 'px-2 pb-2',
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
// Dialog mode: simple button that opens the global SettingsDialog
|
|
271
|
-
if (header?.accountAction === 'dialog') {
|
|
272
|
-
return (
|
|
273
|
-
<div className={wrapperClass}>
|
|
274
|
-
{triggerButton}
|
|
275
|
-
</div>
|
|
276
|
-
);
|
|
277
|
-
}
|
|
204
|
+
const wrapperClass =
|
|
205
|
+
'flex w-full min-w-0 shrink-0 items-center justify-between gap-1 border-t border-sidebar-border/60 p-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]';
|
|
278
206
|
|
|
279
207
|
return (
|
|
280
208
|
<div className={wrapperClass}>
|
|
@@ -289,6 +217,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
289
217
|
>
|
|
290
218
|
{triggerButton}
|
|
291
219
|
</MenuBuilder>
|
|
220
|
+
{secondaryButton}
|
|
292
221
|
</div>
|
|
293
222
|
);
|
|
294
223
|
}
|
|
@@ -322,8 +251,8 @@ function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
|
|
|
322
251
|
const inner = (
|
|
323
252
|
<span
|
|
324
253
|
className={cn(
|
|
325
|
-
'relative inline-flex
|
|
326
|
-
'
|
|
254
|
+
'relative inline-flex size-7 shrink-0 items-center justify-center rounded-md',
|
|
255
|
+
'bg-transparent text-sidebar-foreground/60',
|
|
327
256
|
'transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
|
|
328
257
|
)}
|
|
329
258
|
>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar's content: brand header, scrollable nav, account footer.
|
|
3
|
+
* Rendered by the docked sidebar, the peek panel and the mobile drawer alike.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { useMemo } from 'react';
|
|
9
|
+
|
|
10
|
+
import { usePrivateLayoutContext } from '../context';
|
|
11
|
+
import { PrivateSidebarAccount } from './PrivateSidebarAccount';
|
|
12
|
+
import { SidebarBrand } from './SidebarBrand';
|
|
13
|
+
import { SidebarNavGroup } from './SidebarNavGroup';
|
|
14
|
+
import { SidebarSlots } from './SidebarSlots';
|
|
15
|
+
|
|
16
|
+
export function SidebarBody() {
|
|
17
|
+
const { sidebar, header } = usePrivateLayoutContext();
|
|
18
|
+
|
|
19
|
+
const groups = useMemo(
|
|
20
|
+
() =>
|
|
21
|
+
(sidebar?.groups ?? []).map((group) =>
|
|
22
|
+
group.dynamic && group.items.length === 0 ? null : (
|
|
23
|
+
<SidebarNavGroup
|
|
24
|
+
key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
|
|
25
|
+
group={group}
|
|
26
|
+
/>
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
[sidebar?.groups],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<SidebarBrand />
|
|
35
|
+
<div
|
|
36
|
+
data-sidebar="content"
|
|
37
|
+
className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden py-2"
|
|
38
|
+
>
|
|
39
|
+
<SidebarSlots />
|
|
40
|
+
{groups}
|
|
41
|
+
</div>
|
|
42
|
+
<PrivateSidebarAccount header={header} />
|
|
43
|
+
</>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -1,179 +1,91 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sidebar
|
|
3
|
-
*
|
|
4
|
-
* Renders the sidebar header with brand mark + title.
|
|
5
|
-
* Three modes: expanded (desktop), collapsed rail (desktop), mobile drawer.
|
|
2
|
+
* Sidebar header: brand (or workspace switcher) on the left, one control on the
|
|
3
|
+
* right — collapse on desktop, close on the mobile drawer.
|
|
6
4
|
*/
|
|
7
5
|
|
|
8
6
|
'use client';
|
|
9
7
|
|
|
10
|
-
import React, { memo
|
|
8
|
+
import React, { memo } from 'react';
|
|
9
|
+
import { X } from 'lucide-react';
|
|
11
10
|
|
|
12
|
-
import {
|
|
11
|
+
import { Button, Link, SidebarTrigger, useSidebar } from '@djangocfg/ui-core/components';
|
|
13
12
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
13
|
|
|
15
14
|
import { LucideIcon } from '../../../components';
|
|
16
15
|
import { usePrivateLayoutContext } from '../context';
|
|
17
|
-
import { useShellVisualState } from '../hooks';
|
|
18
16
|
import { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
|
|
19
17
|
|
|
20
|
-
const
|
|
18
|
+
const BRAND_LINK =
|
|
19
|
+
'flex min-w-0 items-center gap-2.5 rounded-lg py-1 outline-none ring-sidebar-ring focus-visible:ring-2';
|
|
21
20
|
|
|
22
21
|
function SidebarBrandRaw() {
|
|
23
|
-
const { header, homeHref, brandTitle, brandMonogram, isMobile } =
|
|
24
|
-
|
|
25
|
-
const { content } = useShellVisualState();
|
|
26
|
-
|
|
27
|
-
const brandMark = useMemo(
|
|
28
|
-
() =>
|
|
29
|
-
header?.brandIcon ? (
|
|
30
|
-
<LucideIcon
|
|
31
|
-
icon={header.brandIcon}
|
|
32
|
-
className="h-4 w-4 text-sidebar-primary-foreground"
|
|
33
|
-
/>
|
|
34
|
-
) : (
|
|
35
|
-
<span className="text-[11px] font-bold leading-none tracking-tight text-sidebar-primary-foreground">
|
|
36
|
-
{brandMonogram}
|
|
37
|
-
</span>
|
|
38
|
-
),
|
|
39
|
-
[header?.brandIcon, brandMonogram],
|
|
40
|
-
);
|
|
22
|
+
const { header, homeHref, brandTitle, brandMonogram, isMobile } = usePrivateLayoutContext();
|
|
23
|
+
const { setOpenMobile } = useSidebar();
|
|
41
24
|
|
|
42
25
|
const customBrand = header?.brand;
|
|
43
26
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
</Link>
|
|
60
|
-
) : (
|
|
61
|
-
customBrand
|
|
62
|
-
)
|
|
63
|
-
) : (
|
|
64
|
-
<Link
|
|
65
|
-
href={homeHref}
|
|
66
|
-
className="flex min-w-0 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
67
|
-
>
|
|
68
|
-
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">
|
|
69
|
-
{brandMark}
|
|
70
|
-
</div>
|
|
71
|
-
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
|
|
72
|
-
{brandTitle}
|
|
73
|
-
</span>
|
|
74
|
-
</Link>
|
|
27
|
+
const brand =
|
|
28
|
+
customBrand != null && customBrand !== false ? (
|
|
29
|
+
typeof customBrand === 'string' ? (
|
|
30
|
+
<Link href={homeHref} className={BRAND_LINK}>
|
|
31
|
+
<span className="truncate text-base font-medium tracking-tight">{customBrand}</span>
|
|
32
|
+
</Link>
|
|
33
|
+
) : (
|
|
34
|
+
customBrand
|
|
35
|
+
)
|
|
36
|
+
) : (
|
|
37
|
+
<Link href={homeHref} className={BRAND_LINK}>
|
|
38
|
+
<span
|
|
39
|
+
className={cn(
|
|
40
|
+
'flex shrink-0 items-center justify-center rounded-lg bg-sidebar-primary',
|
|
41
|
+
isMobile ? 'size-9' : 'size-7',
|
|
75
42
|
)}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
),
|
|
80
|
-
[headerRowClass, customBrand, homeHref, brandMark, brandTitle, isMobile],
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const collapsedHeader = useMemo(
|
|
84
|
-
() => (
|
|
85
|
-
<div className="group/collapsed-brand flex justify-center py-1">
|
|
86
|
-
<div className="relative h-7 w-7">
|
|
87
|
-
<Link
|
|
88
|
-
href={homeHref}
|
|
89
|
-
className="absolute inset-0 flex items-center justify-center rounded-md bg-sidebar-primary outline-none ring-sidebar-ring focus-visible:ring-2 transition-opacity group-hover/collapsed-brand:opacity-0"
|
|
90
|
-
aria-label={brandTitle}
|
|
91
|
-
>
|
|
92
|
-
{brandMark}
|
|
93
|
-
</Link>
|
|
94
|
-
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/collapsed-brand:opacity-100">
|
|
95
|
-
<SidebarTrigger aria-label="Expand sidebar" />
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
),
|
|
100
|
-
[homeHref, brandTitle, brandMark],
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
/** Mobile drawer: menu open/close only from the main column trigger — no duplicate toggle in the sheet. */
|
|
104
|
-
const mobileHeader = useMemo(
|
|
105
|
-
() => (
|
|
106
|
-
<div className="flex items-center gap-3">
|
|
107
|
-
<div className="min-w-0 flex-1">
|
|
108
|
-
{customBrand != null && customBrand !== false ? (
|
|
109
|
-
typeof customBrand === 'string' ? (
|
|
110
|
-
<Link
|
|
111
|
-
href={homeHref}
|
|
112
|
-
className="flex min-w-0 items-center gap-3 rounded-md py-1 outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
113
|
-
>
|
|
114
|
-
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
|
|
115
|
-
{customBrand}
|
|
116
|
-
</span>
|
|
117
|
-
</Link>
|
|
118
|
-
) : (
|
|
119
|
-
customBrand
|
|
120
|
-
)
|
|
43
|
+
>
|
|
44
|
+
{header?.brandIcon ? (
|
|
45
|
+
<LucideIcon icon={header.brandIcon} className="size-4 text-sidebar-primary-foreground" />
|
|
121
46
|
) : (
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
>
|
|
126
|
-
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">
|
|
127
|
-
{brandMark}
|
|
128
|
-
</div>
|
|
129
|
-
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">
|
|
130
|
-
{brandTitle}
|
|
131
|
-
</span>
|
|
132
|
-
</Link>
|
|
47
|
+
<span className="text-[11px] font-bold leading-none text-sidebar-primary-foreground">
|
|
48
|
+
{brandMonogram}
|
|
49
|
+
</span>
|
|
133
50
|
)}
|
|
134
|
-
</
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
51
|
+
</span>
|
|
52
|
+
<span className={cn('truncate font-medium tracking-tight', isMobile ? 'text-lg' : 'text-base')}>
|
|
53
|
+
{brandTitle}
|
|
54
|
+
</span>
|
|
55
|
+
</Link>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const trailing = isMobile ? (
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
variant="ghost"
|
|
62
|
+
size="icon"
|
|
63
|
+
className="size-11 shrink-0 text-sidebar-foreground/70"
|
|
64
|
+
aria-label="Close menu"
|
|
65
|
+
onClick={() => setOpenMobile(false)}
|
|
66
|
+
>
|
|
67
|
+
<X className="size-5" />
|
|
68
|
+
</Button>
|
|
69
|
+
) : header?.switcher ? null : (
|
|
70
|
+
<SidebarTrigger className="shrink-0 text-sidebar-foreground/60 hover:text-sidebar-foreground" />
|
|
138
71
|
);
|
|
139
72
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
config={header.switcher}
|
|
146
|
-
showCollapseTrigger={content.showLabels && !isMobile}
|
|
147
|
-
/>
|
|
148
|
-
</div>
|
|
149
|
-
)
|
|
150
|
-
: null;
|
|
151
|
-
|
|
152
|
-
const sidebarHeaderContent = switcherContent
|
|
153
|
-
?? (isMobile
|
|
154
|
-
? mobileHeader
|
|
155
|
-
: content.showLabels
|
|
156
|
-
? expandedHeader
|
|
157
|
-
: collapsedHeader);
|
|
158
|
-
|
|
159
|
-
const sidebarHeaderClass = useMemo(
|
|
160
|
-
() =>
|
|
161
|
-
cn(
|
|
162
|
-
'pb-2',
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
data-sidebar="header"
|
|
76
|
+
className={cn(
|
|
77
|
+
'flex shrink-0 items-center gap-2',
|
|
163
78
|
isMobile
|
|
164
|
-
? 'pb-
|
|
165
|
-
: 'pt-3
|
|
166
|
-
)
|
|
167
|
-
|
|
79
|
+
? 'px-4 pb-2 pt-[max(0.5rem,env(safe-area-inset-top,0px))]'
|
|
80
|
+
: 'px-3 pb-1 pt-3',
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
<div className="min-w-0 flex-1">
|
|
84
|
+
{header?.switcher ? <SidebarBrandSwitcher config={header.switcher} showCollapseTrigger={!isMobile} /> : brand}
|
|
85
|
+
</div>
|
|
86
|
+
{trailing}
|
|
87
|
+
</div>
|
|
168
88
|
);
|
|
169
|
-
|
|
170
|
-
return <SidebarHeader className={sidebarHeaderClass}>{sidebarHeaderContent}</SidebarHeader>;
|
|
171
89
|
}
|
|
172
90
|
|
|
173
|
-
/**
|
|
174
|
-
* Memoised brand header. Re-renders only when context values change
|
|
175
|
-
* (header config, brand title, mobile state, showLabels). The three
|
|
176
|
-
* visual modes (expanded / collapsed rail / mobile) are pre-built with
|
|
177
|
-
* useMemo so the JSX tree is stable across renders.
|
|
178
|
-
*/
|
|
179
91
|
export const SidebarBrand = memo(SidebarBrandRaw);
|
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
* Dropdown for switching workspaces / accounts / projects.
|
|
5
5
|
* Renders in the sidebar header area, replacing the static brand block.
|
|
6
6
|
*
|
|
7
|
-
* Collapsed rail: shows only the active item's avatar/monogram (no dropdown trigger).
|
|
8
|
-
* Hover-expanded / mobile: shows full trigger + dropdown.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
'use client';
|
|
@@ -27,7 +25,7 @@ import {
|
|
|
27
25
|
import { Link } from '@djangocfg/ui-core/components';
|
|
28
26
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
29
27
|
|
|
30
|
-
import {
|
|
28
|
+
import { usePeekPin } from '../hooks';
|
|
31
29
|
import type { SidebarBrandSwitcherConfig, SidebarBrandSwitcherItem } from '../types';
|
|
32
30
|
|
|
33
31
|
interface SidebarBrandSwitcherProps {
|
|
@@ -37,8 +35,8 @@ interface SidebarBrandSwitcherProps {
|
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
function SidebarBrandSwitcherRaw({ config, showCollapseTrigger }: SidebarBrandSwitcherProps) {
|
|
40
|
-
const { content } = useShellVisualState();
|
|
41
38
|
const [open, setOpen] = React.useState(false);
|
|
39
|
+
usePeekPin(open);
|
|
42
40
|
|
|
43
41
|
const activeItem = useMemo(
|
|
44
42
|
() => config.items.find((i) => i.active) ?? config.items[0] ?? null,
|
|
@@ -66,32 +64,6 @@ function SidebarBrandSwitcherRaw({ config, showCollapseTrigger }: SidebarBrandSw
|
|
|
66
64
|
</Avatar>
|
|
67
65
|
);
|
|
68
66
|
|
|
69
|
-
// Collapsed rail — avatar with trigger on hover
|
|
70
|
-
if (!content.showLabels) {
|
|
71
|
-
return (
|
|
72
|
-
<div className="group/collapsed-switcher flex justify-center py-1">
|
|
73
|
-
<div className="relative h-7 w-7">
|
|
74
|
-
{activeItem.href ? (
|
|
75
|
-
<Link
|
|
76
|
-
href={activeItem.href}
|
|
77
|
-
className="absolute inset-0 flex items-center justify-center rounded-md outline-none ring-sidebar-ring focus-visible:ring-2 transition-opacity group-hover/collapsed-switcher:opacity-0"
|
|
78
|
-
aria-label={activeItem.label}
|
|
79
|
-
>
|
|
80
|
-
{activeAvatar}
|
|
81
|
-
</Link>
|
|
82
|
-
) : (
|
|
83
|
-
<div className="absolute inset-0 flex items-center justify-center transition-opacity group-hover/collapsed-switcher:opacity-0">
|
|
84
|
-
{activeAvatar}
|
|
85
|
-
</div>
|
|
86
|
-
)}
|
|
87
|
-
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/collapsed-switcher:opacity-100">
|
|
88
|
-
<SidebarTrigger aria-label="Expand sidebar" />
|
|
89
|
-
</div>
|
|
90
|
-
</div>
|
|
91
|
-
</div>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
67
|
return (
|
|
96
68
|
<div className="flex items-center gap-1">
|
|
97
69
|
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|