@djangocfg/layouts 2.1.249 → 2.1.251

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.
@@ -12,7 +12,6 @@ import React, { useEffect, useState } from 'react';
12
12
  import { Laptop, Moon, Sun } from 'lucide-react';
13
13
 
14
14
  import { Button } from '@djangocfg/ui-core/components';
15
- import { useIsMobile } from '@djangocfg/ui-core/hooks';
16
15
  import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
17
16
 
18
17
  import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
@@ -71,33 +70,36 @@ function ThemeModeControl() {
71
70
  }
72
71
 
73
72
  export function PublicFooter({
74
- siteName,
73
+ projectInfo,
75
74
  description,
76
- logo,
77
75
  badge,
78
76
  socialLinks,
79
77
  links = [],
80
78
  menuSections = [],
79
+ showProjectInfo = true,
80
+ menuColumnMinWidth = 180,
81
+ menuMaxColumns = 5,
81
82
  copyright: copyrightProp,
82
83
  credits: creditsProp,
83
84
  variant = 'full',
84
85
  containerClassName,
85
86
  i18n,
86
87
  }: PublicFooterProps) {
87
- const isMobile = useIsMobile();
88
-
89
88
  // Prepare data BEFORE render
90
89
  const currentYear = new Date().getFullYear();
91
- const copyright = copyrightProp || `© ${currentYear} ${siteName}. All rights reserved.`;
92
- const credits = creditsProp || {
93
- text: 'Built with DjangoCFG',
94
- url: 'https://djangocfg.com',
95
- };
90
+ const copyright = copyrightProp || `© ${currentYear}. All rights reserved.`;
91
+ const credits = creditsProp;
96
92
 
97
93
  // Simple variant - minimal footer
98
94
  if (variant === 'simple') {
99
95
  return (
100
- <footer className="bg-background border-t border-border mt-auto">
96
+ <footer
97
+ className="border-t border-border/60 mt-auto"
98
+ style={{
99
+ background: 'linear-gradient(to bottom, hsl(var(--accent) / 0.26), hsl(var(--background) / 0.97) 42%, hsl(var(--accent) / 0.34))',
100
+ boxShadow: 'inset 0 1px 0 hsl(var(--border) / 0.75), inset 0 14px 26px hsl(var(--accent) / 0.18)',
101
+ }}
102
+ >
101
103
  <div className={`mx-auto px-4 py-4 ${containerClassName || 'w-full'}`}>
102
104
  <div className="text-center">
103
105
  <div className="text-sm text-muted-foreground">{copyright}</div>
@@ -110,17 +112,22 @@ export function PublicFooter({
110
112
  // Compact variant - single line with logo and links
111
113
  if (variant === 'compact') {
112
114
  return (
113
- <footer className="bg-background border-t border-border mt-auto">
115
+ <footer
116
+ className="border-t border-border/60 mt-auto"
117
+ style={{
118
+ background: 'linear-gradient(to bottom, hsl(var(--accent) / 0.26), hsl(var(--background) / 0.97) 42%, hsl(var(--accent) / 0.34))',
119
+ boxShadow: 'inset 0 1px 0 hsl(var(--border) / 0.75), inset 0 14px 26px hsl(var(--accent) / 0.18)',
120
+ }}
121
+ >
114
122
  <div className={`mx-auto px-4 sm:px-6 lg:px-8 py-8 ${containerClassName || 'w-full'}`}>
115
123
  {/* Main row: logo left, links right */}
116
124
  <div className="flex flex-col sm:flex-row items-center justify-between gap-6">
117
125
  {/* Logo + Name */}
118
- <div className="flex items-center gap-3">
119
- {logo && (
120
- <img src={logo} alt={siteName} className="h-8 w-8" />
121
- )}
122
- <span className="text-lg font-semibold text-foreground">{siteName}</span>
123
- </div>
126
+ {projectInfo ?? (
127
+ <div className="flex items-center gap-3">
128
+ <span className="text-lg font-semibold text-foreground">Project</span>
129
+ </div>
130
+ )}
124
131
 
125
132
  {/* Links */}
126
133
  {links.length > 0 && (
@@ -153,17 +160,19 @@ export function PublicFooter({
153
160
  {/* Bottom row: copyright + credits */}
154
161
  <div className="mt-6 pt-6 border-t border-border flex flex-col sm:flex-row items-center justify-between gap-3 text-sm text-muted-foreground">
155
162
  <span>{copyright}</span>
156
- {credits.url ? (
157
- <a
158
- href={credits.url}
159
- target="_blank"
160
- rel="noopener noreferrer"
161
- className="hover:text-primary transition-colors"
162
- >
163
- {credits.text}
164
- </a>
165
- ) : (
166
- <span>{credits.text}</span>
163
+ {credits && (
164
+ credits.url ? (
165
+ <a
166
+ href={credits.url}
167
+ target="_blank"
168
+ rel="noopener noreferrer"
169
+ className="hover:text-primary transition-colors"
170
+ >
171
+ {credits.text}
172
+ </a>
173
+ ) : (
174
+ <span>{credits.text}</span>
175
+ )
167
176
  )}
168
177
  </div>
169
178
  </div>
@@ -171,18 +180,25 @@ export function PublicFooter({
171
180
  );
172
181
  }
173
182
 
174
- // Mobile Footer
175
- if (isMobile) {
176
- return (
177
- <footer className="lg:hidden bg-background border-t border-border mt-auto">
183
+ return (
184
+ <>
185
+ <footer
186
+ className="lg:hidden border-t border-border/60 mt-auto"
187
+ style={{
188
+ background: 'linear-gradient(to bottom, hsl(var(--accent) / 0.26), hsl(var(--background) / 0.97) 42%, hsl(var(--accent) / 0.34))',
189
+ boxShadow: 'inset 0 1px 0 hsl(var(--border) / 0.75), inset 0 14px 26px hsl(var(--accent) / 0.18)',
190
+ }}
191
+ >
178
192
  <div className={`mx-auto px-4 py-8 ${containerClassName || 'w-full'}`}>
179
- <FooterProjectInfo
180
- siteName={siteName}
181
- description={description}
182
- logo={logo}
183
- socialLinks={socialLinks}
184
- variant="mobile"
185
- />
193
+ {showProjectInfo && (
194
+ projectInfo ?? (
195
+ <FooterProjectInfo
196
+ description={description}
197
+ socialLinks={socialLinks}
198
+ variant="mobile"
199
+ />
200
+ )
201
+ )}
186
202
 
187
203
  {/* Quick Links */}
188
204
  {links.length > 0 && (
@@ -213,15 +229,17 @@ export function PublicFooter({
213
229
 
214
230
  <div className="border-t border-border mt-6 pt-4 space-y-3">
215
231
  <div className="text-xs text-muted-foreground text-center">{copyright}</div>
216
- <div className="text-xs text-muted-foreground text-center">
217
- {credits.url ? (
218
- <a href={credits.url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
219
- {credits.text}
220
- </a>
221
- ) : (
222
- credits.text
223
- )}
224
- </div>
232
+ {credits && (
233
+ <div className="text-xs text-muted-foreground text-center">
234
+ {credits.url ? (
235
+ <a href={credits.url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
236
+ {credits.text}
237
+ </a>
238
+ ) : (
239
+ credits.text
240
+ )}
241
+ </div>
242
+ )}
225
243
  </div>
226
244
 
227
245
  <div className="mt-5 pt-4 border-t border-border/60 flex items-center justify-center gap-2">
@@ -239,69 +257,72 @@ export function PublicFooter({
239
257
  </div>
240
258
  </div>
241
259
  </footer>
242
- );
243
- }
244
-
245
- // Desktop Footer
246
- return (
247
- <footer
248
- className="max-lg:hidden border-t border-border/50 mt-auto"
249
- style={{ backgroundColor: 'hsl(var(--background) / 0.94)' }}
250
- >
251
- <div className={`mx-auto px-6 sm:px-8 lg:px-10 py-14 ${containerClassName || 'w-full'}`}>
252
- <div className="grid grid-cols-12 gap-10 lg:gap-14">
253
- <div className="col-span-12 lg:col-span-4">
254
- <FooterProjectInfo
255
- siteName={siteName}
256
- description={description}
257
- logo={logo}
258
- badge={badge}
259
- socialLinks={socialLinks}
260
- variant="desktop"
261
- />
262
- </div>
260
+ <footer
261
+ className="max-lg:hidden border-t border-border/60 mt-auto"
262
+ style={{
263
+ background: 'linear-gradient(to bottom, hsl(var(--accent) / 0.26), hsl(var(--background) / 0.97) 42%, hsl(var(--accent) / 0.34))',
264
+ boxShadow: 'inset 0 1px 0 hsl(var(--border) / 0.75), inset 0 14px 26px hsl(var(--accent) / 0.18)',
265
+ }}
266
+ >
267
+ <div className={`mx-auto px-6 sm:px-8 lg:px-10 py-14 ${containerClassName || 'w-full'}`}>
268
+ <div className="grid grid-cols-12 gap-10 lg:gap-14">
269
+ {showProjectInfo && (
270
+ <div className="col-span-12 lg:col-span-4">
271
+ {projectInfo ?? (
272
+ <FooterProjectInfo
273
+ description={description}
274
+ badge={badge}
275
+ socialLinks={socialLinks}
276
+ variant="desktop"
277
+ />
278
+ )}
279
+ </div>
280
+ )}
263
281
 
264
- <div className="col-span-12 lg:col-span-8 lg:pl-8">
265
- <FooterMenuSections menuSections={menuSections} />
282
+ <div className={showProjectInfo ? 'col-span-12 lg:col-span-8 lg:pl-8' : 'col-span-12'}>
283
+ <FooterMenuSections
284
+ menuSections={menuSections}
285
+ minColumnWidth={menuColumnMinWidth}
286
+ maxColumns={menuMaxColumns}
287
+ />
288
+ </div>
266
289
  </div>
267
- </div>
268
290
 
269
- <div className="mt-12 pt-5 border-t border-border/60 flex items-center justify-between gap-8">
270
- <div className="text-xs text-muted-foreground shrink-0">{copyright}</div>
291
+ <div className="mt-12 pt-5 border-t border-border/60 grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] items-center gap-4 lg:gap-6">
292
+ <div className="text-xs text-muted-foreground text-center lg:text-left lg:justify-self-start">
293
+ {copyright}
294
+ </div>
271
295
 
272
- <div className="flex-1 flex items-center justify-center min-w-0">
273
- {credits.url ? (
274
- <a href={credits.url} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground hover:text-foreground transition-colors whitespace-nowrap">
275
- {credits.text}
276
- </a>
277
- ) : (
278
- <span className="text-xs text-muted-foreground whitespace-nowrap">{credits.text}</span>
279
- )}
280
- </div>
296
+ <div className="min-w-0 flex items-center justify-center gap-3 text-xs text-muted-foreground flex-wrap">
297
+ {credits && (
298
+ credits.url ? (
299
+ <a href={credits.url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors whitespace-nowrap">
300
+ {credits.text}
301
+ </a>
302
+ ) : (
303
+ <span className="whitespace-nowrap">{credits.text}</span>
304
+ )
305
+ )}
306
+ {links.length > 0 && links.map((link) =>
307
+ link.external ? (
308
+ <a
309
+ key={link.path}
310
+ href={link.path}
311
+ target="_blank"
312
+ rel="noopener noreferrer"
313
+ className="hover:text-foreground transition-colors whitespace-nowrap"
314
+ >
315
+ {link.label}
316
+ </a>
317
+ ) : (
318
+ <Link key={link.path} href={link.path} className="hover:text-foreground transition-colors whitespace-nowrap">
319
+ {link.label}
320
+ </Link>
321
+ )
322
+ )}
323
+ </div>
281
324
 
282
- <div className="flex flex-col items-end gap-2 shrink-0">
283
- {links.length > 0 && (
284
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
285
- {links.map((link) =>
286
- link.external ? (
287
- <a
288
- key={link.path}
289
- href={link.path}
290
- target="_blank"
291
- rel="noopener noreferrer"
292
- className="hover:text-foreground transition-colors whitespace-nowrap"
293
- >
294
- {link.label}
295
- </a>
296
- ) : (
297
- <Link key={link.path} href={link.path} className="hover:text-foreground transition-colors whitespace-nowrap">
298
- {link.label}
299
- </Link>
300
- )
301
- )}
302
- </div>
303
- )}
304
- <div className="flex items-center gap-2">
325
+ <div className="flex items-center justify-center lg:justify-self-end gap-2">
305
326
  <ThemeModeControl />
306
327
  {i18n && (
307
328
  <LocaleSwitcher
@@ -316,7 +337,7 @@ export function PublicFooter({
316
337
  </div>
317
338
  </div>
318
339
  </div>
319
- </div>
320
- </footer>
340
+ </footer>
341
+ </>
321
342
  );
322
343
  }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { LucideIcon } from 'lucide-react';
6
+ import type { ReactNode } from 'react';
6
7
  import type { I18nLayoutConfig } from '../../../AppLayout/AppLayout';
7
8
 
8
9
  export interface FooterLink {
@@ -29,12 +30,10 @@ export interface FooterSocialLinks {
29
30
  }
30
31
 
31
32
  export interface PublicFooterProps {
32
- /** Project name */
33
- siteName: string;
33
+ /** Custom project/brand block node (full control over footer brand area) */
34
+ projectInfo?: ReactNode;
34
35
  /** Project description */
35
36
  description?: string;
36
- /** Logo path or URL */
37
- logo?: string;
38
37
  /** Optional badge */
39
38
  badge?: {
40
39
  icon: LucideIcon;
@@ -46,6 +45,12 @@ export interface PublicFooterProps {
46
45
  links?: FooterLink[];
47
46
  /** Footer menu sections (desktop grid) */
48
47
  menuSections?: FooterMenuSection[];
48
+ /** Show brand block (logo + project info) in footer */
49
+ showProjectInfo?: boolean;
50
+ /** Minimum width in pixels for each footer menu column */
51
+ menuColumnMinWidth?: number;
52
+ /** Soft max column count for menu grid */
53
+ menuMaxColumns?: number;
49
54
  /** Copyright text (auto-generated if not provided) */
50
55
  copyright?: string;
51
56
  /** Credits */
@@ -13,6 +13,7 @@ import React, { useMemo } from 'react';
13
13
  import { useAuth } from '@djangocfg/api/auth';
14
14
  import { useAppT } from '@djangocfg/i18n';
15
15
  import { Button } from '@djangocfg/ui-core/components';
16
+ import { usePathnameWithoutLocale } from '../../../hooks';
16
17
 
17
18
  import { UserMenu } from '../../_components/UserMenu';
18
19
  import { usePublicLayoutOptional } from '../context';
@@ -32,10 +33,11 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
32
33
  const context = usePublicLayoutOptional();
33
34
  const mobileMenuOpen = props.isOpen ?? context?.mobileMenuOpen ?? false;
34
35
  const closeMobileMenu = props.onClose ?? context?.closeMobileMenu ?? (() => {});
35
- const navigation = props.navigation ?? context?.navigation ?? [];
36
- const userMenu = props.userMenu ?? context?.userMenu;
37
- const containerClassName = props.containerClassName ?? context?.containerClassName;
36
+ const navigation = props.navigation ?? [];
37
+ const userMenu = props.userMenu;
38
+ const containerClassName = props.containerClassName;
38
39
  const { isAuthenticated } = useAuth();
40
+ const pathname = usePathnameWithoutLocale();
39
41
  const t = useAppT();
40
42
  const { isRendered, isActive, onTransitionEnd } = useFloatingPanel({
41
43
  isOpen: mobileMenuOpen,
@@ -48,6 +50,17 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
48
50
  signIn: t('layouts.profile.login'),
49
51
  }), [t]);
50
52
 
53
+ const mobileNavigation = useMemo(() => {
54
+ const hasHome = navigation.some((item) => item.href === '/');
55
+ if (hasHome) return navigation;
56
+ return [{ label: 'Home', href: '/' }, ...navigation];
57
+ }, [navigation]);
58
+
59
+ const isActivePath = (href: string) => {
60
+ if (href === '/') return pathname === '/';
61
+ return pathname === href || pathname.startsWith(`${href}/`);
62
+ };
63
+
51
64
  if (!isRendered) return null;
52
65
 
53
66
  return (
@@ -60,15 +73,22 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
60
73
  onClick={closeMobileMenu}
61
74
  />
62
75
  )}
63
- <div className="fixed inset-x-0 top-20 z-1000 lg:hidden px-4 sm:px-6 lg:px-8">
76
+ <div
77
+ className="fixed inset-x-0 z-1000 lg:hidden px-4 sm:px-6 lg:px-8"
78
+ style={{ top: 'var(--public-navbar-mobile-drawer-top, 5rem)' }}
79
+ >
64
80
  <div
65
81
  onTransitionEnd={onTransitionEnd}
66
- className={`mx-auto w-full max-h-[80vh] rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl shadow-2xl overflow-hidden flex flex-col transform-gpu will-change-transform transition-[transform,opacity] duration-[220ms] ease-out ${containerClassName || ''} ${
82
+ className={`mx-auto w-full rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl shadow-2xl overflow-hidden flex flex-col transform-gpu will-change-transform transition-[transform,opacity] duration-[220ms] ease-out ${containerClassName || ''} ${
67
83
  isActive
68
84
  ? 'opacity-100 translate-y-0 scale-100'
69
85
  : 'opacity-0 -translate-y-2 scale-[0.985] pointer-events-none'
70
86
  }`}
71
- style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
87
+ style={{
88
+ maxHeight: 'min(var(--public-navbar-mobile-drawer-max-height, calc(100dvh - 5rem - 12px)), calc(100dvh - 12px))',
89
+ backgroundColor: 'hsl(var(--background) / 0.72)',
90
+ backdropFilter: 'blur(10px)',
91
+ }}
72
92
  >
73
93
  {/* Scrollable content */}
74
94
  <div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5">
@@ -92,12 +112,16 @@ export function PublicMobileDrawer(props: PublicMobileDrawerProps = {}) {
92
112
  </h3>
93
113
  </div>
94
114
  <div className="space-y-1">
95
- {navigation.map((item) => (
115
+ {mobileNavigation.map((item) => (
96
116
  <div key={item.href}>
97
117
  <Link
98
118
  href={item.href}
99
119
  onClick={closeMobileMenu}
100
- className="block px-3 py-2.5 rounded-lg text-[15px] font-medium transition-colors text-foreground hover:bg-accent/70 hover:text-accent-foreground"
120
+ className={`block px-3 py-2.5 rounded-lg text-[15px] font-medium transition-colors ${
121
+ isActivePath(item.href)
122
+ ? 'bg-accent/55 text-foreground'
123
+ : 'text-foreground hover:bg-accent/70 hover:text-accent-foreground'
124
+ }`}
101
125
  >
102
126
  {item.label}
103
127
  </Link>
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { PublicMobileDrawer } from './PublicMobileDrawer';
6
+ import { PublicNavigation } from './PublicNavigation';
7
+
8
+ import type { NavigationItem, UserMenuConfig } from '../../types';
9
+ import type {
10
+ PublicDesktopDropdownRenderer,
11
+ PublicNavbarPosition,
12
+ PublicNavbarVariant,
13
+ } from './PublicNavigation';
14
+
15
+ export interface PublicNavbarConfig {
16
+ brand?: React.ReactNode;
17
+ brandHref?: string;
18
+ logo?: string;
19
+ siteName?: string;
20
+ navigation?: NavigationItem[];
21
+ userMenu?: UserMenuConfig;
22
+ containerClassName?: string;
23
+ navbarVariant?: PublicNavbarVariant;
24
+ navbarPosition?: PublicNavbarPosition;
25
+ renderDesktopDropdown?: PublicDesktopDropdownRenderer;
26
+ desktopMaxPrimaryItems?: number;
27
+ }
28
+
29
+ export interface PublicNavbarProps extends PublicNavbarConfig {
30
+ /**
31
+ * Preferred API: pass everything as a single config object.
32
+ * Flat props are still supported and override `config`.
33
+ */
34
+ config?: PublicNavbarConfig;
35
+ }
36
+
37
+ export function PublicNavbar(props: PublicNavbarProps = {}) {
38
+ const cfg = props.config ?? {};
39
+ const brand = props.brand ?? cfg.brand;
40
+ const brandHref = props.brandHref ?? cfg.brandHref;
41
+ const logo = props.logo ?? cfg.logo;
42
+ const siteName = props.siteName ?? cfg.siteName;
43
+ const navigation = props.navigation ?? cfg.navigation ?? [];
44
+ const userMenu = props.userMenu ?? cfg.userMenu;
45
+ const containerClassName = props.containerClassName ?? cfg.containerClassName;
46
+ const navbarVariant = props.navbarVariant ?? cfg.navbarVariant;
47
+ const navbarPosition = props.navbarPosition ?? cfg.navbarPosition;
48
+ const renderDesktopDropdown = props.renderDesktopDropdown ?? cfg.renderDesktopDropdown;
49
+ const desktopMaxPrimaryItems = props.desktopMaxPrimaryItems ?? cfg.desktopMaxPrimaryItems;
50
+
51
+ return (
52
+ <>
53
+ <PublicNavigation
54
+ brand={brand}
55
+ brandHref={brandHref}
56
+ logo={logo}
57
+ siteName={siteName}
58
+ navigation={navigation}
59
+ userMenu={userMenu}
60
+ containerClassName={containerClassName}
61
+ navbarVariant={navbarVariant}
62
+ navbarPosition={navbarPosition}
63
+ renderDesktopDropdown={renderDesktopDropdown}
64
+ desktopMaxPrimaryItems={desktopMaxPrimaryItems}
65
+ />
66
+ <PublicMobileDrawer
67
+ navigation={navigation}
68
+ userMenu={userMenu}
69
+ containerClassName={containerClassName}
70
+ />
71
+ </>
72
+ );
73
+ }
74
+