@djangocfg/layouts 2.1.251 → 2.1.252
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 +22 -66
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +28 -11
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +159 -116
- package/src/layouts/PrivateLayout/components/index.ts +0 -2
- package/src/layouts/_components/PrivateSidebarAccount.tsx +168 -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.252",
|
|
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.252",
|
|
78
|
+
"@djangocfg/centrifugo": "^2.1.252",
|
|
79
|
+
"@djangocfg/i18n": "^2.1.252",
|
|
80
|
+
"@djangocfg/monitor": "^2.1.252",
|
|
81
|
+
"@djangocfg/debuger": "^2.1.252",
|
|
82
|
+
"@djangocfg/ui-core": "^2.1.252",
|
|
83
|
+
"@djangocfg/ui-nextjs": "^2.1.252",
|
|
84
|
+
"@djangocfg/ui-tools": "^2.1.252",
|
|
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.252",
|
|
113
|
+
"@djangocfg/i18n": "^2.1.252",
|
|
114
|
+
"@djangocfg/centrifugo": "^2.1.252",
|
|
115
|
+
"@djangocfg/monitor": "^2.1.252",
|
|
116
|
+
"@djangocfg/debuger": "^2.1.252",
|
|
117
|
+
"@djangocfg/typescript-config": "^2.1.252",
|
|
118
|
+
"@djangocfg/ui-core": "^2.1.252",
|
|
119
|
+
"@djangocfg/ui-nextjs": "^2.1.252",
|
|
120
|
+
"@djangocfg/ui-tools": "^2.1.252",
|
|
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
|
|
|
@@ -86,21 +47,25 @@ export interface SidebarConfig {
|
|
|
86
47
|
}
|
|
87
48
|
|
|
88
49
|
export interface HeaderConfig {
|
|
50
|
+
/** Shown next to the logo when the sidebar is expanded */
|
|
89
51
|
title?: string;
|
|
90
|
-
/** User menu groups */
|
|
52
|
+
/** User menu groups (account panel in the sidebar footer) */
|
|
91
53
|
groups?: UserMenuConfig['groups'];
|
|
92
54
|
/** Auth page path (for sign in button) */
|
|
93
55
|
authPath?: string;
|
|
94
56
|
}
|
|
95
57
|
|
|
96
|
-
import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
|
|
97
|
-
|
|
98
58
|
export interface PrivateLayoutProps {
|
|
99
59
|
children: ReactNode;
|
|
100
60
|
/** Sidebar configuration */
|
|
101
61
|
sidebar?: SidebarConfig;
|
|
102
|
-
/**
|
|
62
|
+
/** Title + account links (no top navbar — title is used in the sidebar chrome) */
|
|
103
63
|
header?: HeaderConfig;
|
|
64
|
+
/**
|
|
65
|
+
* Path for active nav highlighting. With `@djangocfg/nextjs` i18n routing, pass `usePathname()` from
|
|
66
|
+
* `@djangocfg/nextjs/i18n/navigation` (no `/[locale]` segment). If omitted, uses `next/navigation` (includes locale).
|
|
67
|
+
*/
|
|
68
|
+
pathname?: string;
|
|
104
69
|
/** Content padding */
|
|
105
70
|
contentPadding?: 'none' | 'default';
|
|
106
71
|
/** i18n configuration for locale switching */
|
|
@@ -111,6 +76,7 @@ export function PrivateLayout({
|
|
|
111
76
|
children,
|
|
112
77
|
sidebar,
|
|
113
78
|
header,
|
|
79
|
+
pathname,
|
|
114
80
|
contentPadding = 'default',
|
|
115
81
|
i18n,
|
|
116
82
|
}: PrivateLayoutProps) {
|
|
@@ -120,23 +86,18 @@ export function PrivateLayout({
|
|
|
120
86
|
|
|
121
87
|
useEffect(() => {
|
|
122
88
|
if (!isLoading && !isAuthenticated && !isRedirecting) {
|
|
123
|
-
// Save current URL (including query params) before redirecting to auth
|
|
124
89
|
const currentUrl = window.location.pathname + window.location.search;
|
|
125
90
|
saveRedirectUrl(currentUrl);
|
|
126
|
-
|
|
127
|
-
// Set redirecting state to prevent flicker
|
|
128
91
|
setIsRedirecting(true);
|
|
129
92
|
router.push(header?.authPath || '/auth');
|
|
130
93
|
}
|
|
131
94
|
}, [isAuthenticated, isLoading, isRedirecting, router, saveRedirectUrl, header?.authPath]);
|
|
132
95
|
|
|
133
|
-
// Show loading state while auth is being checked or redirecting
|
|
134
|
-
// Note: SSR hydration is handled by ClientOnly wrapper in AppLayout
|
|
135
96
|
if (isLoading || isRedirecting || !isAuthenticated) {
|
|
136
97
|
return (
|
|
137
98
|
<Preloader
|
|
138
99
|
variant="fullscreen"
|
|
139
|
-
text={isRedirecting ?
|
|
100
|
+
text={isRedirecting ? 'Redirecting to login...' : 'Authenticating...'}
|
|
140
101
|
size="lg"
|
|
141
102
|
backdrop={true}
|
|
142
103
|
backdropOpacity={80}
|
|
@@ -146,20 +107,15 @@ export function PrivateLayout({
|
|
|
146
107
|
|
|
147
108
|
return (
|
|
148
109
|
<SidebarProvider defaultOpen={true}>
|
|
149
|
-
{
|
|
150
|
-
|
|
110
|
+
{sidebar && (
|
|
111
|
+
<PrivateSidebar sidebar={sidebar} header={header} i18n={i18n} pathname={pathname} />
|
|
112
|
+
)}
|
|
151
113
|
|
|
152
|
-
{/* Main content area */}
|
|
153
114
|
<SidebarInset className="flex flex-col">
|
|
154
|
-
{
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
)}
|
|
158
|
-
|
|
159
|
-
{/* Page content */}
|
|
160
|
-
<PrivateContent padding={contentPadding}>{children}</PrivateContent>
|
|
115
|
+
<PrivateContent padding={contentPadding} hasSidebar={Boolean(sidebar)}>
|
|
116
|
+
{children}
|
|
117
|
+
</PrivateContent>
|
|
161
118
|
</SidebarInset>
|
|
162
119
|
</SidebarProvider>
|
|
163
120
|
);
|
|
164
121
|
}
|
|
165
|
-
|
|
@@ -1,33 +1,50 @@
|
|
|
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) {
|
|
22
25
|
return (
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
27
|
+
{hasSidebar && (
|
|
28
|
+
<div
|
|
29
|
+
className={cn(
|
|
30
|
+
'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',
|
|
31
|
+
'md:hidden',
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
<SidebarTrigger
|
|
35
|
+
className="shrink-0"
|
|
36
|
+
aria-label="Open menu"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
27
39
|
)}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
'min-h-0 flex-1 overflow-y-auto',
|
|
43
|
+
padding === 'default' && 'p-4 sm:p-6 lg:p-8',
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
31
49
|
);
|
|
32
50
|
}
|
|
33
|
-
|
|
@@ -1,155 +1,198 @@
|
|
|
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: inactive muted. Light active = neutral pill (readable on white); dark = dark chip.
|
|
45
|
+
* Avoids `sidebar-accent` in light — project tokens often map to near-black and wash out labels.
|
|
46
|
+
*/
|
|
47
|
+
const navItemClass = cn(
|
|
48
|
+
'border-0 font-normal shadow-none transition-colors',
|
|
49
|
+
'text-muted-foreground',
|
|
50
|
+
'data-[active=true]:font-medium',
|
|
51
|
+
'data-[active=true]:bg-zinc-200/90 data-[active=true]:text-zinc-900',
|
|
52
|
+
'dark:data-[active=true]:bg-[#1a1a1a] dark:data-[active=true]:text-zinc-50',
|
|
53
|
+
'hover:bg-zinc-100/90 hover:text-foreground dark:hover:bg-white/[0.06]',
|
|
54
|
+
'data-[active=true]:hover:bg-zinc-200 dark:data-[active=true]:hover:bg-[#1a1a1a]',
|
|
55
|
+
'[&>svg]:shrink-0 [&>svg]:text-muted-foreground [&>svg]:opacity-85',
|
|
56
|
+
'data-[active=true]:[&>svg]:text-zinc-800 data-[active=true]:[&>svg]:opacity-100',
|
|
57
|
+
'dark:data-[active=true]:[&>svg]:text-zinc-50',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const DENSITY = {
|
|
61
|
+
comfortable: {
|
|
62
|
+
menu: 'gap-1.5',
|
|
63
|
+
group: 'gap-3',
|
|
64
|
+
label:
|
|
65
|
+
'h-7 uppercase text-[10px] font-light leading-none tracking-[0.14em] text-muted-foreground/40',
|
|
66
|
+
buttonSize: 'lg' as const,
|
|
67
|
+
iconClass: 'h-5 w-5',
|
|
68
|
+
extraButton: 'rounded-lg !px-3',
|
|
69
|
+
},
|
|
70
|
+
default: {
|
|
71
|
+
menu: 'gap-1',
|
|
72
|
+
group: 'gap-2',
|
|
73
|
+
label:
|
|
74
|
+
'uppercase text-[9px] font-light leading-none tracking-[0.12em] text-muted-foreground/50',
|
|
75
|
+
buttonSize: 'default' as const,
|
|
76
|
+
iconClass: 'h-4 w-4',
|
|
77
|
+
extraButton: 'rounded-lg',
|
|
78
|
+
},
|
|
79
|
+
compact: {
|
|
80
|
+
menu: 'gap-0.5',
|
|
81
|
+
group: 'gap-1',
|
|
82
|
+
label:
|
|
83
|
+
'h-6 uppercase text-[8px] font-light leading-none tracking-[0.1em] text-muted-foreground/40',
|
|
84
|
+
buttonSize: 'sm' as const,
|
|
85
|
+
iconClass: 'h-3.5 w-3.5',
|
|
86
|
+
extraButton: 'rounded-md !px-2',
|
|
87
|
+
},
|
|
88
|
+
} as const;
|
|
22
89
|
|
|
23
90
|
interface PrivateSidebarProps {
|
|
24
91
|
sidebar: SidebarConfig;
|
|
92
|
+
header?: HeaderConfig;
|
|
93
|
+
i18n?: I18nLayoutConfig;
|
|
94
|
+
pathname?: string;
|
|
25
95
|
}
|
|
26
96
|
|
|
27
|
-
export function PrivateSidebar({ sidebar }: PrivateSidebarProps) {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
97
|
+
export function PrivateSidebar({ sidebar, header, i18n, pathname: pathnameProp }: PrivateSidebarProps) {
|
|
98
|
+
const pathnameFromNext = useNextPathname();
|
|
99
|
+
const pathname = pathnameProp ?? pathnameFromNext;
|
|
100
|
+
const { state, isMobile, setOpenMobile } = useSidebar();
|
|
30
101
|
const homeHref = sidebar.homeHref || '/';
|
|
31
102
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
if (isMobile) setOpenMobile(false);
|
|
105
|
+
}, [pathname, isMobile, setOpenMobile]);
|
|
106
|
+
const brandTitle = header?.title?.trim() || 'Dashboard';
|
|
107
|
+
|
|
108
|
+
const allItems = React.useMemo(
|
|
109
|
+
() => sidebar.groups.flatMap((g) => g.items),
|
|
110
|
+
[sidebar.groups],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const density = React.useMemo(() => navDensityFromCount(allItems.length), [allItems.length]);
|
|
114
|
+
const d = DENSITY[density];
|
|
36
115
|
|
|
37
116
|
const isActive = (href: string) => {
|
|
38
|
-
const matches = pathname === href || pathname.startsWith(href
|
|
117
|
+
const matches = pathname === href || pathname.startsWith(`${href}/`);
|
|
39
118
|
if (!matches) return false;
|
|
40
|
-
|
|
41
|
-
// Check if there's a more specific (longer) path that also matches
|
|
42
119
|
return !allItems.some(
|
|
43
|
-
(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
(pathname ===
|
|
47
|
-
pathname.startsWith(otherItem.href + '/'))
|
|
48
|
-
);
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// Render a single menu item
|
|
52
|
-
const renderMenuItem = (item: SidebarItem) => {
|
|
53
|
-
const active = isActive(item.href);
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
<SidebarMenuItem key={item.href}>
|
|
57
|
-
<SidebarMenuButton
|
|
58
|
-
asChild
|
|
59
|
-
isActive={active}
|
|
60
|
-
tooltip={item.label}
|
|
61
|
-
size={isMobile ? 'lg' : 'default'}
|
|
62
|
-
>
|
|
63
|
-
<Link href={item.href}>
|
|
64
|
-
{item.icon && (
|
|
65
|
-
<LucideIcon
|
|
66
|
-
icon={typeof item.icon === 'string' ? item.icon : item.icon}
|
|
67
|
-
className={isMobile ? 'h-5 w-5' : 'h-4 w-4'}
|
|
68
|
-
/>
|
|
69
|
-
)}
|
|
70
|
-
<span className={isMobile ? 'text-base' : ''}>{item.label}</span>
|
|
71
|
-
{item.badge && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
|
|
72
|
-
</Link>
|
|
73
|
-
</SidebarMenuButton>
|
|
74
|
-
</SidebarMenuItem>
|
|
120
|
+
(other) =>
|
|
121
|
+
other.href !== href &&
|
|
122
|
+
other.href.startsWith(`${href}/`) &&
|
|
123
|
+
(pathname === other.href || pathname.startsWith(`${other.href}/`)),
|
|
75
124
|
);
|
|
76
125
|
};
|
|
77
126
|
|
|
78
|
-
|
|
79
|
-
const renderContent = () => {
|
|
80
|
-
return sidebar.groups.map((group) => {
|
|
81
|
-
// Skip dynamic groups with no items
|
|
82
|
-
if (group.dynamic && group.items.length === 0) {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<SidebarGroup key={group.label}>
|
|
88
|
-
<SidebarGroupLabel className="font-medium text-[10px]">{group.label}</SidebarGroupLabel>
|
|
89
|
-
<SidebarGroupContent>
|
|
90
|
-
<SidebarMenu>{group.items.map(renderMenuItem)}</SidebarMenu>
|
|
91
|
-
</SidebarGroupContent>
|
|
92
|
-
</SidebarGroup>
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
};
|
|
127
|
+
const expanded = state === 'expanded';
|
|
96
128
|
|
|
97
129
|
return (
|
|
98
130
|
<Sidebar collapsible="icon">
|
|
99
|
-
<SidebarHeader>
|
|
100
|
-
|
|
101
|
-
className="flex items-center gap-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
transition: 'padding 200ms ease-in-out',
|
|
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">
|
|
131
|
+
<SidebarHeader className="px-2 py-1.5">
|
|
132
|
+
{expanded ? (
|
|
133
|
+
<div className="flex items-center gap-2">
|
|
134
|
+
<Link
|
|
135
|
+
href={homeHref}
|
|
136
|
+
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"
|
|
137
|
+
>
|
|
138
|
+
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary">
|
|
139
|
+
<span className="text-[11px] font-bold leading-none tracking-tight text-primary-foreground">
|
|
125
140
|
D
|
|
126
141
|
</span>
|
|
127
142
|
</div>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
</div>
|
|
140
|
-
</Link>
|
|
141
|
-
</div>
|
|
143
|
+
<span className="truncate text-sm font-semibold tracking-tight text-foreground">
|
|
144
|
+
{brandTitle}
|
|
145
|
+
</span>
|
|
146
|
+
</Link>
|
|
147
|
+
<SidebarTrigger className="shrink-0" aria-label="Collapse sidebar" />
|
|
148
|
+
</div>
|
|
149
|
+
) : (
|
|
150
|
+
<div className="flex justify-center py-0.5">
|
|
151
|
+
<SidebarTrigger aria-label="Expand sidebar" />
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
142
154
|
</SidebarHeader>
|
|
143
155
|
|
|
144
|
-
<SidebarContent
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
156
|
+
<SidebarContent className={cn('gap-2', d.group)}>
|
|
157
|
+
{sidebar.groups.map((group) => {
|
|
158
|
+
if (group.dynamic && group.items.length === 0) return null;
|
|
159
|
+
return (
|
|
160
|
+
<SidebarGroup key={group.label} className="gap-0">
|
|
161
|
+
<SidebarGroupLabel className={cn('px-2', d.label)}>{group.label}</SidebarGroupLabel>
|
|
162
|
+
<SidebarGroupContent>
|
|
163
|
+
<SidebarMenu className={d.menu}>
|
|
164
|
+
{group.items.map((item: SidebarItem) => (
|
|
165
|
+
<SidebarMenuItem key={item.href}>
|
|
166
|
+
<SidebarMenuButton
|
|
167
|
+
asChild
|
|
168
|
+
isActive={isActive(item.href)}
|
|
169
|
+
size={d.buttonSize}
|
|
170
|
+
className={cn(navItemClass, d.extraButton)}
|
|
171
|
+
>
|
|
172
|
+
<Link href={item.href}>
|
|
173
|
+
{item.icon && (
|
|
174
|
+
<LucideIcon
|
|
175
|
+
icon={typeof item.icon === 'string' ? item.icon : item.icon}
|
|
176
|
+
className={d.iconClass}
|
|
177
|
+
/>
|
|
178
|
+
)}
|
|
179
|
+
<span>{item.label}</span>
|
|
180
|
+
{item.badge && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
|
|
181
|
+
</Link>
|
|
182
|
+
</SidebarMenuButton>
|
|
183
|
+
</SidebarMenuItem>
|
|
184
|
+
))}
|
|
185
|
+
</SidebarMenu>
|
|
186
|
+
</SidebarGroupContent>
|
|
187
|
+
</SidebarGroup>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
</SidebarContent>
|
|
191
|
+
|
|
192
|
+
<SidebarFooter className="p-2">
|
|
193
|
+
{sidebar.footer && <div className="mb-2">{sidebar.footer}</div>}
|
|
194
|
+
<PrivateSidebarAccount header={header} i18n={i18n} />
|
|
195
|
+
</SidebarFooter>
|
|
152
196
|
</Sidebar>
|
|
153
197
|
);
|
|
154
198
|
}
|
|
155
|
-
|
|
@@ -0,0 +1,168 @@
|
|
|
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 } = useSidebar();
|
|
43
|
+
const [open, setOpen] = React.useState(false);
|
|
44
|
+
|
|
45
|
+
const signOutLabel = t('layouts.profile.signOut');
|
|
46
|
+
|
|
47
|
+
const accountLinks = React.useMemo(() => {
|
|
48
|
+
if (!header?.groups?.length) return [];
|
|
49
|
+
return header.groups.flatMap((g) => g.items.filter((i) => i.href));
|
|
50
|
+
}, [header?.groups]);
|
|
51
|
+
|
|
52
|
+
if (!user) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const displayName = user.display_username || user.email || 'User';
|
|
57
|
+
const userInitial = displayName.charAt(0).toUpperCase();
|
|
58
|
+
const userAvatar = user.avatar || '';
|
|
59
|
+
const narrow = state === 'collapsed';
|
|
60
|
+
const hasEmailOrLinks = Boolean(user.email) || accountLinks.length > 0;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Collapsible open={open} onOpenChange={setOpen} className="w-full min-w-0 border-t border-sidebar-border/45 pt-2">
|
|
64
|
+
<CollapsibleTrigger asChild>
|
|
65
|
+
<Button
|
|
66
|
+
type="button"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
aria-expanded={open}
|
|
69
|
+
aria-label={narrow ? 'Account' : undefined}
|
|
70
|
+
className={cn(
|
|
71
|
+
'h-auto min-h-10 w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-sidebar-accent',
|
|
72
|
+
narrow && 'justify-center px-0',
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
<Avatar className="h-8 w-8 shrink-0">
|
|
76
|
+
<AvatarImage src={userAvatar} alt={displayName} />
|
|
77
|
+
<AvatarFallback className="text-xs">{userInitial}</AvatarFallback>
|
|
78
|
+
</Avatar>
|
|
79
|
+
{!narrow && (
|
|
80
|
+
<>
|
|
81
|
+
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium leading-tight">
|
|
82
|
+
{displayName}
|
|
83
|
+
</span>
|
|
84
|
+
<ChevronDown
|
|
85
|
+
className={cn(
|
|
86
|
+
'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',
|
|
87
|
+
open && 'rotate-180',
|
|
88
|
+
)}
|
|
89
|
+
aria-hidden
|
|
90
|
+
/>
|
|
91
|
+
</>
|
|
92
|
+
)}
|
|
93
|
+
</Button>
|
|
94
|
+
</CollapsibleTrigger>
|
|
95
|
+
|
|
96
|
+
<CollapsibleContent className="overflow-hidden data-[state=closed]:hidden">
|
|
97
|
+
<div
|
|
98
|
+
className={cn(
|
|
99
|
+
'mt-2 flex flex-col gap-3 rounded-lg border border-zinc-200/90 bg-zinc-50/80 p-3 shadow-sm',
|
|
100
|
+
'dark:border-sidebar-border dark:bg-sidebar-accent/15',
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{user.email && (
|
|
104
|
+
<p className="truncate text-xs leading-snug text-muted-foreground">{user.email}</p>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{accountLinks.length > 0 && (
|
|
108
|
+
<nav className="flex max-h-40 flex-col gap-0.5 overflow-y-auto" aria-label="Account">
|
|
109
|
+
{accountLinks.map((item) => {
|
|
110
|
+
const Icon = item.icon;
|
|
111
|
+
return (
|
|
112
|
+
<Link
|
|
113
|
+
key={item.href}
|
|
114
|
+
href={item.href!}
|
|
115
|
+
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm leading-snug text-zinc-800 transition-colors hover:bg-zinc-200/70 dark:text-foreground dark:hover:bg-sidebar-accent"
|
|
116
|
+
>
|
|
117
|
+
{Icon && (
|
|
118
|
+
<Icon className="h-4 w-4 shrink-0 text-zinc-500 dark:text-muted-foreground" />
|
|
119
|
+
)}
|
|
120
|
+
<span className="truncate">{item.label}</span>
|
|
121
|
+
</Link>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</nav>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
<div
|
|
128
|
+
className={cn(
|
|
129
|
+
'flex min-h-10 items-center gap-2',
|
|
130
|
+
hasEmailOrLinks && 'border-t border-border/50 pt-3 dark:border-sidebar-border/40',
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={handleLogout}
|
|
136
|
+
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"
|
|
137
|
+
>
|
|
138
|
+
<LogOut className="h-4 w-4 shrink-0" />
|
|
139
|
+
<span className="truncate">{signOutLabel}</span>
|
|
140
|
+
</button>
|
|
141
|
+
<div
|
|
142
|
+
className="flex shrink-0 items-center gap-0.5 border-l border-border/50 pl-2 dark:border-sidebar-border/40"
|
|
143
|
+
role="group"
|
|
144
|
+
aria-label="Language and theme"
|
|
145
|
+
>
|
|
146
|
+
{i18n && (
|
|
147
|
+
<LocaleSwitcher
|
|
148
|
+
locale={i18n.locale}
|
|
149
|
+
locales={i18n.locales}
|
|
150
|
+
onChange={i18n.onLocaleChange}
|
|
151
|
+
variant="ghost"
|
|
152
|
+
size="icon"
|
|
153
|
+
showTriggerLabel={false}
|
|
154
|
+
showIcon={false}
|
|
155
|
+
className="h-8 w-8 shrink-0 text-base leading-none"
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
<ThemeToggle
|
|
159
|
+
size="compact"
|
|
160
|
+
className="h-8 w-8 shrink-0 focus-visible:ring-1 focus-visible:ring-sidebar-ring focus-visible:ring-offset-0"
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</CollapsibleContent>
|
|
166
|
+
</Collapsible>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -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
|
-
|