@djangocfg/layouts 2.1.283 → 2.1.285
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -18
- package/src/components/errors/ErrorLayout.tsx +175 -57
- package/src/layouts/AppLayout/AppLayout.tsx +45 -1
- package/src/layouts/AppLayout/README.md +88 -0
- package/src/layouts/AppLayout/index.ts +14 -0
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +69 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.285",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -74,14 +74,14 @@
|
|
|
74
74
|
"check": "tsc --noEmit"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@djangocfg/api": "^2.1.
|
|
78
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
79
|
-
"@djangocfg/debuger": "^2.1.
|
|
80
|
-
"@djangocfg/i18n": "^2.1.
|
|
81
|
-
"@djangocfg/monitor": "^2.1.
|
|
82
|
-
"@djangocfg/ui-core": "^2.1.
|
|
83
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
84
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
77
|
+
"@djangocfg/api": "^2.1.285",
|
|
78
|
+
"@djangocfg/centrifugo": "^2.1.285",
|
|
79
|
+
"@djangocfg/debuger": "^2.1.285",
|
|
80
|
+
"@djangocfg/i18n": "^2.1.285",
|
|
81
|
+
"@djangocfg/monitor": "^2.1.285",
|
|
82
|
+
"@djangocfg/ui-core": "^2.1.285",
|
|
83
|
+
"@djangocfg/ui-nextjs": "^2.1.285",
|
|
84
|
+
"@djangocfg/ui-tools": "^2.1.285",
|
|
85
85
|
"@hookform/resolvers": "^5.2.2",
|
|
86
86
|
"consola": "^3.4.2",
|
|
87
87
|
"lucide-react": "^0.545.0",
|
|
@@ -110,15 +110,15 @@
|
|
|
110
110
|
"uuid": "^11.1.0"
|
|
111
111
|
},
|
|
112
112
|
"devDependencies": {
|
|
113
|
-
"@djangocfg/api": "^2.1.
|
|
114
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
115
|
-
"@djangocfg/debuger": "^2.1.
|
|
116
|
-
"@djangocfg/i18n": "^2.1.
|
|
117
|
-
"@djangocfg/monitor": "^2.1.
|
|
118
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
119
|
-
"@djangocfg/ui-core": "^2.1.
|
|
120
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
121
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
113
|
+
"@djangocfg/api": "^2.1.285",
|
|
114
|
+
"@djangocfg/centrifugo": "^2.1.285",
|
|
115
|
+
"@djangocfg/debuger": "^2.1.285",
|
|
116
|
+
"@djangocfg/i18n": "^2.1.285",
|
|
117
|
+
"@djangocfg/monitor": "^2.1.285",
|
|
118
|
+
"@djangocfg/typescript-config": "^2.1.285",
|
|
119
|
+
"@djangocfg/ui-core": "^2.1.285",
|
|
120
|
+
"@djangocfg/ui-nextjs": "^2.1.285",
|
|
121
|
+
"@djangocfg/ui-tools": "^2.1.285",
|
|
122
122
|
"@types/node": "^24.7.2",
|
|
123
123
|
"@types/react": "^19.1.0",
|
|
124
124
|
"@types/react-dom": "^19.1.0",
|
|
@@ -41,64 +41,182 @@ export interface ErrorLayoutProps {
|
|
|
41
41
|
supportEmail?: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Error glyphs — inline SVG, stroke-based.
|
|
46
|
+
*
|
|
47
|
+
* Inline on purpose: `ErrorLayout` renders inside Next.js error boundaries
|
|
48
|
+
* (`error.tsx`, `not-found.tsx`, `global-error.tsx`). When the root layout
|
|
49
|
+
* or a provider throws, the React tree is partially blown out — external
|
|
50
|
+
* icon libs (`lucide-react` et al.) may fail to resolve because their
|
|
51
|
+
* provider-dependent runtime is gone. Inline SVG has zero dependencies
|
|
52
|
+
* and ships in the same bundle as `ErrorLayout` itself, so it always renders.
|
|
53
|
+
*
|
|
54
|
+
* Design: 1.25px stroke, `currentColor`, no heavy filled circles. The visual
|
|
55
|
+
* weight comes from a soft radial glow on the wrapper, not the icon itself.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
interface GlyphSpec {
|
|
59
|
+
/** Accent tint used for the glow and the gradient stop. */
|
|
60
|
+
accent: string;
|
|
61
|
+
/** viewBox for the inner SVG. Default 24 24. */
|
|
62
|
+
viewBox?: string;
|
|
63
|
+
/** Inner SVG children. Must use `currentColor` for stroke. */
|
|
64
|
+
paths: React.ReactNode;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const GLYPH_SPECS: Record<string, GlyphSpec> = {
|
|
68
|
+
// 404 — magnifier over a page corner (not-found)
|
|
69
|
+
'404': {
|
|
70
|
+
accent: 'rgb(139 92 246)', // violet-500
|
|
71
|
+
paths: (
|
|
72
|
+
<>
|
|
73
|
+
<path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7.5" />
|
|
74
|
+
<path d="M13.5 3 19 8.5" />
|
|
75
|
+
<path d="M13 3v4a2 2 0 0 0 2 2h4" />
|
|
76
|
+
<circle cx="11" cy="14.5" r="2.5" />
|
|
77
|
+
<path d="m13 16.5 2 2" />
|
|
78
|
+
</>
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
// 500 — server stack with a warning bolt (server-side failure)
|
|
82
|
+
'500': {
|
|
83
|
+
accent: 'rgb(239 68 68)', // red-500
|
|
84
|
+
paths: (
|
|
85
|
+
<>
|
|
86
|
+
<rect x="3" y="4" width="18" height="6" rx="1.5" />
|
|
87
|
+
<rect x="3" y="14" width="18" height="6" rx="1.5" />
|
|
88
|
+
<path d="M7 7h.01" />
|
|
89
|
+
<path d="M7 17h.01" />
|
|
90
|
+
<path d="m15 6-2 4h3l-2 4" strokeLinejoin="round" />
|
|
91
|
+
</>
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
// 502 — two servers with a broken link between
|
|
95
|
+
'502': {
|
|
96
|
+
accent: 'rgb(249 115 22)', // orange-500
|
|
97
|
+
paths: (
|
|
98
|
+
<>
|
|
99
|
+
<rect x="3" y="4" width="8" height="16" rx="1.5" />
|
|
100
|
+
<rect x="13" y="4" width="8" height="16" rx="1.5" />
|
|
101
|
+
<path d="M6 8h.01" />
|
|
102
|
+
<path d="M16 8h.01" />
|
|
103
|
+
<path d="M11 11.5h-1" />
|
|
104
|
+
<path d="M14 12.5h-1" />
|
|
105
|
+
<path d="m11 10 2 4" strokeLinejoin="round" />
|
|
106
|
+
</>
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
// 503 — power off / plug pulled
|
|
110
|
+
'503': {
|
|
111
|
+
accent: 'rgb(249 115 22)', // orange-500
|
|
112
|
+
paths: (
|
|
113
|
+
<>
|
|
114
|
+
<path d="M12 2v10" />
|
|
115
|
+
<path d="M18.4 6.6a9 9 0 1 1-12.77 0" />
|
|
116
|
+
</>
|
|
117
|
+
),
|
|
118
|
+
},
|
|
119
|
+
// 504 — clock with slash (gateway timeout)
|
|
120
|
+
'504': {
|
|
121
|
+
accent: 'rgb(234 179 8)', // yellow-500
|
|
122
|
+
paths: (
|
|
123
|
+
<>
|
|
124
|
+
<circle cx="12" cy="12" r="9" />
|
|
125
|
+
<path d="M12 7v5l3 2" />
|
|
126
|
+
</>
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
// 408 — clock (request timeout)
|
|
130
|
+
'408': {
|
|
131
|
+
accent: 'rgb(234 179 8)', // yellow-500
|
|
132
|
+
paths: (
|
|
133
|
+
<>
|
|
134
|
+
<circle cx="12" cy="12" r="9" />
|
|
135
|
+
<path d="M12 7v5l3 2" />
|
|
136
|
+
</>
|
|
137
|
+
),
|
|
138
|
+
},
|
|
139
|
+
// 403 — shield with a prohibition line (forbidden)
|
|
140
|
+
'403': {
|
|
141
|
+
accent: 'rgb(239 68 68)', // red-500
|
|
142
|
+
paths: (
|
|
143
|
+
<>
|
|
144
|
+
<path d="M12 2 4 5v6c0 5 3.5 9 8 11 4.5-2 8-6 8-11V5l-8-3Z" />
|
|
145
|
+
<path d="m9 9 6 6" strokeLinejoin="round" />
|
|
146
|
+
</>
|
|
147
|
+
),
|
|
148
|
+
},
|
|
149
|
+
// 401 — key (auth required)
|
|
150
|
+
'401': {
|
|
151
|
+
accent: 'rgb(59 130 246)', // blue-500
|
|
152
|
+
paths: (
|
|
153
|
+
<>
|
|
154
|
+
<circle cx="8" cy="15" r="4" />
|
|
155
|
+
<path d="m10.8 12.2 8.2-8.2" />
|
|
156
|
+
<path d="m17 5 3 3" />
|
|
157
|
+
<path d="m15 7 2 2" />
|
|
158
|
+
</>
|
|
159
|
+
),
|
|
160
|
+
},
|
|
161
|
+
// 400 — general alert triangle (bad request)
|
|
162
|
+
'400': {
|
|
163
|
+
accent: 'rgb(234 179 8)', // yellow-500
|
|
164
|
+
paths: (
|
|
165
|
+
<>
|
|
166
|
+
<path d="m12 3 10 18H2L12 3Z" strokeLinejoin="round" />
|
|
167
|
+
<path d="M12 9v5" />
|
|
168
|
+
<path d="M12 17.5h.01" />
|
|
169
|
+
</>
|
|
170
|
+
),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const DEFAULT_GLYPH: GlyphSpec = {
|
|
175
|
+
accent: 'rgb(113 113 122)', // zinc-500
|
|
176
|
+
paths: (
|
|
177
|
+
<>
|
|
178
|
+
<circle cx="12" cy="12" r="9" />
|
|
179
|
+
<path d="M12 8v4" />
|
|
180
|
+
<path d="M12 16h.01" />
|
|
181
|
+
</>
|
|
182
|
+
),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Render the glyph for a given code, wrapped in a container with a soft radial
|
|
187
|
+
* glow behind the icon. Returns `null` if nothing should be shown.
|
|
188
|
+
*/
|
|
46
189
|
function getErrorIcon(code?: string | number): React.ReactNode {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
aria-hidden="true"
|
|
78
|
-
>
|
|
79
|
-
{/* Server Error Icon - Circle with Exclamation */}
|
|
80
|
-
<g>
|
|
81
|
-
<path d="M256 118c-76.1 0-138 61.88-138 138s61.9 138.05 138 138.05 138-61.93 138-138S332.1 118 256 118zm0 237.93a30.12 30.12 0 1 1 30.11-30.12A30.15 30.15 0 0 1 256 355.88zm30.11-80.31a8.48 8.48 0 0 1-8.47 8.48h-43.28a8.48 8.48 0 0 1-8.47-8.48v-111a8.47 8.47 0 0 1 8.47-8.47h43.28a8.47 8.47 0 0 1 8.47 8.47z" />
|
|
82
|
-
<path d="M256 312.6a13.17 13.17 0 1 0 13.16 13.16A13.17 13.17 0 0 0 256 312.6zM242.84 173.07h26.32v94.02h-26.32z" />
|
|
83
|
-
<path d="M256 0C114.62 0 0 114.62 0 256s114.62 256 256 256 256-114.62 256-256S397.38 0 256 0zm109.57 365.6A155 155 0 1 1 411 256a153.91 153.91 0 0 1-45.43 109.6z" />
|
|
84
|
-
</g>
|
|
85
|
-
</svg>
|
|
86
|
-
);
|
|
87
|
-
case '403':
|
|
88
|
-
return (
|
|
89
|
-
<svg
|
|
90
|
-
className="w-32 h-32 mx-auto text-foreground/80"
|
|
91
|
-
viewBox="0 0 24 24"
|
|
92
|
-
fill="currentColor"
|
|
93
|
-
aria-hidden="true"
|
|
94
|
-
>
|
|
95
|
-
{/* Forbidden Icon - Circle with X */}
|
|
96
|
-
<path d="M12 1a11 11 0 1 0 11 11A11.013 11.013 0 0 0 12 1zm4.242 13.829a1 1 0 1 1-1.414 1.414L12 13.414l-2.828 2.829a1 1 0 0 1-1.414-1.414L10.586 12 7.758 9.171a1 1 0 1 1 1.414-1.414L12 10.586l2.828-2.829a1 1 0 1 1 1.414 1.414L13.414 12z" />
|
|
97
|
-
</svg>
|
|
98
|
-
);
|
|
99
|
-
default:
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
190
|
+
if (code === undefined || code === null || code === '') return null;
|
|
191
|
+
const c = String(code);
|
|
192
|
+
const spec = GLYPH_SPECS[c] ?? DEFAULT_GLYPH;
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
className="relative mx-auto flex size-28 items-center justify-center"
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
>
|
|
199
|
+
{/* soft radial glow */}
|
|
200
|
+
<div
|
|
201
|
+
className="pointer-events-none absolute inset-0 rounded-full blur-2xl opacity-60"
|
|
202
|
+
style={{
|
|
203
|
+
background: `radial-gradient(circle at 50% 50%, ${spec.accent} 0%, transparent 65%)`,
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
{/* icon */}
|
|
207
|
+
<svg
|
|
208
|
+
className="relative size-20"
|
|
209
|
+
viewBox={spec.viewBox ?? '0 0 24 24'}
|
|
210
|
+
fill="none"
|
|
211
|
+
stroke="currentColor"
|
|
212
|
+
strokeWidth={1.25}
|
|
213
|
+
strokeLinecap="round"
|
|
214
|
+
style={{ color: spec.accent }}
|
|
215
|
+
>
|
|
216
|
+
{spec.paths}
|
|
217
|
+
</svg>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
102
220
|
}
|
|
103
221
|
|
|
104
222
|
/**
|
|
@@ -38,6 +38,7 @@ import { ClientOnly, Suspense } from '../../components/core';
|
|
|
38
38
|
import { usePathnameWithoutLocale } from '../../hooks';
|
|
39
39
|
import { matchesPath } from '../../utils/pathMatcher';
|
|
40
40
|
import { BaseApp } from './BaseApp';
|
|
41
|
+
import { ForcedThemeProvider, ThemeOverride, resolveForcedTheme } from '@djangocfg/ui-nextjs/theme';
|
|
41
42
|
|
|
42
43
|
import type {
|
|
43
44
|
ThemeConfig,
|
|
@@ -53,6 +54,7 @@ import type {
|
|
|
53
54
|
export type { I18nLayoutConfig } from '../types';
|
|
54
55
|
import type { AuthConfig } from '@djangocfg/api/auth';
|
|
55
56
|
import type { MonitorConfig } from '@djangocfg/monitor';
|
|
57
|
+
import type { ThemeOverrideRule } from '@djangocfg/ui-nextjs/theme';
|
|
56
58
|
|
|
57
59
|
import type { FloatingNavbarConfig } from '../PublicLayout/navbars/FloatingNavbar';
|
|
58
60
|
import type { DefaultFooterConfig } from '../PublicLayout/footers/DefaultFooter/types';
|
|
@@ -178,6 +180,23 @@ export interface AppLayoutLayoutsConfig {
|
|
|
178
180
|
authPath?: string;
|
|
179
181
|
/** Merged over root `publicChrome` on `AppLayout` (see `mergeAppLayoutPublicChrome`). */
|
|
180
182
|
publicChrome?: AppLayoutPublicChrome;
|
|
183
|
+
/**
|
|
184
|
+
* Per-route theme overrides. Each rule uses the same matcher as `enabledPath`
|
|
185
|
+
* (string, string[], or glob with `*` / `**`). When the pathname matches a
|
|
186
|
+
* rule, the forced theme is applied via `next-themes`; when navigation leaves
|
|
187
|
+
* the matched path, the user's previous theme choice is restored.
|
|
188
|
+
*
|
|
189
|
+
* Rules are evaluated top-to-bottom — first match wins.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```ts
|
|
193
|
+
* themeOverrides: [
|
|
194
|
+
* { path: '/', theme: 'dark' }, // marketing landing always dark
|
|
195
|
+
* { path: ['/legal', '/legal/**'], theme: 'light' }, // legal always light
|
|
196
|
+
* ]
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
themeOverrides?: ThemeOverrideRule[];
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
export interface AppLayoutBaseAppConfig {
|
|
@@ -260,6 +279,12 @@ export interface AppLayoutProps {
|
|
|
260
279
|
/** Base layer for `publicChrome`; `layouts.publicChrome` overlays this. */
|
|
261
280
|
publicChrome?: AppLayoutPublicChrome;
|
|
262
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Per-route theme overrides. Shortcut for `layouts.themeOverrides`.
|
|
284
|
+
* See `AppLayoutLayoutsConfig.themeOverrides` for the full docs.
|
|
285
|
+
*/
|
|
286
|
+
themeOverrides?: ThemeOverrideRule[];
|
|
287
|
+
|
|
263
288
|
/** Monitor configuration — initialises window.monitor + auto-captures JS errors & console */
|
|
264
289
|
monitor?: MonitorConfig;
|
|
265
290
|
|
|
@@ -275,6 +300,7 @@ interface AppLayoutContentProps {
|
|
|
275
300
|
authPath?: string;
|
|
276
301
|
i18n?: I18nLayoutConfig;
|
|
277
302
|
publicChrome?: AppLayoutPublicChrome;
|
|
303
|
+
themeOverrides?: ThemeOverrideRule[];
|
|
278
304
|
}
|
|
279
305
|
|
|
280
306
|
/**
|
|
@@ -292,6 +318,7 @@ function AppLayoutContent({
|
|
|
292
318
|
authPath = '/auth',
|
|
293
319
|
i18n,
|
|
294
320
|
publicChrome,
|
|
321
|
+
themeOverrides,
|
|
295
322
|
}: AppLayoutContentProps) {
|
|
296
323
|
// Use pathname without locale prefix for route matching.
|
|
297
324
|
// Pass locale from i18n so strip is exact — avoids regex misfiring on /ui, /ko, etc.
|
|
@@ -389,8 +416,23 @@ function AppLayoutContent({
|
|
|
389
416
|
}
|
|
390
417
|
};
|
|
391
418
|
|
|
419
|
+
// Prepare everything above the JSX — no inline conditionals in return().
|
|
420
|
+
const hasThemeOverrides = Boolean(themeOverrides && themeOverrides.length > 0);
|
|
421
|
+
const forcedTheme = hasThemeOverrides
|
|
422
|
+
? resolveForcedTheme(pathname, themeOverrides)
|
|
423
|
+
: null;
|
|
424
|
+
const themeOverrideElement = hasThemeOverrides
|
|
425
|
+
? <ThemeOverride pathname={pathname} rules={themeOverrides!} />
|
|
426
|
+
: null;
|
|
427
|
+
const layoutElement = renderLayout();
|
|
428
|
+
|
|
392
429
|
// No providers here - all providers now in BaseApp
|
|
393
|
-
return
|
|
430
|
+
return (
|
|
431
|
+
<ForcedThemeProvider value={forcedTheme}>
|
|
432
|
+
{themeOverrideElement}
|
|
433
|
+
{layoutElement}
|
|
434
|
+
</ForcedThemeProvider>
|
|
435
|
+
);
|
|
394
436
|
}
|
|
395
437
|
|
|
396
438
|
/**
|
|
@@ -406,6 +448,7 @@ export function AppLayout(props: AppLayoutProps) {
|
|
|
406
448
|
const noLayoutPaths = layoutsConfig?.noLayoutPaths ?? props.noLayoutPaths;
|
|
407
449
|
const authPath = layoutsConfig?.authPath ?? props.authPath;
|
|
408
450
|
const publicChrome = mergeAppLayoutPublicChrome(props.publicChrome, layoutsConfig?.publicChrome);
|
|
451
|
+
const themeOverrides = layoutsConfig?.themeOverrides ?? props.themeOverrides;
|
|
409
452
|
|
|
410
453
|
const {
|
|
411
454
|
i18n,
|
|
@@ -447,6 +490,7 @@ export function AppLayout(props: AppLayoutProps) {
|
|
|
447
490
|
authPath={authPath}
|
|
448
491
|
i18n={i18n}
|
|
449
492
|
publicChrome={publicChrome}
|
|
493
|
+
themeOverrides={themeOverrides}
|
|
450
494
|
/>
|
|
451
495
|
</BaseApp>
|
|
452
496
|
);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# AppLayout
|
|
2
|
+
|
|
3
|
+
Smart layout router + all-in-one providers wrapper for Next.js apps.
|
|
4
|
+
|
|
5
|
+
Picks `public` / `private` / `admin` layout based on the current pathname, mounts `BaseApp` (theme, auth, analytics, centrifugo, SWR, error boundary, monitor, PWA), and optionally forces a theme on specific routes.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { AppLayout } from '@djangocfg/layouts';
|
|
9
|
+
|
|
10
|
+
<AppLayout
|
|
11
|
+
layouts={{
|
|
12
|
+
public: { component: PublicLayout, enabledPath: ['/', '/about'] },
|
|
13
|
+
private: { component: DashboardLayout, enabledPath: '/dashboard' },
|
|
14
|
+
admin: { component: AdminLayout, enabledPath: '/admin' },
|
|
15
|
+
noLayoutPaths: ['/embed', '/print'],
|
|
16
|
+
themeOverrides: [
|
|
17
|
+
{ path: '/', theme: 'dark' }, // landing always dark
|
|
18
|
+
{ path: ['/legal', '/legal/**'], theme: 'light' }, // legal always light
|
|
19
|
+
],
|
|
20
|
+
}}
|
|
21
|
+
baseApp={{
|
|
22
|
+
theme: { defaultTheme: 'system', storageKey: 'myapp-theme' },
|
|
23
|
+
auth: { apiUrl: settings.api.baseUrl },
|
|
24
|
+
analytics: { googleTrackingId: 'G-...' },
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</AppLayout>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Path matcher
|
|
32
|
+
|
|
33
|
+
All `enabledPath` / `themeOverrides[].path` fields use the same matcher:
|
|
34
|
+
|
|
35
|
+
| Pattern | Matches |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `/dashboard` | `/dashboard`, `/dashboard/anything` (prefix) |
|
|
38
|
+
| `/projects/*/edit` | `/projects/123/edit` (single segment) |
|
|
39
|
+
| `/admin/**` | `/admin`, `/admin/a/b/c` (any depth) |
|
|
40
|
+
| `['/a', '/b']` | any of the listed paths |
|
|
41
|
+
|
|
42
|
+
Pathname is stripped of the locale prefix before matching (`/ru/dashboard` → `/dashboard`).
|
|
43
|
+
|
|
44
|
+
## themeOverrides
|
|
45
|
+
|
|
46
|
+
Per-route theme override — evaluated top-to-bottom, first match wins. While the pathname matches, the forced theme is applied via `next-themes`. When you navigate away, the user's previous choice is restored from `localStorage`.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
themeOverrides: [
|
|
50
|
+
{ path: '/', theme: 'dark' },
|
|
51
|
+
{ path: '/legal/**', theme: 'light' },
|
|
52
|
+
]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use this for marketing landings that must always be dark, legal/compliance pages that must always be readable, or print views that must always be light — without locking the whole app.
|
|
56
|
+
|
|
57
|
+
For a local, scoped override (a single section that needs different CSS vars without touching the global theme), use `<ForceTheme>` from `@djangocfg/ui-nextjs/theme` instead.
|
|
58
|
+
|
|
59
|
+
## Full config surface
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
interface AppLayoutProps {
|
|
63
|
+
layouts?: {
|
|
64
|
+
public?: { component; enabledPath? };
|
|
65
|
+
private?: { component; enabledPath? };
|
|
66
|
+
admin?: { component; enabledPath? };
|
|
67
|
+
noLayoutPaths?: string | string[];
|
|
68
|
+
authPath?: string; // default '/auth' — always fullscreen
|
|
69
|
+
publicChrome?: AppLayoutPublicChrome; // navbar/footer defaults
|
|
70
|
+
themeOverrides?: ThemeOverrideRule[];
|
|
71
|
+
};
|
|
72
|
+
baseApp?: {
|
|
73
|
+
project?; theme?; auth?; analytics?; centrifugo?;
|
|
74
|
+
errorTracking?; errorBoundary?; swr?; pwaInstall?;
|
|
75
|
+
monitor?; debug?;
|
|
76
|
+
};
|
|
77
|
+
i18n?: I18nLayoutConfig;
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Top-level shortcuts (`theme`, `auth`, `analytics`, `publicLayout`, …) mirror the nested fields and are merged — nested wins.
|
|
83
|
+
|
|
84
|
+
## Related
|
|
85
|
+
|
|
86
|
+
- `BaseApp` — the provider stack. Exported separately if you need it without the layout router.
|
|
87
|
+
- `ForceTheme` / `ThemeOverride` (`@djangocfg/ui-nextjs/theme`) — theme primitives used by `themeOverrides`.
|
|
88
|
+
- `ErrorLayout` (`@djangocfg/layouts/components`) — universal error page for `error.tsx` / `not-found.tsx`.
|
|
@@ -20,3 +20,17 @@ export { mergeAppLayoutPublicChrome } from './AppLayout';
|
|
|
20
20
|
export { BaseApp } from './BaseApp';
|
|
21
21
|
export type { BaseAppProps } from './BaseApp';
|
|
22
22
|
|
|
23
|
+
// Re-export theme-override primitives so consumers can import both the
|
|
24
|
+
// AppLayout config surface and the rule/theme types from one place.
|
|
25
|
+
export {
|
|
26
|
+
ThemeOverride,
|
|
27
|
+
resolveForcedTheme,
|
|
28
|
+
ForcedThemeProvider,
|
|
29
|
+
useForcedTheme,
|
|
30
|
+
} from '@djangocfg/ui-nextjs/theme';
|
|
31
|
+
export type {
|
|
32
|
+
ThemeOverrideRule,
|
|
33
|
+
ThemeOverrideProps,
|
|
34
|
+
ForcedTheme,
|
|
35
|
+
} from '@djangocfg/ui-nextjs/theme';
|
|
36
|
+
|
|
@@ -10,7 +10,7 @@ import React, { useEffect, useState } from 'react';
|
|
|
10
10
|
import { Laptop, Moon, Sun } from 'lucide-react';
|
|
11
11
|
|
|
12
12
|
import { Button } from '@djangocfg/ui-core/components';
|
|
13
|
-
import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
|
|
13
|
+
import { useForcedTheme, useThemeContext } from '@djangocfg/ui-nextjs/theme';
|
|
14
14
|
|
|
15
15
|
import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
|
|
16
16
|
import { useLinkComponent } from '../../primitives/LinkComponentContext';
|
|
@@ -19,51 +19,85 @@ import { FooterProjectInfo } from './FooterProjectInfo';
|
|
|
19
19
|
|
|
20
20
|
import type { DefaultFooterProps } from './types';
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
interface ThemeModeControlProps {
|
|
23
|
+
/** What to do when the route forces a theme via `ThemeOverride`. */
|
|
24
|
+
lockedBehavior?: 'disable' | 'hide';
|
|
25
|
+
/** Tooltip shown when locked. */
|
|
26
|
+
lockedTitle?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ThemeModeControl({
|
|
30
|
+
lockedBehavior = 'disable',
|
|
31
|
+
lockedTitle = 'Theme is set for this page',
|
|
32
|
+
}: ThemeModeControlProps) {
|
|
23
33
|
const { theme, setTheme } = useThemeContext();
|
|
34
|
+
const forcedTheme = useForcedTheme();
|
|
24
35
|
const [mounted, setMounted] = useState(false);
|
|
25
36
|
|
|
26
37
|
useEffect(() => {
|
|
27
38
|
setMounted(true);
|
|
28
39
|
}, []);
|
|
29
40
|
|
|
30
|
-
const
|
|
31
|
-
|
|
41
|
+
const isLocked = Boolean(forcedTheme);
|
|
42
|
+
// When locked, the active item reflects the forced theme — not the user's pick.
|
|
43
|
+
const currentTheme: 'system' | 'light' | 'dark' = isLocked
|
|
44
|
+
? forcedTheme!
|
|
45
|
+
: mounted
|
|
46
|
+
? ((theme as 'system' | 'light' | 'dark' | undefined) || 'system')
|
|
47
|
+
: 'system';
|
|
48
|
+
|
|
49
|
+
// Prepare item descriptors above JSX — no inline conditionals in the tree.
|
|
50
|
+
const items: Array<{
|
|
51
|
+
key: 'system' | 'light' | 'dark';
|
|
52
|
+
icon: typeof Laptop;
|
|
53
|
+
label: string;
|
|
54
|
+
}> = [
|
|
55
|
+
{ key: 'system', icon: Laptop, label: 'Use system theme' },
|
|
56
|
+
{ key: 'light', icon: Sun, label: 'Use light theme' },
|
|
57
|
+
{ key: 'dark', icon: Moon, label: 'Use dark theme' },
|
|
58
|
+
];
|
|
59
|
+
|
|
32
60
|
const baseItemClass = 'h-8 w-8 rounded-full p-0 text-muted-foreground hover:text-foreground';
|
|
33
61
|
const activeItemClass = 'bg-background/80 text-foreground shadow-sm';
|
|
62
|
+
const lockedItemClass = 'cursor-not-allowed opacity-60 hover:text-muted-foreground';
|
|
63
|
+
|
|
64
|
+
if (isLocked && lockedBehavior === 'hide') {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const containerClass = [
|
|
69
|
+
'inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/30 p-1',
|
|
70
|
+
isLocked ? 'cursor-not-allowed' : '',
|
|
71
|
+
].filter(Boolean).join(' ');
|
|
72
|
+
const containerTitle = isLocked ? lockedTitle : undefined;
|
|
34
73
|
|
|
35
74
|
return (
|
|
36
|
-
<div className=
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
onClick={() => setTheme('dark')}
|
|
63
|
-
aria-label="Use dark theme"
|
|
64
|
-
>
|
|
65
|
-
<Moon className="h-4 w-4" />
|
|
66
|
-
</Button>
|
|
75
|
+
<div className={containerClass} title={containerTitle} aria-disabled={isLocked}>
|
|
76
|
+
{items.map((item) => {
|
|
77
|
+
const Icon = item.icon;
|
|
78
|
+
const isActive = currentTheme === item.key;
|
|
79
|
+
const className = [
|
|
80
|
+
baseItemClass,
|
|
81
|
+
isActive ? activeItemClass : '',
|
|
82
|
+
isLocked ? lockedItemClass : '',
|
|
83
|
+
].filter(Boolean).join(' ');
|
|
84
|
+
const handleClick = isLocked ? undefined : () => setTheme(item.key);
|
|
85
|
+
return (
|
|
86
|
+
<Button
|
|
87
|
+
key={item.key}
|
|
88
|
+
type="button"
|
|
89
|
+
variant="ghost"
|
|
90
|
+
size="icon"
|
|
91
|
+
className={className}
|
|
92
|
+
onClick={handleClick}
|
|
93
|
+
disabled={isLocked}
|
|
94
|
+
aria-label={item.label}
|
|
95
|
+
title={isLocked ? lockedTitle : item.label}
|
|
96
|
+
>
|
|
97
|
+
<Icon className="h-4 w-4" />
|
|
98
|
+
</Button>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
67
101
|
</div>
|
|
68
102
|
);
|
|
69
103
|
}
|