@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.251",
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.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.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.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.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
- * 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
 
@@ -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
- /** Header configuration */
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 ? "Redirecting to login..." : "Authenticating..."}
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
- {/* Sidebar */}
150
- {sidebar && <PrivateSidebar sidebar={sidebar} />}
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
- {/* Header with sidebar trigger */}
155
- {(header || isAuthenticated) && (
156
- <PrivateHeader header={header} i18n={i18n} />
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 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) {
22
25
  return (
23
- <main
24
- className={cn(
25
- 'flex-1 overflow-y-auto',
26
- padding === 'default' && 'p-4 sm:p-6 lg:p-8'
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
- {children}
30
- </main>
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 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: 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 pathname = usePathname();
29
- const { state, isMobile } = useSidebar();
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
- // Get all items for active detection
33
- const allItems = React.useMemo(() => {
34
- return sidebar.groups.flatMap((g) => g.items);
35
- }, [sidebar.groups]);
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
- (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>
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
- // Render groups
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
- <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">
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
- {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>
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>{renderContent()}</SidebarContent>
145
-
146
- {/* Footer */}
147
- {sidebar.footer && (
148
- <SidebarFooter>
149
- {sidebar.footer}
150
- </SidebarFooter>
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
-
@@ -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,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
-