@djangocfg/layouts 2.1.256 → 2.1.259

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.
Files changed (36) hide show
  1. package/README.md +101 -203
  2. package/package.json +18 -18
  3. package/src/index.ts +4 -1
  4. package/src/layouts/AppLayout/AppLayout.tsx +97 -8
  5. package/src/layouts/AppLayout/BaseApp.tsx +2 -0
  6. package/src/layouts/AppLayout/index.ts +6 -0
  7. package/src/layouts/PrivateLayout/PrivateLayout.tsx +3 -1
  8. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +15 -4
  9. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +3 -3
  10. package/src/layouts/PublicLayout/PublicLayout.tsx +82 -17
  11. package/src/layouts/PublicLayout/components/PublicFooter/FooterProjectInfo.tsx +17 -24
  12. package/src/layouts/PublicLayout/components/PublicFooter/PublicFooter.tsx +79 -95
  13. package/src/layouts/PublicLayout/components/PublicFooter/index.ts +2 -0
  14. package/src/layouts/PublicLayout/components/PublicFooter/types.ts +41 -31
  15. package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +84 -40
  16. package/src/layouts/PublicLayout/components/PublicNavbar.tsx +22 -35
  17. package/src/layouts/PublicLayout/components/PublicNavigation.tsx +184 -98
  18. package/src/layouts/PublicLayout/components/ThemeBrandMark.tsx +83 -0
  19. package/src/layouts/PublicLayout/components/index.ts +2 -0
  20. package/src/layouts/PublicLayout/context.tsx +5 -0
  21. package/src/layouts/PublicLayout/hooks/index.ts +1 -1
  22. package/src/layouts/PublicLayout/hooks/useMobileNavPanel.ts +55 -0
  23. package/src/layouts/PublicLayout/index.ts +8 -0
  24. package/src/layouts/PublicLayout/navbarTypes.ts +20 -0
  25. package/src/layouts/PublicLayout/publicShellShadow.ts +12 -0
  26. package/src/layouts/_components/PrivateSidebarAccount.tsx +16 -3
  27. package/src/layouts/_components/UserMenu.tsx +133 -30
  28. package/src/layouts/types/index.ts +10 -1
  29. package/src/layouts/types/providers.types.ts +10 -0
  30. package/src/layouts/types/ui.types.ts +9 -0
  31. package/src/theme/ThemeStyleBridge.tsx +41 -0
  32. package/src/theme/buildThemeStyleSheet.ts +71 -0
  33. package/src/theme/index.ts +16 -0
  34. package/src/theme/themeStyle.types.ts +89 -0
  35. package/src/theme/themeStylePresets.ts +202 -0
  36. package/src/layouts/PublicLayout/hooks/useFloatingPanel.ts +0 -61
@@ -7,10 +7,16 @@ export type {
7
7
  AppLayoutProps,
8
8
  AppLayoutLayoutsConfig,
9
9
  AppLayoutBaseAppConfig,
10
+ AppLayoutLayoutComponentProps,
11
+ AppLayoutPublicChrome,
10
12
  LayoutMode,
11
13
  I18nLayoutConfig,
14
+ PublicMainTopSpacing,
15
+ PublicMainBottomSpacing,
12
16
  } from './AppLayout';
13
17
 
18
+ export { mergeAppLayoutPublicChrome } from './AppLayout';
19
+
14
20
  export { BaseApp } from './BaseApp';
15
21
  export type { BaseAppProps } from './BaseApp';
16
22
 
@@ -15,7 +15,7 @@ import { useAuth } from '@djangocfg/api/auth';
15
15
  import { Preloader } from '@djangocfg/ui-core/components';
16
16
  import { SidebarInset, SidebarProvider } from '@djangocfg/ui-nextjs/components';
17
17
 
18
- import type { I18nLayoutConfig } from '../AppLayout/AppLayout';
18
+ import type { AppLayoutPublicChrome, I18nLayoutConfig } from '../AppLayout/AppLayout';
19
19
  import { UserMenuConfig } from '../types';
20
20
  import { PrivateContent, PrivateSidebar } from './components';
21
21
 
@@ -92,6 +92,8 @@ export interface PrivateLayoutProps {
92
92
  contentPadding?: 'none' | 'default';
93
93
  /** i18n configuration for locale switching */
94
94
  i18n?: I18nLayoutConfig;
95
+ /** Reserved for `AppLayout` passthrough (`publicChrome`); unused in this layout. */
96
+ publicChrome?: AppLayoutPublicChrome;
95
97
  }
