@djangocfg/layouts 2.1.251 → 2.1.254
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 +18 -18
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +44 -66
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +25 -12
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +200 -120
- package/src/layouts/PrivateLayout/components/index.ts +0 -2
- package/src/layouts/_components/PrivateSidebarAccount.tsx +204 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/sidebarNav.ts +16 -0
- package/src/layouts/PrivateLayout/components/PrivateHeader.tsx +0 -72
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.254",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -74,14 +74,14 @@
|
|
|
74
74
|
"check": "tsc --noEmit"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@djangocfg/api": "^2.1.
|
|
78
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
79
|
-
"@djangocfg/i18n": "^2.1.
|
|
80
|
-
"@djangocfg/monitor": "^2.1.
|
|
81
|
-
"@djangocfg/debuger": "^2.1.
|
|
82
|
-
"@djangocfg/ui-core": "^2.1.
|
|
83
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
84
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
77
|
+
"@djangocfg/api": "^2.1.254",
|
|
78
|
+
"@djangocfg/centrifugo": "^2.1.254",
|
|
79
|
+
"@djangocfg/i18n": "^2.1.254",
|
|
80
|
+
"@djangocfg/monitor": "^2.1.254",
|
|
81
|
+
"@djangocfg/debuger": "^2.1.254",
|
|
82
|
+
"@djangocfg/ui-core": "^2.1.254",
|
|
83
|
+
"@djangocfg/ui-nextjs": "^2.1.254",
|
|
84
|
+
"@djangocfg/ui-tools": "^2.1.254",
|
|
85
85
|
"@hookform/resolvers": "^5.2.2",
|
|
86
86
|
"consola": "^3.4.2",
|
|
87
87
|
"lucide-react": "^0.545.0",
|
|
@@ -109,15 +109,15 @@
|
|
|
109
109
|
"uuid": "^11.1.0"
|
|
110
110
|
},
|
|
111
111
|
"devDependencies": {
|
|
112
|
-
"@djangocfg/api": "^2.1.
|
|
113
|
-
"@djangocfg/i18n": "^2.1.
|
|
114
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
115
|
-
"@djangocfg/monitor": "^2.1.
|
|
116
|
-
"@djangocfg/debuger": "^2.1.
|
|
117
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
118
|
-
"@djangocfg/ui-core": "^2.1.
|
|
119
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
120
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
112
|
+
"@djangocfg/api": "^2.1.254",
|
|
113
|
+
"@djangocfg/i18n": "^2.1.254",
|
|
114
|
+
"@djangocfg/centrifugo": "^2.1.254",
|
|
115
|
+
"@djangocfg/monitor": "^2.1.254",
|
|
116
|
+
"@djangocfg/debuger": "^2.1.254",
|
|
117
|
+
"@djangocfg/typescript-config": "^2.1.254",
|
|
118
|
+
"@djangocfg/ui-core": "^2.1.254",
|
|
119
|
+
"@djangocfg/ui-nextjs": "^2.1.254",
|
|
120
|
+
"@djangocfg/ui-tools": "^2.1.254",
|
|
121
121
|
"@types/node": "^24.7.2",
|
|
122
122
|
"@types/react": "^19.1.0",
|
|
123
123
|
"@types/react-dom": "^19.1.0",
|
|
@@ -1,47 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Private Layout
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Features:
|
|
8
|
-
* - Responsive sidebar with mobile burger menu
|
|
9
|
-
* - Keyboard shortcut (Ctrl/Cmd + B) to toggle sidebar
|
|
10
|
-
* - Header with sidebar trigger and user menu
|
|
11
|
-
* - Configurable content padding
|
|
12
|
-
* - NO SSR - renders only on client to avoid hydration mismatch
|
|
13
|
-
*
|
|
14
|
-
* @example
|
|
15
|
-
* ```tsx
|
|
16
|
-
* import { PrivateLayout } from '@djangocfg/layouts';
|
|
17
|
-
*
|
|
18
|
-
* <PrivateLayout
|
|
19
|
-
* sidebar={{
|
|
20
|
-
* items: [
|
|
21
|
-
* { label: 'Dashboard', href: '/dashboard', icon: 'LayoutDashboard' },
|
|
22
|
-
* { label: 'Profile', href: '/profile', icon: 'User' }
|
|
23
|
-
* ]
|
|
24
|
-
* }}
|
|
25
|
-
* header={{
|
|
26
|
-
* title: 'Dashboard',
|
|
27
|
-
* groups: [
|
|
28
|
-
* {
|
|
29
|
-
* title: 'Account',
|
|
30
|
-
* items: [
|
|
31
|
-
* { label: 'Profile', href: '/profile' },
|
|
32
|
-
* { label: 'Settings', href: '/settings' }
|
|
33
|
-
* ]
|
|
34
|
-
* }
|
|
35
|
-
* ],
|
|
36
|
-
* authPath: '/auth'
|
|
37
|
-
* }}
|
|
38
|
-
* >
|
|
39
|
-
* {children}
|
|
40
|
-
* </PrivateLayout>
|
|
41
|
-
*
|
|
42
|
-
* Note: User data (name, email, avatar) is automatically loaded from useAuth() context
|
|
43
|
-
* Keyboard shortcut: Ctrl/Cmd + B to toggle sidebar
|
|
44
|
-
* ```
|
|
4
|
+
* Authenticated shell: collapsible sidebar (icon rail vs expanded) + scrollable content.
|
|
5
|
+
* Toggle lives in the sidebar header on desktop; on narrow viewports a `SidebarTrigger` sits in `PrivateContent` (opens the mobile Sheet).
|
|
6
|
+
* Ctrl/Cmd + B still toggles the sidebar width.
|
|
45
7
|
*/
|
|
46
8
|
|
|
47
9
|
'use client';
|
|
@@ -50,13 +12,12 @@ import React, { ReactNode, useEffect, useState } from 'react';
|
|
|
50
12
|
import { useRouter } from 'next/navigation';
|
|
51
13
|
|
|
52
14
|
import { useAuth } from '@djangocfg/api/auth';
|
|
53
|
-
import {
|
|
54
|
-
Preloader
|
|
55
|
-
} from '@djangocfg/ui-core/components';
|
|
15
|
+
import { Preloader } from '@djangocfg/ui-core/components';
|
|
56
16
|
import { SidebarInset, SidebarProvider } from '@djangocfg/ui-nextjs/components';
|
|
57
17
|
|
|
18
|
+
import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
|
|
58
19
|
import { UserMenuConfig } from '../types';
|
|
59
|
-
import { PrivateContent,
|
|
20
|
+
import { PrivateContent, PrivateSidebar } from './components';
|
|
60
21
|
|
|
61
22
|
import type { LucideIcon as LucideIconType } from 'lucide-react';
|
|
62
23
|
|
|
@@ -65,6 +26,8 @@ export interface SidebarItem {
|
|
|
65
26
|
href: string;
|
|
66
27
|
icon?: string | LucideIconType;
|
|
67
28
|
badge?: string | number;
|
|
29
|
+
/** Collapsed rail: shown in tooltip; defaults to `label`. */
|
|
30
|
+
tooltip?: string;
|
|
68
31
|
}
|
|
69
32
|
|
|
70
33
|
export interface SidebarGroupConfig {
|
|
@@ -81,26 +44,50 @@ export interface SidebarConfig {
|
|
|
81
44
|
groups: SidebarGroupConfig[];
|
|
82
45
|
/** Home link href */
|
|
83
46
|
homeHref?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Custom block inside the scrollable nav column, **above** all `groups`
|
|
49
|
+
* (below the brand header, same horizontal padding as nav).
|
|
50
|
+
*/
|
|
51
|
+
menuStart?: ReactNode;
|
|
52
|
+
/**
|
|
53
|
+
* Custom block inside the scrollable nav column, **below** all `groups`
|
|
54
|
+
* (above `footer` + account block).
|
|
55
|
+
*/
|
|
56
|
+
menuEnd?: ReactNode;
|
|
84
57
|
/** Custom footer component rendered at the bottom of the sidebar */
|
|
85
58
|
footer?: ReactNode;
|
|
86
59
|
}
|
|
87
60
|
|
|
88
61
|
export interface HeaderConfig {
|
|
62
|
+
/** Shown next to the logo when the sidebar is expanded */
|
|
89
63
|
title?: string;
|
|
90
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Brand mark in the sidebar header (Lucide icon name or component).
|
|
66
|
+
* If omitted, a single-letter monogram from `brandLetter` / `title` is shown.
|
|
67
|
+
*/
|
|
68
|
+
brandIcon?: string | LucideIconType;
|
|
69
|
+
/**
|
|
70
|
+
* Monogram when `brandIcon` is not set (one visible character).
|
|
71
|
+
* Defaults to the first letter of `title`, uppercased.
|
|
72
|
+
*/
|
|
73
|
+
brandLetter?: string;
|
|
74
|
+
/** User menu groups (account panel in the sidebar footer) */
|
|
91
75
|
groups?: UserMenuConfig['groups'];
|
|
92
76
|
/** Auth page path (for sign in button) */
|
|
93
77
|
authPath?: string;
|
|
94
78
|
}
|
|
95
79
|
|
|
96
|
-
import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
|
|
97
|
-
|
|
98
80
|
export interface PrivateLayoutProps {
|
|
99
81
|
children: ReactNode;
|
|
100
82
|
/** Sidebar configuration */
|
|
101
83
|
sidebar?: SidebarConfig;
|
|
102
|
-
/**
|
|
84
|
+
/** Title + account links (no top navbar — title is used in the sidebar chrome) */
|
|
103
85
|
header?: HeaderConfig;
|
|
86
|
+
/**
|
|
87
|
+
* Path for active nav highlighting. With `@djangocfg/nextjs` i18n routing, pass `usePathname()` from
|
|
88
|
+
* `@djangocfg/nextjs/i18n/navigation` (no `/[locale]` segment). If omitted, uses `next/navigation` (includes locale).
|
|
89
|
+
*/
|
|
90
|
+
pathname?: string;
|
|
104
91
|
/** Content padding */
|
|
105
92
|
contentPadding?: 'none' | 'default';
|
|
106
93
|
/** i18n configuration for locale switching */
|
|
@@ -111,6 +98,7 @@ export function PrivateLayout({
|
|
|
111
98
|
children,
|
|
112
99
|
sidebar,
|
|
113
100
|
header,
|
|
101
|
+
pathname,
|
|
114
102
|
contentPadding = 'default',
|
|
115
103
|
i18n,
|
|
116
104
|
}: PrivateLayoutProps) {
|
|
@@ -120,23 +108,18 @@ export function PrivateLayout({
|
|
|
120
108
|
|
|
121
109
|
useEffect(() => {
|
|
122
110
|
if (!isLoading && !isAuthenticated && !isRedirecting) {
|
|
123
|
-
// Save current URL (including query params) before redirecting to auth
|
|
124
111
|
const currentUrl = window.location.pathname + window.location.search;
|
|
125
112
|
saveRedirectUrl(currentUrl);
|
|
126
|
-
|
|
127
|
-
// Set redirecting state to prevent flicker
|
|
128
113
|
setIsRedirecting(true);
|
|
129
114
|
router.push(header?.authPath || '/auth');
|
|
130
115
|
}
|
|
131
116
|
}, [isAuthenticated, isLoading, isRedirecting, router, saveRedirectUrl, header?.authPath]);
|
|
132
117
|
|
|
133
|
-
// Show loading state while auth is being checked or redirecting
|
|
134
|
-
// Note: SSR hydration is handled by ClientOnly wrapper in AppLayout
|
|
135
118
|
if (isLoading || isRedirecting || !isAuthenticated) {
|
|
136
119
|
return (
|
|
137
120
|
<Preloader
|
|
138
121
|
variant="fullscreen"
|
|
139
|
-
text={isRedirecting ?
|
|
122
|
+
text={isRedirecting ? 'Redirecting to login...' : 'Authenticating...'}
|
|
140
123
|
size="lg"
|
|
141
124
|
backdrop={true}
|
|
142
125
|
backdropOpacity={80}
|
|
@@ -146,20 +129,15 @@ export function PrivateLayout({
|
|
|
146
129
|
|
|
147
130
|
return (
|
|
148
131
|
<SidebarProvider defaultOpen={true}>
|
|
149
|
-
{
|
|
150
|
-
|
|
132
|
+
{sidebar && (
|
|
133
|
+
<PrivateSidebar sidebar={sidebar} header={header} i18n={i18n} pathname={pathname} />
|
|
134
|
+
)}
|
|
151
135
|
|
|
152
|
-
{/* Main content area */}
|
|
153
136
|
<SidebarInset className="flex flex-col">
|
|
154
|
-
{
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
)}
|
|
158
|
-
|
|
159
|
-
{/* Page content */}
|
|
160
|
-
<PrivateContent padding={contentPadding}>{children}</PrivateContent>
|
|
137
|
+
<PrivateContent padding={contentPadding} hasSidebar={Boolean(sidebar)}>
|
|
138
|
+
{children}
|
|
139
|
+
</PrivateContent>
|
|
161
140
|
</SidebarInset>
|
|
162
141
|
</SidebarProvider>
|
|
163
142
|
);
|
|
164
143
|
}
|
|
165
|
-
|
|
@@ -1,33 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Private
|
|
3
|
-
*
|
|
4
|
-
* Main content wrapper for PrivateLayout
|
|
2
|
+
* Private layout main column — optional mobile menu strip (`SidebarTrigger`) + scrollable area.
|
|
3
|
+
* On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `Sheet` from ui-nextjs sidebar.
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
'use client';
|
|
8
7
|
|
|
9
8
|
import React, { ReactNode } from 'react';
|
|
10
9
|
|
|
10
|
+
import { SidebarTrigger } from '@djangocfg/ui-nextjs/components';
|
|
11
11
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
12
|
|
|
13
13
|
interface PrivateContentProps {
|
|
14
14
|
children: ReactNode;
|
|
15
15
|
padding?: 'none' | 'default';
|
|
16
|
+
/** When false, no mobile hamburger (e.g. layout without a sidebar). Default true. */
|
|
17
|
+
hasSidebar?: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export function PrivateContent({
|
|
19
21
|
children,
|
|
20
22
|
padding = 'default',
|
|
23
|
+
hasSidebar = true,
|
|
21
24
|
}: PrivateContentProps) {
|
|
25
|
+
const mobileToolbarClass = cn(
|
|
26
|
+
'sticky top-0 z-40 flex shrink-0 items-center gap-2 border-b border-border/50 bg-background/95 py-2 pl-2 pr-3 backdrop-blur-md supports-[backdrop-filter]:bg-background/80',
|
|
27
|
+
'md:hidden',
|
|
28
|
+
);
|
|
29
|
+
const scrollAreaClass = cn(
|
|
30
|
+
'min-h-0 flex-1 overflow-y-auto',
|
|
31
|
+
padding === 'default' && 'p-4 sm:p-6 lg:p-8',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const mobileToolbar = hasSidebar ? (
|
|
35
|
+
<div className={mobileToolbarClass}>
|
|
36
|
+
<SidebarTrigger className="shrink-0" aria-label="Open menu" />
|
|
37
|
+
</div>
|
|
38
|
+
) : null;
|
|
39
|
+
|
|
22
40
|
return (
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)}
|
|
28
|
-
>
|
|
29
|
-
{children}
|
|
30
|
-
</main>
|
|
41
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
42
|
+
{mobileToolbar}
|
|
43
|
+
<div className={scrollAreaClass}>{children}</div>
|
|
44
|
+
</div>
|
|
31
45
|
);
|
|
32
46
|
}
|
|
33
|
-
|
|
@@ -1,155 +1,235 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Private
|
|
3
|
-
*
|
|
4
|
-
* Sidebar navigation component for PrivateLayout
|
|
2
|
+
* Private sidebar: header (brand only when expanded; icon mode shows expand trigger only),
|
|
3
|
+
* nav groups, account footer. Nav: muted inactive rows, pill active; density scales with item count.
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
'use client';
|
|
8
7
|
|
|
9
8
|
import Link from 'next/link';
|
|
10
|
-
import { usePathname } from 'next/navigation';
|
|
9
|
+
import { usePathname as useNextPathname } from 'next/navigation';
|
|
11
10
|
import React from 'react';
|
|
12
11
|
|
|
13
12
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
Sidebar,
|
|
14
|
+
SidebarContent,
|
|
15
|
+
SidebarFooter,
|
|
16
|
+
SidebarGroup,
|
|
17
|
+
SidebarGroupContent,
|
|
18
|
+
SidebarGroupLabel,
|
|
19
|
+
SidebarHeader,
|
|
20
|
+
SidebarMenu,
|
|
21
|
+
SidebarMenuBadge,
|
|
22
|
+
SidebarMenuButton,
|
|
23
|
+
SidebarMenuItem,
|
|
24
|
+
SidebarTrigger,
|
|
25
|
+
useSidebar,
|
|
16
26
|
} from '@djangocfg/ui-nextjs/components';
|
|
17
27
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
18
28
|
|
|
29
|
+
import { PrivateSidebarAccount } from '../../_components/PrivateSidebarAccount';
|
|
19
30
|
import { LucideIcon } from '../../../components';
|
|
20
31
|
|
|
21
|
-
import type {
|
|
32
|
+
import type { I18nLayoutConfig } from '../../AppLayout/AppLayout';
|
|
33
|
+
import type { HeaderConfig, SidebarItem, SidebarConfig } from '../PrivateLayout';
|
|
34
|
+
|
|
35
|
+
type NavDensity = 'comfortable' | 'default' | 'compact';
|
|
36
|
+
|
|
37
|
+
function navDensityFromCount(n: number): NavDensity {
|
|
38
|
+
if (n <= 6) return 'comfortable';
|
|
39
|
+
if (n <= 14) return 'default';
|
|
40
|
+
return 'compact';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Nav rows use semantic sidebar tokens so light/dark follows ui-core theme vars.
|
|
45
|
+
*/
|
|
46
|
+
const navItemClass = cn(
|
|
47
|
+
'border-0 font-normal shadow-none transition-colors',
|
|
48
|
+
'text-sidebar-foreground/70',
|
|
49
|
+
'data-[active=true]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
|
50
|
+
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
51
|
+
'data-[active=true]:hover:bg-sidebar-accent',
|
|
52
|
+
'[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
|
|
53
|
+
'data-[active=true]:[&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:opacity-100',
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const DENSITY = {
|
|
57
|
+
comfortable: {
|
|
58
|
+
menu: 'gap-1.5',
|
|
59
|
+
group: 'gap-2',
|
|
60
|
+
/** Tighter than default SidebarGroup `p-2` (doubles Y between groups). */
|
|
61
|
+
groupPad: 'px-2 py-1',
|
|
62
|
+
label:
|
|
63
|
+
'h-7 uppercase text-[10px] font-light leading-none tracking-[0.14em] text-sidebar-foreground/40',
|
|
64
|
+
buttonSize: 'lg' as const,
|
|
65
|
+
iconClass: 'h-5 w-5',
|
|
66
|
+
extraButton: 'rounded-lg !px-3',
|
|
67
|
+
/** Extra left inset so brand aligns with nav icons (group p-2 + button px). */
|
|
68
|
+
headerRowInset: 'pl-3',
|
|
69
|
+
},
|
|
70
|
+
default: {
|
|
71
|
+
menu: 'gap-1',
|
|
72
|
+
group: 'gap-1.5',
|
|
73
|
+
groupPad: 'px-2 py-1',
|
|
74
|
+
label:
|
|
75
|
+
'uppercase text-[9px] font-light leading-none tracking-[0.12em] text-sidebar-foreground/50',
|
|
76
|
+
buttonSize: 'default' as const,
|
|
77
|
+
iconClass: 'h-4 w-4',
|
|
78
|
+
extraButton: 'rounded-lg',
|
|
79
|
+
headerRowInset: 'pl-2',
|
|
80
|
+
},
|
|
81
|
+
compact: {
|
|
82
|
+
menu: 'gap-0.5',
|
|
83
|
+
group: 'gap-0.5',
|
|
84
|
+
groupPad: 'px-2 py-0.5',
|
|
85
|
+
label:
|
|
86
|
+
'h-6 uppercase text-[8px] font-light leading-none tracking-[0.1em] text-sidebar-foreground/40',
|
|
87
|
+
buttonSize: 'sm' as const,
|
|
88
|
+
iconClass: 'h-3.5 w-3.5',
|
|
89
|
+
extraButton: 'rounded-md !px-2',
|
|
90
|
+
headerRowInset: 'pl-2',
|
|
91
|
+
},
|
|
92
|
+
} as const;
|
|
22
93
|
|
|
23
94
|
interface PrivateSidebarProps {
|
|
24
95
|
sidebar: SidebarConfig;
|
|
96
|
+
header?: HeaderConfig;
|
|
97
|
+
i18n?: I18nLayoutConfig;
|
|
98
|
+
pathname?: string;
|
|
25
99
|
}
|
|
26
100
|
|
|
27
|
-
export function PrivateSidebar({ sidebar }: PrivateSidebarProps) {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
101
|
+
export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }: PrivateSidebarProps) {
|
|
102
|
+
const pathnameFromNext = useNextPathname();
|
|
103
|
+
const pathname = pathnameProp ?? pathnameFromNext;
|
|
104
|
+
const { state, isMobile, setOpenMobile } = useSidebar();
|
|
30
105
|
const homeHref = sidebar.homeHref || '/';
|
|
31
106
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
107
|
+
React.useEffect(() => {
|
|
108
|
+
if (isMobile) setOpenMobile(false);
|
|
109
|
+
}, [pathname, isMobile, setOpenMobile]);
|
|
110
|
+
const brandTitle = header?.title?.trim() || 'Dashboard';
|
|
111
|
+
const brandMonogram = (header?.brandLetter?.trim().charAt(0) || brandTitle.charAt(0) || 'D').toUpperCase();
|
|
112
|
+
|
|
113
|
+
const allItems = React.useMemo(
|
|
114
|
+
() => sidebar.groups.flatMap((g) => g.items),
|
|
115
|
+
[sidebar.groups],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const density = React.useMemo(() => navDensityFromCount(allItems.length), [allItems.length]);
|
|
119
|
+
const d = DENSITY[density];
|
|
120
|
+
|
|
121
|
+
const isActive = React.useCallback(
|
|
122
|
+
(href: string) => {
|
|
123
|
+
const matches = pathname === href || pathname.startsWith(`${href}/`);
|
|
124
|
+
if (!matches) return false;
|
|
125
|
+
return !allItems.some(
|
|
126
|
+
(other) =>
|
|
127
|
+
other.href !== href &&
|
|
128
|
+
other.href.startsWith(`${href}/`) &&
|
|
129
|
+
(pathname === other.href || pathname.startsWith(`${other.href}/`)),
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
[pathname, allItems],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const expanded = state === 'expanded';
|
|
136
|
+
|
|
137
|
+
const headerRowClass = cn('flex items-center gap-2', d.headerRowInset);
|
|
138
|
+
const brandMark = header?.brandIcon ? (
|
|
139
|
+
<LucideIcon icon={header.brandIcon} className="h-4 w-4 text-sidebar-primary-foreground" />
|
|
140
|
+
) : (
|
|
141
|
+
<span className="text-[11px] font-bold leading-none tracking-tight text-sidebar-primary-foreground">
|
|
142
|
+
{brandMonogram}
|
|
143
|
+
</span>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const showMenuStart = sidebar.menuStart != null && sidebar.menuStart !== false;
|
|
147
|
+
const showMenuEnd = sidebar.menuEnd != null && sidebar.menuEnd !== false;
|
|
148
|
+
const menuStartSlot = showMenuStart ? (
|
|
149
|
+
<div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuStart}</div>
|
|
150
|
+
) : null;
|
|
151
|
+
const menuEndSlot = showMenuEnd ? (
|
|
152
|
+
<div className="w-full min-w-0 shrink-0 px-2">{sidebar.menuEnd}</div>
|
|
153
|
+
) : null;
|
|
154
|
+
|
|
155
|
+
const sidebarContentClass = cn('gap-2', d.group);
|
|
156
|
+
|
|
157
|
+
const renderedGroups = React.useMemo(() => {
|
|
158
|
+
const navButtonClass = cn(navItemClass, d.extraButton);
|
|
159
|
+
const groupLabelClass = cn('px-2', d.label);
|
|
160
|
+
const sidebarGroupClass = cn('gap-0', d.groupPad);
|
|
161
|
+
|
|
80
162
|
return sidebar.groups.map((group) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
163
|
+
if (group.dynamic && group.items.length === 0) return null;
|
|
164
|
+
const items = group.items.map((item: SidebarItem) => {
|
|
165
|
+
const iconProp = typeof item.icon === 'string' ? item.icon : item.icon;
|
|
166
|
+
const tooltipText = item.tooltip ?? item.label;
|
|
167
|
+
return (
|
|
168
|
+
<SidebarMenuItem key={item.href}>
|
|
169
|
+
<SidebarMenuButton
|
|
170
|
+
asChild
|
|
171
|
+
isActive={isActive(item.href)}
|
|
172
|
+
size={d.buttonSize}
|
|
173
|
+
tooltip={tooltipText}
|
|
174
|
+
className={navButtonClass}
|
|
175
|
+
>
|
|
176
|
+
<Link href={item.href}>
|
|
177
|
+
{item.icon ? <LucideIcon icon={iconProp} className={d.iconClass} /> : null}
|
|
178
|
+
<span>{item.label}</span>
|
|
179
|
+
{item.badge ? <SidebarMenuBadge>{item.badge}</SidebarMenuBadge> : null}
|
|
180
|
+
</Link>
|
|
181
|
+
</SidebarMenuButton>
|
|
182
|
+
</SidebarMenuItem>
|
|
183
|
+
);
|
|
184
|
+
});
|
|
85
185
|
|
|
86
186
|
return (
|
|
87
|
-
<SidebarGroup key={group.label}>
|
|
88
|
-
<SidebarGroupLabel className=
|
|
187
|
+
<SidebarGroup key={group.label} className={sidebarGroupClass}>
|
|
188
|
+
<SidebarGroupLabel className={groupLabelClass}>{group.label}</SidebarGroupLabel>
|
|
89
189
|
<SidebarGroupContent>
|
|
90
|
-
<SidebarMenu>{
|
|
190
|
+
<SidebarMenu className={d.menu}>{items}</SidebarMenu>
|
|
91
191
|
</SidebarGroupContent>
|
|
92
192
|
</SidebarGroup>
|
|
93
193
|
);
|
|
94
194
|
});
|
|
95
|
-
};
|
|
195
|
+
}, [sidebar.groups, isActive, d]);
|
|
196
|
+
|
|
197
|
+
const expandedHeader = (
|
|
198
|
+
<div className={headerRowClass}>
|
|
199
|
+
<Link
|
|
200
|
+
href={homeHref}
|
|
201
|
+
className="flex min-w-0 flex-1 items-center gap-2 rounded-md py-0.5 outline-none ring-sidebar-ring focus-visible:ring-2"
|
|
202
|
+
>
|
|
203
|
+
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-sidebar-primary">{brandMark}</div>
|
|
204
|
+
<span className="truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{brandTitle}</span>
|
|
205
|
+
</Link>
|
|
206
|
+
<SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const collapsedHeader = (
|
|
211
|
+
<div className="flex justify-center py-1">
|
|
212
|
+
<SidebarTrigger aria-label="Expand sidebar" />
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const sidebarHeaderContent = expanded ? expandedHeader : collapsedHeader;
|
|
217
|
+
const footerExtra = sidebar.footer ? <div className="mb-2">{sidebar.footer}</div> : null;
|
|
96
218
|
|
|
97
219
|
return (
|
|
98
220
|
<Sidebar collapsible="icon">
|
|
99
|
-
<SidebarHeader>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
padding: '0.5rem',
|
|
112
|
-
transition: 'padding 200ms ease-in-out',
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
>
|
|
116
|
-
<Link href={homeHref}>
|
|
117
|
-
<div className="flex items-center gap-3">
|
|
118
|
-
<div
|
|
119
|
-
className={cn(
|
|
120
|
-
'bg-primary rounded-sm flex items-center justify-center flex-shrink-0',
|
|
121
|
-
isMobile ? 'h-10 w-10' : 'h-8 w-8'
|
|
122
|
-
)}
|
|
123
|
-
>
|
|
124
|
-
<span className="text-primary-foreground font-bold text-sm">
|
|
125
|
-
D
|
|
126
|
-
</span>
|
|
127
|
-
</div>
|
|
128
|
-
{state !== 'collapsed' && (
|
|
129
|
-
<span
|
|
130
|
-
className={cn(
|
|
131
|
-
'font-semibold text-foreground truncate',
|
|
132
|
-
isMobile && 'text-base'
|
|
133
|
-
)}
|
|
134
|
-
style={{ whiteSpace: 'nowrap' }}
|
|
135
|
-
>
|
|
136
|
-
Dashboard
|
|
137
|
-
</span>
|
|
138
|
-
)}
|
|
139
|
-
</div>
|
|
140
|
-
</Link>
|
|
141
|
-
</div>
|
|
142
|
-
</SidebarHeader>
|
|
143
|
-
|
|
144
|
-
<SidebarContent>{renderContent()}</SidebarContent>
|
|
145
|
-
|
|
146
|
-
{/* Footer */}
|
|
147
|
-
{sidebar.footer && (
|
|
148
|
-
<SidebarFooter>
|
|
149
|
-
{sidebar.footer}
|
|
150
|
-
</SidebarFooter>
|
|
151
|
-
)}
|
|
221
|
+
<SidebarHeader className="px-2 pt-2.5 pb-2">{sidebarHeaderContent}</SidebarHeader>
|
|
222
|
+
|
|
223
|
+
<SidebarContent className={sidebarContentClass}>
|
|
224
|
+
{menuStartSlot}
|
|
225
|
+
{renderedGroups}
|
|
226
|
+
{menuEndSlot}
|
|
227
|
+
</SidebarContent>
|
|
228
|
+
|
|
229
|
+
<SidebarFooter className="p-2">
|
|
230
|
+
{footerExtra}
|
|
231
|
+
<PrivateSidebarAccount header={header} i18n={i18n} />
|
|
232
|
+
</SidebarFooter>
|
|
152
233
|
</Sidebar>
|
|
153
234
|
);
|
|
154
235
|
}
|
|
155
|
-
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account block: collapsible trigger + expanded card (email, links, footer row:
|
|
3
|
+
* sign out on the left, locale + theme toggles on the right).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import { ChevronDown, LogOut } from 'lucide-react';
|
|
9
|
+
import Link from 'next/link';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
|
|
12
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
13
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
14
|
+
import {
|
|
15
|
+
Avatar,
|
|
16
|
+
AvatarFallback,
|
|
17
|
+
AvatarImage,
|
|
18
|
+
Button,
|
|
19
|
+
Collapsible,
|
|
20
|
+
CollapsibleContent,
|
|
21
|
+
CollapsibleTrigger,
|
|
22
|
+
} from '@djangocfg/ui-core/components';
|
|
23
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
24
|
+
import { useSidebar } from '@djangocfg/ui-nextjs/components';
|
|
25
|
+
import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
|
|
26
|
+
|
|
27
|
+
import { useLogout } from '../../hooks';
|
|
28
|
+
import { LocaleSwitcher } from './LocaleSwitcher';
|
|
29
|
+
|
|
30
|
+
import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
|
|
31
|
+
import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
|
|
32
|
+
|
|
33
|
+
interface PrivateSidebarAccountProps {
|
|
34
|
+
header?: HeaderConfig;
|
|
35
|
+
i18n?: I18nLayoutConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function PrivateSidebarAccount({ header, i18n }: PrivateSidebarAccountProps) {
|
|
39
|
+
const { user } = useAuth();
|
|
40
|
+
const handleLogout = useLogout();
|
|
41
|
+
const t = useAppT();
|
|
42
|
+
const { state, setOpen: setSidebarOpen } = useSidebar();
|
|
43
|
+
const [accountOpen, setAccountOpen] = React.useState(false);
|
|
44
|
+
const accountRootRef = React.useRef<HTMLDivElement>(null);
|
|
45
|
+
const narrow = state === 'collapsed';
|
|
46
|
+
|
|
47
|
+
React.useEffect(() => {
|
|
48
|
+
if (state === 'collapsed') setAccountOpen(false);
|
|
49
|
+
}, [state]);
|
|
50
|
+
|
|
51
|
+
React.useEffect(() => {
|
|
52
|
+
if (!accountOpen) return;
|
|
53
|
+
|
|
54
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
55
|
+
const root = accountRootRef.current;
|
|
56
|
+
if (root && !root.contains(event.target as Node)) {
|
|
57
|
+
setAccountOpen(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
document.addEventListener('pointerdown', handlePointerDown);
|
|
62
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
|
63
|
+
}, [accountOpen]);
|
|
64
|
+
|
|
65
|
+
const signOutLabel = t('layouts.profile.signOut');
|
|
66
|
+
|
|
67
|
+
const accountLinks = React.useMemo(() => {
|
|
68
|
+
if (!header?.groups?.length) return [];
|
|
69
|
+
return header.groups.flatMap((g) => g.items.filter((i) => i.href));
|
|
70
|
+
}, [header?.groups]);
|
|
71
|
+
|
|
72
|
+
if (!user) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const displayName = user.display_username || user.email || 'User';
|
|
77
|
+
const userInitial = displayName.charAt(0).toUpperCase();
|
|
78
|
+
const userAvatar = user.avatar || '';
|
|
79
|
+
const hasEmailOrLinks = Boolean(user.email) || accountLinks.length > 0;
|
|
80
|
+
|
|
81
|
+
const triggerClassName = cn(
|
|
82
|
+
'h-auto min-h-10 w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-sidebar-accent',
|
|
83
|
+
narrow && 'justify-center px-0',
|
|
84
|
+
);
|
|
85
|
+
const chevronClassName = cn(
|
|
86
|
+
'h-4 w-4 shrink-0 text-sidebar-foreground/65 transition-transform duration-200',
|
|
87
|
+
accountOpen && 'rotate-180',
|
|
88
|
+
);
|
|
89
|
+
const accountPanelClassName = cn(
|
|
90
|
+
'mt-2 flex flex-col gap-3 rounded-lg border border-sidebar-border/60 bg-sidebar-accent/45 p-3 shadow-sm',
|
|
91
|
+
'dark:border-sidebar-border dark:bg-sidebar-accent/20',
|
|
92
|
+
);
|
|
93
|
+
const accountActionsClassName = cn(
|
|
94
|
+
'flex min-h-10 items-center gap-2',
|
|
95
|
+
hasEmailOrLinks && 'border-t border-sidebar-border/50 pt-3',
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const onAccountTriggerClick = React.useCallback(() => {
|
|
99
|
+
if (narrow) setSidebarOpen(true);
|
|
100
|
+
}, [narrow, setSidebarOpen]);
|
|
101
|
+
|
|
102
|
+
const accountLinkRows = React.useMemo(
|
|
103
|
+
() =>
|
|
104
|
+
accountLinks.map((item) => {
|
|
105
|
+
const Icon = item.icon;
|
|
106
|
+
return (
|
|
107
|
+
<Link
|
|
108
|
+
key={item.href}
|
|
109
|
+
href={item.href!}
|
|
110
|
+
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm leading-snug text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
|
|
111
|
+
>
|
|
112
|
+
{Icon ? <Icon className="h-4 w-4 shrink-0 text-sidebar-foreground/65" /> : null}
|
|
113
|
+
<span className="truncate">{item.label}</span>
|
|
114
|
+
</Link>
|
|
115
|
+
);
|
|
116
|
+
}),
|
|
117
|
+
[accountLinks],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const emailBlock = user.email ? (
|
|
121
|
+
<p className="truncate text-xs leading-snug text-sidebar-foreground/65">{user.email}</p>
|
|
122
|
+
) : null;
|
|
123
|
+
|
|
124
|
+
const accountLinksNav =
|
|
125
|
+
accountLinks.length > 0 ? (
|
|
126
|
+
<nav className="flex max-h-40 flex-col gap-0.5 overflow-y-auto" aria-label="Account">
|
|
127
|
+
{accountLinkRows}
|
|
128
|
+
</nav>
|
|
129
|
+
) : null;
|
|
130
|
+
|
|
131
|
+
const expandedTriggerMeta = narrow ? null : (
|
|
132
|
+
<>
|
|
133
|
+
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium leading-tight text-sidebar-foreground">
|
|
134
|
+
{displayName}
|
|
135
|
+
</span>
|
|
136
|
+
<ChevronDown className={chevronClassName} aria-hidden />
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const localeThemeGroup = i18n ? (
|
|
141
|
+
<LocaleSwitcher
|
|
142
|
+
locale={i18n.locale}
|
|
143
|
+
locales={i18n.locales}
|
|
144
|
+
onChange={i18n.onLocaleChange}
|
|
145
|
+
variant="ghost"
|
|
146
|
+
size="icon"
|
|
147
|
+
showTriggerLabel={false}
|
|
148
|
+
showIcon={false}
|
|
149
|
+
className="h-8 w-8 shrink-0 text-base leading-none"
|
|
150
|
+
/>
|
|
151
|
+
) : null;
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div ref={accountRootRef} className="w-full min-w-0 border-t border-sidebar-border/45 pt-2">
|
|
155
|
+
<Collapsible open={accountOpen} onOpenChange={setAccountOpen} className="w-full min-w-0">
|
|
156
|
+
<CollapsibleTrigger asChild>
|
|
157
|
+
<Button
|
|
158
|
+
type="button"
|
|
159
|
+
variant="ghost"
|
|
160
|
+
aria-expanded={accountOpen}
|
|
161
|
+
aria-label={narrow ? 'Account' : undefined}
|
|
162
|
+
className={triggerClassName}
|
|
163
|
+
onClick={onAccountTriggerClick}
|
|
164
|
+
>
|
|
165
|
+
<Avatar className="h-8 w-8 shrink-0">
|
|
166
|
+
<AvatarImage src={userAvatar} alt={displayName} />
|
|
167
|
+
<AvatarFallback className="text-xs">{userInitial}</AvatarFallback>
|
|
168
|
+
</Avatar>
|
|
169
|
+
{expandedTriggerMeta}
|
|
170
|
+
</Button>
|
|
171
|
+
</CollapsibleTrigger>
|
|
172
|
+
|
|
173
|
+
<CollapsibleContent className="overflow-hidden data-[state=closed]:hidden">
|
|
174
|
+
<div className={accountPanelClassName}>
|
|
175
|
+
{emailBlock}
|
|
176
|
+
{accountLinksNav}
|
|
177
|
+
|
|
178
|
+
<div className={accountActionsClassName}>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={handleLogout}
|
|
182
|
+
className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-destructive hover:bg-destructive/10"
|
|
183
|
+
>
|
|
184
|
+
<LogOut className="h-4 w-4 shrink-0" />
|
|
185
|
+
<span className="truncate">{signOutLabel}</span>
|
|
186
|
+
</button>
|
|
187
|
+
<div
|
|
188
|
+
className="flex shrink-0 items-center gap-0.5 border-l border-sidebar-border/50 pl-2"
|
|
189
|
+
role="group"
|
|
190
|
+
aria-label="Language and theme"
|
|
191
|
+
>
|
|
192
|
+
{localeThemeGroup}
|
|
193
|
+
<ThemeToggle
|
|
194
|
+
size="compact"
|
|
195
|
+
className="h-8 w-8 shrink-0 focus-visible:ring-1 focus-visible:ring-sidebar-ring focus-visible:ring-offset-0"
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</CollapsibleContent>
|
|
201
|
+
</Collapsible>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LucideIcon as LucideIconType } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maps menu/route `icon` values into `SidebarItem['icon']`.
|
|
5
|
+
* Supports Lucide icon **name** (`"LayoutDashboard"`), a **Lucide component**, or legacy `{ name: string }`.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeSidebarNavIcon(icon: unknown): string | LucideIconType | undefined {
|
|
8
|
+
if (icon == null) return undefined;
|
|
9
|
+
if (typeof icon === 'string') return icon;
|
|
10
|
+
if (typeof icon === 'function') return icon as LucideIconType;
|
|
11
|
+
if (typeof icon === 'object' && icon !== null && 'name' in icon) {
|
|
12
|
+
const n = (icon as { name?: unknown }).name;
|
|
13
|
+
if (typeof n === 'string' && n.length > 0) return n;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Private Layout Header
|
|
3
|
-
*
|
|
4
|
-
* Header component for PrivateLayout with sidebar trigger
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
'use client';
|
|
8
|
-
|
|
9
|
-
import React from 'react';
|
|
10
|
-
|
|
11
|
-
import { useAuth } from '@djangocfg/api/auth';
|
|
12
|
-
import { Separator } from '@djangocfg/ui-nextjs/components';
|
|
13
|
-
import { SidebarTrigger } from '@djangocfg/ui-nextjs/components';
|
|
14
|
-
import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
|
|
15
|
-
|
|
16
|
-
import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
|
|
17
|
-
import { UserMenu } from '../../_components/UserMenu';
|
|
18
|
-
|
|
19
|
-
import type { HeaderConfig } from '../PrivateLayout';
|
|
20
|
-
import type { I18nLayoutConfig } from '../../AppLayout/AppLayout';
|
|
21
|
-
|
|
22
|
-
interface PrivateHeaderProps {
|
|
23
|
-
header?: HeaderConfig;
|
|
24
|
-
/** i18n configuration for locale switching */
|
|
25
|
-
i18n?: I18nLayoutConfig;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function PrivateHeader({ header, i18n }: PrivateHeaderProps) {
|
|
29
|
-
const { user: _user } = useAuth();
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<header
|
|
33
|
-
className="sticky top-0 z-10 flex items-center justify-between px-4 shrink-0 bg-background border-b border-border"
|
|
34
|
-
style={{ height: '64px', minHeight: '64px' }}
|
|
35
|
-
>
|
|
36
|
-
{/* Left side */}
|
|
37
|
-
<div className="flex items-center gap-4">
|
|
38
|
-
<SidebarTrigger className="-ml-1" />
|
|
39
|
-
<Separator orientation="vertical" className="mr-2 h-4" />
|
|
40
|
-
|
|
41
|
-
{header?.title && (
|
|
42
|
-
<h1 className="text-lg font-semibold text-foreground">
|
|
43
|
-
{header.title}
|
|
44
|
-
</h1>
|
|
45
|
-
)}
|
|
46
|
-
</div>
|
|
47
|
-
|
|
48
|
-
{/* Right side */}
|
|
49
|
-
<div className="flex items-center gap-3">
|
|
50
|
-
{/* Locale Switcher */}
|
|
51
|
-
{i18n && (
|
|
52
|
-
<LocaleSwitcher
|
|
53
|
-
locale={i18n.locale}
|
|
54
|
-
locales={i18n.locales}
|
|
55
|
-
onChange={i18n.onLocaleChange}
|
|
56
|
-
/>
|
|
57
|
-
)}
|
|
58
|
-
|
|
59
|
-
{/* Theme Toggle */}
|
|
60
|
-
<ThemeToggle />
|
|
61
|
-
|
|
62
|
-
{/* User Menu */}
|
|
63
|
-
<UserMenu
|
|
64
|
-
variant="desktop"
|
|
65
|
-
groups={header?.groups}
|
|
66
|
-
authPath={header?.authPath}
|
|
67
|
-
/>
|
|
68
|
-
</div>
|
|
69
|
-
</header>
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|