@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.251",
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.251",
78
- "@djangocfg/centrifugo": "^2.1.251",
79
- "@djangocfg/i18n": "^2.1.251",
80
- "@djangocfg/monitor": "^2.1.251",
81
- "@djangocfg/debuger": "^2.1.251",
82
- "@djangocfg/ui-core": "^2.1.251",
83
- "@djangocfg/ui-nextjs": "^2.1.251",
84
- "@djangocfg/ui-tools": "^2.1.251",
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.251",
113
- "@djangocfg/i18n": "^2.1.251",
114
- "@djangocfg/centrifugo": "^2.1.251",
115
- "@djangocfg/monitor": "^2.1.251",
116
- "@djangocfg/debuger": "^2.1.251",
117
- "@djangocfg/typescript-config": "^2.1.251",
118
- "@djangocfg/ui-core": "^2.1.251",
119
- "@djangocfg/ui-nextjs": "^2.1.251",
120
- "@djangocfg/ui-tools": "^2.1.251",
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
- * Layout for authenticated user pages (dashboard, profile, etc.)
5
- * Import and use directly with props - no complex configs needed!
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, PrivateHeader, PrivateSidebar } from './components';
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
- /** User menu groups */
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
- /** Header configuration */
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 ? "Redirecting to login..." : "Authenticating..."}
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
- {/* Sidebar */}
150
- {sidebar && <PrivateSidebar sidebar={sidebar} />}
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
- {/* Header with sidebar trigger */}
155
- {(header || isAuthenticated) && (
156
- <PrivateHeader header={header} i18n={i18n} />
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 Layout Content
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
- <main
24
- className={cn(
25
- 'flex-1 overflow-y-auto',
26
- padding === 'default' && 'p-4 sm:p-6 lg:p-8'
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 Layout Sidebar
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
- Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader,
15
- SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, useSidebar
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 { SidebarItem, SidebarConfig } from '../PrivateLayout';
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 pathname = usePathname();
29
- const { state, isMobile } = useSidebar();
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
- // Get all items for active detection
33
- const allItems = React.useMemo(() => {
34
- return sidebar.groups.flatMap((g) => g.items);
35
- }, [sidebar.groups]);
36
-
37
- const isActive = (href: string) => {
38
- const matches = pathname === href || pathname.startsWith(href + '/');
39
- if (!matches) return false;
40
-
41
- // Check if there's a more specific (longer) path that also matches
42
- return !allItems.some(
43
- (otherItem) =>
44
- otherItem.href !== href &&
45
- otherItem.href.startsWith(href + '/') &&
46
- (pathname === otherItem.href ||
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>
75
- );
76
- };
77
-
78
- // Render groups
79
- const renderContent = () => {
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
- // Skip dynamic groups with no items
82
- if (group.dynamic && group.items.length === 0) {
83
- return null;
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="font-medium text-[10px]">{group.label}</SidebarGroupLabel>
187
+ <SidebarGroup key={group.label} className={sidebarGroupClass}>
188
+ <SidebarGroupLabel className={groupLabelClass}>{group.label}</SidebarGroupLabel>
89
189
  <SidebarGroupContent>
90
- <SidebarMenu>{group.items.map(renderMenuItem)}</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
- <div
101
- className="flex items-center gap-3"
102
- style={
103
- state === 'collapsed'
104
- ? {
105
- paddingLeft: '7px',
106
- paddingTop: '0.5rem',
107
- paddingBottom: '0.5rem',
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">
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
-
@@ -3,6 +3,4 @@
3
3
  */
4
4
 
5
5
  export { PrivateSidebar } from './PrivateSidebar';
6
- export { PrivateHeader } from './PrivateHeader';
7
6
  export { PrivateContent } from './PrivateContent';
8
-
@@ -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
+ }
@@ -5,4 +5,5 @@
5
5
  export * from './config';
6
6
  export * from './logger';
7
7
  export * from './pathMatcher';
8
+ export * from './sidebarNav';
8
9
 
@@ -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
-