96
98
 
97
99
  export function PrivateLayout({
@@ -7,7 +7,7 @@
7
7
 
8
8
  import React, { ReactNode } from 'react';
9
9
 
10
- import { SidebarTrigger } from '@djangocfg/ui-nextjs/components';
10
+ import { SidebarTrigger, useSidebar } from '@djangocfg/ui-nextjs/components';
11
11
  import { cn } from '@djangocfg/ui-core/lib';
12
12
 
13
13
  interface PrivateContentProps {
@@ -22,14 +22,25 @@ export function PrivateContent({
22
22
  padding = 'default',
23
23
  hasSidebar = true,
24
24
  }: PrivateContentProps) {
25
- /** Space for fixed FAB + safe area so content does not sit under the button. */
25
+ const { isMobile, openMobile } = useSidebar();
26
+
27
+ /**
28
+ * Space for fixed FAB + safe area so content does not sit under the button.
29
+ * Tighter when the mobile drawer is closed (FAB only). When the drawer is open, a bit more
30
+ * room avoids the first line sitting under the overlay edge.
31
+ */
26
32
  const mobileFabClearance =
27
33
  hasSidebar &&
28
- 'max-md:pt-[max(4.75rem,calc(3.25rem+env(safe-area-inset-top,0px)))]';
34
+ (isMobile && !openMobile
35
+ ? 'max-md:pt-[max(3.75rem,calc(3rem+env(safe-area-inset-top,0px)))]'
36
+ : 'max-md:pt-[max(4rem,calc(3.25rem+env(safe-area-inset-top,0px)))]');
29
37
 
30
38
  const scrollAreaClass = cn(
31
39
  'min-h-0 flex-1 overflow-y-auto',
32
- padding === 'default' && 'p-4 sm:p-6 lg:p-8',
40
+ padding === 'default' && [
41
+ 'pt-4 px-4 sm:pt-6 sm:px-6 lg:pt-8 lg:px-8',
42
+ 'pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] lg:pb-[calc(2rem+env(safe-area-inset-bottom,0px))]',
43
+ ],
33
44
  mobileFabClearance,
34
45
  );
35
46
 
@@ -48,9 +48,9 @@ function navDensityFromCount(n: number): NavDensity {
48
48
  * Nav rows use semantic sidebar tokens so light/dark follows ui-core theme vars.
49
49
  */
50
50
  const navItemClass = cn(
51
- 'border-0 font-normal shadow-none transition-colors',
52
- 'text-sidebar-foreground/70',
53
- 'data-[active=true]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
51
+ 'border-0 font-medium shadow-none transition-colors',
52
+ 'text-sidebar-foreground/80',
53
+ 'data-[active=true]:font-semibold data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
54
54
  'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
55
55
  'data-[active=true]:hover:bg-sidebar-accent',
56
56
  '[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
@@ -16,26 +16,31 @@
16
16
  * <PublicLayout
17
17
  * navbar={
18
18
  * <PublicNavbar
19
- * logo="/logo.svg"
20
- * siteName="My App"
21
- * navigation={[
22
- * { label: 'Home', href: '/' },
23
- * { label: 'Docs', href: '/docs' }
24
- * ]}
19
+ * config={{
20
+ * brand: <YourNavbarBrand />,
21
+ * navigation: [
22
+ * { label: 'Home', href: '/' },
23
+ * { label: 'Docs', href: '/docs' },
24
+ * ],
25
+ * }}
25
26
  * />
26
27
  * }
27
28
  * >
28
29
  * {children}
29
30
  * </PublicLayout>
30
31
  * ```
32
+ * Pass `brand` on `PublicNavbar` (`ReactNode` or string).
31
33
  */
32
34
 
33
35
  'use client';
34
36
 
35
37
  import { usePathname } from 'next/navigation';
36
- import { ReactNode, useEffect, useMemo, useState } from 'react';
38
+ import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
37
39
 
38
- import { PublicLayoutProvider } from './context';
40
+ import { cn } from '@djangocfg/ui-core/lib';
41
+
42
+ import { PublicLayoutProvider, usePublicLayoutOptional } from './context';
43
+ import type { PublicNavbarSurface } from './navbarTypes';
39
44
 
40
45
  export interface PublicLayoutProps {
41
46
  children: ReactNode;
@@ -48,36 +53,96 @@ export interface PublicLayoutProps {
48
53
  */
49
54
  navbar?: ReactNode;
50
55
  footer?: ReactNode;
56
+ /**
57
+ * When `auto` (default), `<main>` gets a small top offset from `PublicNavigation` surface
58
+ * (`floating` vs `flush`). Set `none` if the page controls spacing itself.
59
+ */
60
+ contentTopSpacing?: 'auto' | 'none';
61
+ /**
62
+ * When `auto` (default), `<main>` gets bottom padding before the footer so the last block of
63
+ * page content is not flush against the footer edge. Set `none` if the page/footer slot handles it.
64
+ * `compact` uses a smaller gap than `auto`.
65
+ */
66
+ contentBottomSpacing?: 'auto' | 'none' | 'compact';
67
+ }
68
+
69
+ function PublicMain({
70
+ children,
71
+ contentTopSpacing,
72
+ contentBottomSpacing,
73
+ }: {
74
+ children: ReactNode;
75
+ contentTopSpacing: 'auto' | 'none';
76
+ contentBottomSpacing: 'auto' | 'none' | 'compact';
77
+ }) {
78
+ const ctx = usePublicLayoutOptional();
79
+ const variant = ctx?.navbarSurface?.variant;
80
+
81
+ const topClass =
82
+ contentTopSpacing === 'none'
83
+ ? undefined
84
+ : !variant
85
+ ? 'pt-4 sm:pt-5'
86
+ : variant === 'floating'
87
+ ? 'pt-2 sm:pt-3 lg:pt-4'
88
+ : 'pt-1 sm:pt-2 lg:pt-3';
89
+
90
+ const bottomClass =
91
+ contentBottomSpacing === 'none'
92
+ ? undefined
93
+ : contentBottomSpacing === 'compact'
94
+ ? 'pb-4 sm:pb-6 lg:pb-8'
95
+ : 'pb-8 sm:pb-10 lg:pb-12';
96
+
97
+ return (
98
+ <main className={cn('flex-1', topClass, bottomClass)}>
99
+ {children}
100
+ </main>
101
+ );
51
102
  }
52
103
 
53
104
  export function PublicLayout({
54
105
  children,
55
106
  navbar,
56
107
  footer,
108
+ contentTopSpacing = 'auto',
109
+ contentBottomSpacing = 'auto',
57
110
  }: PublicLayoutProps) {
58
111
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
112
+ const [navbarSurface, setNavbarSurfaceState] = useState<PublicNavbarSurface | null>(null);
59
113
  const pathname = usePathname();
60
114
 
115
+ const setNavbarSurface = useCallback((surface: PublicNavbarSurface | null) => {
116
+ setNavbarSurfaceState(surface);
117
+ }, []);
118
+
61
119
  // Close mobile menu on route change
62
120
  useEffect(() => {
63
121
  setMobileMenuOpen(false);
64
122
  }, [pathname]);
65
123
 
66
- const contextValue = useMemo(() => ({
67
- mobileMenuOpen,
68
- toggleMobileMenu: () => setMobileMenuOpen((prev) => !prev),
69
- closeMobileMenu: () => setMobileMenuOpen(false),
70
- }), [
71
- mobileMenuOpen,
72
- ]);
124
+ const contextValue = useMemo(
125
+ () => ({
126
+ mobileMenuOpen,
127
+ toggleMobileMenu: () => setMobileMenuOpen((prev) => !prev),
128
+ closeMobileMenu: () => setMobileMenuOpen(false),
129
+ navbarSurface,
130
+ setNavbarSurface,
131
+ }),
132
+ [mobileMenuOpen, navbarSurface, setNavbarSurface],
133
+ );
73
134
 
74
135
  return (
75
136
  <PublicLayoutProvider value={contextValue}>
76
137
  <div className="min-h-screen flex flex-col">
77
138
  {navbar ?? null}
78
139
 
79
- {/* Main Content */}
80
- <main className="flex-1">{children}</main>
140
+ <PublicMain
141
+ contentTopSpacing={contentTopSpacing}
142
+ contentBottomSpacing={contentBottomSpacing}
143
+ >
144
+ {children}
145
+ </PublicMain>
81
146
 
82
147
  {footer ?? null}
83
148
  </div>
@@ -6,16 +6,15 @@
6
6
 
7
7
  import React from 'react';
8
8
 
9
- import { DjangoCFGLogo } from './DjangoCFGLogo';
10
9
  import { FooterSocialLinksComponent } from './FooterSocialLinks';
11
10
 
12
11
  import type { LucideIcon } from 'lucide-react';
13
12
  import type { FooterSocialLinks } from './types';
14
13
 
15
14
  export interface FooterProjectInfoProps {
16
- siteName?: string;
15
+ /** Brand row: custom node, or a plain string (styled title). */
16
+ brand?: React.ReactNode;
17
17
  description?: string;
18
- logo?: string;
19
18
  badge?: {
20
19
  icon: LucideIcon;
21
20
  text: string;
@@ -25,35 +24,29 @@ export interface FooterProjectInfoProps {
25
24
  }
26
25
 
27
26
  export function FooterProjectInfo({
28
- siteName,
27
+ brand,
29
28
  description,
30
- logo,
31
29
  badge,
32
30
  socialLinks,
33
31
  variant = 'desktop',
34
32
  }: FooterProjectInfoProps) {
35
33
  const isMobile = variant === 'mobile';
36
34
 
35
+ const showBrand = brand != null && brand !== '' && brand !== false;
36
+
37
37
  return (
38
38
  <div className={isMobile ? 'text-center space-y-4 mb-6' : 'space-y-4 lg:flex-shrink-0 lg:w-80'}>
39
- <div className={isMobile ? 'flex items-center justify-center gap-2' : 'flex items-center gap-2'}>
40
- {logo ? (
41
- <div className={isMobile ? 'w-6 h-6 flex items-center justify-center' : 'w-8 h-8 flex items-center justify-center'}>
42
- <img
43
- src={logo}
44
- alt={`${siteName || 'Project'} Logo`}
45
- className="w-full h-full object-contain"
46
- />
47
- </div>
48
- ) : (
49
- <DjangoCFGLogo size={isMobile ? 24 : 32} className="text-foreground" />
50
- )}
51
- {siteName && (
52
- <span className={isMobile ? 'text-lg font-bold text-foreground' : 'text-lg font-semibold text-foreground'}>
53
- {siteName}
54
- </span>
55
- )}
56
- </div>
39
+ {showBrand && (
40
+ <div className={isMobile ? 'flex items-center justify-center gap-2' : 'flex items-center gap-2'}>
41
+ {typeof brand === 'string' ? (
42
+ <span className={isMobile ? 'text-lg font-bold text-foreground' : 'text-lg font-semibold text-foreground'}>
43
+ {brand}
44
+ </span>
45
+ ) : (
46
+ brand
47
+ )}
48
+ </div>
49
+ )}
57
50
 
58
51
  {description && (
59
52
  <p className={isMobile ? 'text-muted-foreground text-sm leading-relaxed max-w-md mx-auto' : 'text-muted-foreground text-xs leading-relaxed max-w-xs'}>
@@ -63,7 +56,7 @@ export function FooterProjectInfo({
63
56
 
64
57
  {badge && !isMobile && (
65
58
  <div className="pt-2">
66
- <span className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-gradient-to-r from-primary/80 to-secondary/60 border border-primary/30 shadow-brand text-xs font-semibold text-primary-foreground">
59
+ <span className="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/15 px-3 py-1 text-xs font-semibold text-primary">
67
60
  <badge.icon className="w-4 h-4" />
68
61
  {badge.text}
69
62
  </span>
@@ -69,38 +69,40 @@ function ThemeModeControl() {
69
69
  );
70
70
  }
71
71
 
72
- export function PublicFooter({
73
- projectInfo,
74
- description,
75
- badge,
76
- socialLinks,
77
- links = [],
78
- menuSections = [],
79
- showProjectInfo = true,
80
- menuColumnMinWidth = 180,
81
- menuMaxColumns = 5,
82
- copyright: copyrightProp,
83
- credits: creditsProp,
84
- variant = 'full',
85
- containerClassName,
86
- i18n,
87
- }: PublicFooterProps) {
88
- // Prepare data BEFORE render
72
+ export function PublicFooter({ config }: PublicFooterProps) {
73
+ const variant = config.variant ?? 'full';
74
+ const shellClass = config.shell?.className;
75
+ const brandSlot = config.brand?.slot;
76
+ const description = config.brand?.description;
77
+ const badge = config.brand?.badge;
78
+ const showBrandColumn = config.brand?.showColumn ?? true;
79
+ const menuSections = config.menus?.sections ?? [];
80
+ const menuColumnMinWidth = config.menus?.columnMinWidth ?? 180;
81
+ const menuMaxColumns = config.menus?.maxColumns ?? 5;
82
+ const links = config.links ?? [];
83
+ const socialLinks = config.social;
84
+ const copyrightProp = config.meta?.copyright;
85
+ const creditsProp = config.meta?.credits;
86
+ const i18n = config.i18n;
87
+ const showThemeSwitcher = config.controls?.showThemeSwitcher !== false;
88
+ const showLocaleSwitcher =
89
+ config.controls?.showLocaleSwitcher !== false && Boolean(i18n);
90
+ const showFooterControlsRow = showThemeSwitcher || showLocaleSwitcher;
91
+
89
92
  const currentYear = new Date().getFullYear();
90
93
  const copyright = copyrightProp || `© ${currentYear}. All rights reserved.`;
91
94
  const credits = creditsProp;
92
95
 
93
- // Simple variant - minimal footer
96
+ /** Extra space below footer on devices with a home indicator / gesture bar. */
97
+ const footerSafeBottom = 'pb-[env(safe-area-inset-bottom,0px)]';
98
+
99
+ const footerSurfaceClass =
100
+ 'border-t border-border/60 bg-muted/35 mt-auto';
101
+
94
102
  if (variant === 'simple') {
95
103
  return (
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
- >
103
- <div className={`mx-auto px-4 py-4 ${containerClassName || 'w-full'}`}>
104
+ <footer className={`${footerSurfaceClass} ${footerSafeBottom}`}>
105
+ <div className={`mx-auto px-4 py-4 ${shellClass || 'w-full'}`}>
104
106
  <div className="text-center">
105
107
  <div className="text-sm text-muted-foreground">{copyright}</div>
106
108
  </div>
@@ -109,27 +111,13 @@ export function PublicFooter({
109
111
  );
110
112
  }
111
113
 
112
- // Compact variant - single line with logo and links
113
114
  if (variant === 'compact') {
114
115
  return (
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
- >
122
- <div className={`mx-auto px-4 sm:px-6 lg:px-8 py-8 ${containerClassName || 'w-full'}`}>
123
- {/* Main row: logo left, links right */}
116
+ <footer className={`${footerSurfaceClass} ${footerSafeBottom}`}>
117
+ <div className={`mx-auto px-4 sm:px-6 lg:px-8 py-8 ${shellClass || 'w-full'}`}>
124
118
  <div className="flex flex-col sm:flex-row items-center justify-between gap-6">
125
- {/* Logo + Name */}
126
- {projectInfo ?? (
127
- <div className="flex items-center gap-3">
128
- <span className="text-lg font-semibold text-foreground">Project</span>
129
- </div>
130
- )}
119
+ {brandSlot ? <div className="flex items-center gap-3">{brandSlot}</div> : null}
131
120
 
132
- {/* Links */}
133
121
  {links.length > 0 && (
134
122
  <div className="flex flex-wrap items-center justify-center gap-6">
135
123
  {links.map((link) =>
@@ -157,7 +145,6 @@ export function PublicFooter({
157
145
  )}
158
146
  </div>
159
147
 
160
- {/* Bottom row: copyright + credits */}
161
148
  <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">
162
149
  <span>{copyright}</span>
163
150
  {credits && (
@@ -182,16 +169,10 @@ export function PublicFooter({
182
169
 
183
170
  return (
184
171
  <>
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
- >
192
- <div className={`mx-auto px-4 py-8 ${containerClassName || 'w-full'}`}>
193
- {showProjectInfo && (
194
- projectInfo ?? (
172
+ <footer className={`lg:hidden ${footerSurfaceClass} ${footerSafeBottom}`}>
173
+ <div className={`mx-auto px-4 py-8 ${shellClass || 'w-full'}`}>
174
+ {showBrandColumn && (
175
+ brandSlot ?? (
195
176
  <FooterProjectInfo
196
177
  description={description}
197
178
  socialLinks={socialLinks}
@@ -200,7 +181,6 @@ export function PublicFooter({
200
181
  )
201
182
  )}
202
183
 
203
- {/* Quick Links */}
204
184
  {links.length > 0 && (
205
185
  <div className="flex flex-wrap justify-center gap-3 mb-6">
206
186
  {links.map((link) =>
@@ -242,33 +222,29 @@ export function PublicFooter({
242
222
  )}
243
223
  </div>
244
224
 
245
- <div className="mt-5 pt-4 border-t border-border/60 flex items-center justify-center gap-2">
246
- <ThemeModeControl />
247
- {i18n && (
248
- <LocaleSwitcher
249
- locale={i18n.locale}
250
- locales={i18n.locales}
251
- onChange={i18n.onLocaleChange}
252
- variant="outline"
253
- size="default"
254
- className="h-10 rounded-full border-border/60 bg-muted/30 text-sm hover:bg-muted/40"
255
- />
256
- )}
257
- </div>
225
+ {showFooterControlsRow && (
226
+ <div className="mt-5 flex items-center justify-center gap-2 border-t border-border/60 pt-4">
227
+ {showThemeSwitcher && <ThemeModeControl />}
228
+ {showLocaleSwitcher && i18n && (
229
+ <LocaleSwitcher
230
+ locale={i18n.locale}
231
+ locales={i18n.locales}
232
+ onChange={i18n.onLocaleChange}
233
+ variant="outline"
234
+ size="default"
235
+ className="h-10 rounded-full border-border/60 bg-muted/30 text-sm hover:bg-muted/40"
236
+ />
237
+ )}
238
+ </div>
239
+ )}
258
240
  </div>
259
241
  </footer>
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'}`}>
242
+ <footer className={`max-lg:hidden ${footerSurfaceClass} ${footerSafeBottom}`}>
243
+ <div className={`mx-auto px-6 sm:px-8 lg:px-10 py-14 ${shellClass || 'w-full'}`}>
268
244
  <div className="grid grid-cols-12 gap-10 lg:gap-14">
269
- {showProjectInfo && (
245
+ {showBrandColumn && (
270
246
  <div className="col-span-12 lg:col-span-4">
271
- {projectInfo ?? (
247
+ {brandSlot ?? (
272
248
  <FooterProjectInfo
273
249
  description={description}
274
250
  badge={badge}
@@ -279,7 +255,7 @@ export function PublicFooter({
279
255
  </div>
280
256
  )}
281
257
 
282
- <div className={showProjectInfo ? 'col-span-12 lg:col-span-8 lg:pl-8' : 'col-span-12'}>
258
+ <div className={showBrandColumn ? 'col-span-12 lg:col-span-8 lg:pl-8' : 'col-span-12'}>
283
259
  <FooterMenuSections
284
260
  menuSections={menuSections}
285
261
  minColumnWidth={menuColumnMinWidth}
@@ -288,12 +264,18 @@ export function PublicFooter({
288
264
  </div>
289
265
  </div>
290
266
 
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">
267
+ <div
268
+ className={
269
+ showFooterControlsRow
270
+ ? 'mt-12 grid grid-cols-1 items-center gap-4 border-t border-border/60 pt-5 lg:grid-cols-[1fr_auto_1fr] lg:gap-6'
271
+ : 'mt-12 flex flex-col gap-4 border-t border-border/60 pt-5 lg:flex-row lg:items-center lg:justify-between'
272
+ }
273
+ >
274
+ <div className="text-center text-xs text-muted-foreground lg:text-left lg:justify-self-start">
293
275
  {copyright}
294
276
  </div>
295
277
 
296
- <div className="min-w-0 flex items-center justify-center gap-3 text-xs text-muted-foreground flex-wrap">
278
+ <div className="flex min-w-0 flex-wrap items-center justify-center gap-3 text-center text-xs text-muted-foreground lg:justify-self-center">
297
279
  {credits && (
298
280
  credits.url ? (
299
281
  <a href={credits.url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors whitespace-nowrap">
@@ -322,19 +304,21 @@ export function PublicFooter({
322
304
  )}
323
305
  </div>
324
306
 
325
- <div className="flex items-center justify-center lg:justify-self-end gap-2">
326
- <ThemeModeControl />
327
- {i18n && (
328
- <LocaleSwitcher
329
- locale={i18n.locale}
330
- locales={i18n.locales}
331
- onChange={i18n.onLocaleChange}
332
- variant="outline"
333
- size="default"
334
- className="h-10 rounded-full border-border/60 bg-muted/30 text-sm hover:bg-muted/40"
335
- />
336
- )}
337
- </div>
307
+ {showFooterControlsRow && (
308
+ <div className="flex items-center justify-center gap-2 lg:justify-self-end">
309
+ {showThemeSwitcher && <ThemeModeControl />}
310
+ {showLocaleSwitcher && i18n && (
311
+ <LocaleSwitcher
312
+ locale={i18n.locale}
313
+ locales={i18n.locales}
314
+ onChange={i18n.onLocaleChange}
315
+ variant="outline"
316
+ size="default"
317
+ className="h-10 rounded-full border-border/60 bg-muted/30 text-sm hover:bg-muted/40"
318
+ />
319
+ )}
320
+ </div>
321
+ )}
338
322
  </div>
339
323
  </div>
340
324
  </footer>
@@ -4,6 +4,7 @@
4
4
 
5
5
  export { PublicFooter } from './PublicFooter';
6
6
  export { FooterProjectInfo } from './FooterProjectInfo';
7
+ export type { FooterProjectInfoProps } from './FooterProjectInfo';
7
8
  export { FooterMenuSections } from './FooterMenuSections';
8
9
  export { FooterBottom } from './FooterBottom';
9
10
  export { FooterSocialLinksComponent } from './FooterSocialLinks';
@@ -11,6 +12,7 @@ export { DjangoCFGLogo } from './DjangoCFGLogo';
11
12
 
12
13
  export type {
13
14
  PublicFooterProps,
15
+ PublicFooterConfig,
14
16
  FooterLink,
15
17
  FooterMenuSection,
16
18
  FooterSocialLinks,
@@ -29,39 +29,49 @@ export interface FooterSocialLinks {
29
29
  email?: string;
30
30
  }
31
31
 
32
- export interface PublicFooterProps {
33
- /** Custom project/brand block node (full control over footer brand area) */
34
- projectInfo?: ReactNode;
35
- /** Project description */
36
- description?: string;
37
- /** Optional badge */
38
- badge?: {
39
- icon: LucideIcon;
40
- text: string;
32
+ export interface PublicFooterConfig {
33
+ variant?: 'full' | 'compact' | 'simple';
34
+ shell?: {
35
+ className?: string;
36
+ };
37
+ brand?: {
38
+ /** Custom brand column (`ReactNode`) */
39
+ slot?: ReactNode;
40
+ description?: string;
41
+ showColumn?: boolean;
42
+ badge?: {
43
+ icon: LucideIcon;
44
+ text: string;
45
+ };
46
+ };
47
+ menus?: {
48
+ sections?: FooterMenuSection[];
49
+ columnMinWidth?: number;
50
+ maxColumns?: number;
41
51
  };
42
- /** Social media links */
43
- socialLinks?: FooterSocialLinks;
44
- /** Quick links (bottom bar) */
45
52
  links?: FooterLink[];
46
- /** Footer menu sections (desktop grid) */
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;
54
- /** Copyright text (auto-generated if not provided) */
55
- copyright?: string;
56
- /** Credits */
57
- credits?: {
58
- text: string;
59
- url?: string;
53
+ meta?: {
54
+ copyright?: string;
55
+ credits?: {
56
+ text: string;
57
+ url?: string;
58
+ };
60
59
  };
61
- /** Variant: full (with all sections), compact (single line with logo and links), or simple (minimal) */
62
- variant?: 'full' | 'compact' | 'simple';
63
- /** Custom className for content container (e.g. "max-w-4xl" or "container") */
64
- containerClassName?: string;
65
- /** i18n configuration for language switcher */
60
+ social?: FooterSocialLinks;
66
61
  i18n?: I18nLayoutConfig;
62
+ /**
63
+ * Full (`variant: 'full'`) footer only — bottom row with theme and/or locale controls.
64
+ * - Locale: still requires `i18n`; omit `i18n` to hide the language switcher, or set
65
+ * `showLocaleSwitcher: false` when you pass `i18n` but want the switcher elsewhere.
66
+ */
67
+ controls?: {
68
+ /** Light / system / dark toggle. @default true */
69
+ showThemeSwitcher?: boolean;
70
+ /** Requires `i18n`. @default true */
71
+ showLocaleSwitcher?: boolean;
72
+ };
73
+ }
74
+
75
+ export interface PublicFooterProps {
76
+ config: PublicFooterConfig;
67
77
  }