@djangocfg/layouts 2.1.427 → 2.1.428
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/README.md +29 -21
- package/package.json +15 -15
- package/src/components/errors/ErrorsTracker/components/ErrorToast.tsx +19 -0
- package/src/components/errors/ErrorsTracker/utils/curl-generator.ts +24 -10
- package/src/components/errors/README.md +63 -0
- package/src/layouts/AppLayout/BaseApp.tsx +7 -0
- package/src/layouts/AppLayout/README.md +79 -64
- package/src/layouts/AppLayout/index.ts +12 -19
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +0 -1
- package/src/layouts/PrivateLayout/README.md +30 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +1 -1
- package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +12 -3
- package/src/layouts/PrivateLayout/types.ts +0 -3
- package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +33 -30
- package/src/layouts/index.ts +0 -1
- package/src/utils/logger.ts +9 -4
- package/src/layouts/AdminLayout/AdminLayout.tsx +0 -57
- package/src/layouts/AdminLayout/index.ts +0 -7
- package/src/layouts/AppLayout/AppLayout.tsx +0 -513
package/README.md
CHANGED
|
@@ -13,38 +13,45 @@ pnpm add @djangocfg/layouts
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
```css
|
|
16
|
-
/*
|
|
17
|
-
@import "@djangocfg/ui-
|
|
16
|
+
/* Golden path — bundles Tailwind + tokens + base + utilities, layer-safe */
|
|
17
|
+
@import "@djangocfg/ui-core/styles/full";
|
|
18
18
|
@import "@djangocfg/layouts/styles";
|
|
19
|
-
@import "tailwindcss";
|
|
20
19
|
```
|
|
21
20
|
|
|
22
|
-
Peers: `@djangocfg/ui-core`,
|
|
21
|
+
Peers: `@djangocfg/ui-core`, React 19, Next.js 16+, Tailwind CSS 4.
|
|
23
22
|
|
|
24
23
|
---
|
|
25
24
|
|
|
26
25
|
## Quick start
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
Two responsibilities, kept separate:
|
|
28
|
+
|
|
29
|
+
1. **Providers** — mount `BaseApp` ONCE at the app root (theme, auth, i18n, SWR,
|
|
30
|
+
monitor, toasts). It's framework-agnostic (works in Wails/Vite too).
|
|
31
|
+
2. **Per-section shell** — each Next.js **route-group `layout.tsx`** imports the
|
|
32
|
+
matching shell (`PrivateLayout` / `PublicLayout`) and passes its own config.
|
|
33
|
+
No runtime layout-router, no `enabledPath` matching.
|
|
29
34
|
|
|
30
35
|
```tsx
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
noLayoutPaths: ['/embed'],
|
|
37
|
-
}}
|
|
38
|
-
baseApp={{ project: 'my-app', theme: { defaultTheme: 'system' }, auth: { apiUrl: '…' } }}
|
|
39
|
-
i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}
|
|
40
|
-
>
|
|
36
|
+
// app/[locale]/layout.tsx — providers, once
|
|
37
|
+
import { BaseApp } from '@djangocfg/layouts';
|
|
38
|
+
<BaseApp project="my-app" theme={{ defaultTheme: 'system' }}
|
|
39
|
+
auth={{ apiUrl: '…', routes: { auth: '/auth' } }}
|
|
40
|
+
i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}>
|
|
41
41
|
{children}
|
|
42
|
-
</
|
|
42
|
+
</BaseApp>
|
|
43
|
+
|
|
44
|
+
// app/[locale]/(pages)/private/layout.tsx — the private shell
|
|
45
|
+
import { PrivateLayout } from '@djangocfg/layouts';
|
|
46
|
+
<PrivateLayout sidebar={sidebar} header={header}>{children}</PrivateLayout>
|
|
43
47
|
```
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
See the [**BaseApp README**](./src/layouts/AppLayout/README.md) for the full
|
|
50
|
+
provider surface and the Next.js vs Wails wiring, and the demo app
|
|
51
|
+
(`apps/demo/app/_layouts`) for a complete reference.
|
|
46
52
|
|
|
47
|
-
> **Pass `i18n`** when using `next-intl` or any locale-prefixed routing
|
|
53
|
+
> **Pass `i18n`** when using `next-intl` or any locale-prefixed routing, so the
|
|
54
|
+
> link bridge and locale switcher resolve correctly.
|
|
48
55
|
|
|
49
56
|
---
|
|
50
57
|
|
|
@@ -55,8 +62,9 @@ Use `BaseApp` directly when you don't need route-based layout switching — see
|
|
|
55
62
|
| **`PublicLayout`** | Marketing / docs. Slots for navbar (`Floating`/`Flush`/`Minimal`) + footer + locale + auth controls. | [README](./src/layouts/PublicLayout/README.md) |
|
|
56
63
|
| **`PrivateLayout`** | Authenticated app shell — sidebar (collapsible icon rail, accordion groups, rail/featured/CTA slots) + popover account footer. | [README](./src/layouts/PrivateLayout/README.md) |
|
|
57
64
|
| **`AuthLayout`** | Sign-in / sign-up flows. Shell-based: `centered` (default) or `split` (two-column desktop). | [README](./src/layouts/AuthLayout/README.md) |
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
|
|
66
|
+
> **Admin** is not a separate layout — use `PrivateLayout` with an admin `sidebar`
|
|
67
|
+
> config in your `app/.../admin/layout.tsx`.
|
|
60
68
|
|
|
61
69
|
### `AuthLayout` shells
|
|
62
70
|
|
|
@@ -115,7 +123,7 @@ import { useLocaleSwitcher } from '@djangocfg/nextjs/i18n/client';
|
|
|
115
123
|
import { routing } from '@djangocfg/nextjs/i18n/routing';
|
|
116
124
|
|
|
117
125
|
const { locale, locales, changeLocale } = useLocaleSwitcher();
|
|
118
|
-
<
|
|
126
|
+
<BaseApp i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}>...</BaseApp>
|
|
119
127
|
```
|
|
120
128
|
|
|
121
129
|
When `routing` is set, `BaseApp` mounts a locale-aware `<Link>` adapter so every layout-rendered link keeps the active locale prefix. Drop it for default-locale-only apps.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.428",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -89,12 +89,12 @@
|
|
|
89
89
|
"check": "tsc --noEmit"
|
|
90
90
|
},
|
|
91
91
|
"peerDependencies": {
|
|
92
|
-
"@djangocfg/api": "^2.1.
|
|
93
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
94
|
-
"@djangocfg/debuger": "^2.1.
|
|
95
|
-
"@djangocfg/i18n": "^2.1.
|
|
96
|
-
"@djangocfg/monitor": "^2.1.
|
|
97
|
-
"@djangocfg/ui-core": "^2.1.
|
|
92
|
+
"@djangocfg/api": "^2.1.428",
|
|
93
|
+
"@djangocfg/centrifugo": "^2.1.428",
|
|
94
|
+
"@djangocfg/debuger": "^2.1.428",
|
|
95
|
+
"@djangocfg/i18n": "^2.1.428",
|
|
96
|
+
"@djangocfg/monitor": "^2.1.428",
|
|
97
|
+
"@djangocfg/ui-core": "^2.1.428",
|
|
98
98
|
"@hookform/resolvers": "^5.2.2",
|
|
99
99
|
"consola": "^3.4.2",
|
|
100
100
|
"lucide-react": "^0.545.0",
|
|
@@ -125,14 +125,14 @@
|
|
|
125
125
|
"uuid": "^11.1.0"
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@djangocfg/api": "^2.1.
|
|
129
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
130
|
-
"@djangocfg/debuger": "^2.1.
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
132
|
-
"@djangocfg/monitor": "^2.1.
|
|
133
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
134
|
-
"@djangocfg/ui-core": "^2.1.
|
|
135
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
128
|
+
"@djangocfg/api": "^2.1.428",
|
|
129
|
+
"@djangocfg/centrifugo": "^2.1.428",
|
|
130
|
+
"@djangocfg/debuger": "^2.1.428",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.428",
|
|
132
|
+
"@djangocfg/monitor": "^2.1.428",
|
|
133
|
+
"@djangocfg/typescript-config": "^2.1.428",
|
|
134
|
+
"@djangocfg/ui-core": "^2.1.428",
|
|
135
|
+
"@djangocfg/ui-tools": "^2.1.428",
|
|
136
136
|
"@types/node": "^25.2.3",
|
|
137
137
|
"@types/react": "^19.2.15",
|
|
138
138
|
"@types/react-dom": "^19.2.3",
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
|
|
11
|
+
import { isDev } from '@djangocfg/ui-core/lib';
|
|
12
|
+
|
|
11
13
|
import { extractDomain, formatErrorTitle, formatZodIssues, safeZodIssues } from '../utils/formatters';
|
|
12
14
|
import { ErrorButtons } from './ErrorButtons';
|
|
13
15
|
|
|
@@ -106,6 +108,17 @@ function buildNetworkDescription(
|
|
|
106
108
|
parts.push(`Status: ${detail.statusCode}`);
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
// DPoP (RFC 9449) 401s are easy to mistake for a plain auth failure. In dev,
|
|
112
|
+
// add a clear hint pointing at the cause. Detected from the backend's error
|
|
113
|
+
// message (codes dpop_proof_required / dpop_proof_invalid). Dev-only so end
|
|
114
|
+
// users never see internal proof wording.
|
|
115
|
+
const looksDpop =
|
|
116
|
+
detail.statusCode === 401 && /dpop/i.test(detail.error || '');
|
|
117
|
+
const dpopHint = isDev && looksDpop
|
|
118
|
+
? 'DPoP: this token is sender-constrained — the request needs a valid DPoP proof '
|
|
119
|
+
+ 'signed by the in-browser key. A copied/replayed token will 401.'
|
|
120
|
+
: null;
|
|
121
|
+
|
|
109
122
|
return (
|
|
110
123
|
<div className="flex flex-col gap-2 text-sm">
|
|
111
124
|
{parts.length > 0 && (
|
|
@@ -116,6 +129,12 @@ function buildNetworkDescription(
|
|
|
116
129
|
|
|
117
130
|
<div className="opacity-90">{detail.error}</div>
|
|
118
131
|
|
|
132
|
+
{dpopHint && (
|
|
133
|
+
<div className="rounded-md bg-amber-500/10 px-2 py-1 text-xs text-amber-600 dark:text-amber-400">
|
|
134
|
+
{dpopHint}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
|
|
119
138
|
<ErrorButtons detail={detail} />
|
|
120
139
|
</div>
|
|
121
140
|
);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Generates cURL commands from API request details with authentication token
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { auth, dpopEnabled } from '@djangocfg/api';
|
|
9
10
|
import consola from 'consola';
|
|
10
11
|
|
|
11
12
|
export interface CurlOptions {
|
|
@@ -19,18 +20,19 @@ export interface CurlOptions {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
* Get
|
|
23
|
+
* Get the current access token via the shared auth store.
|
|
24
|
+
*
|
|
25
|
+
* Must go through `auth.getToken()` — it reads the live token from whichever
|
|
26
|
+
* backend the app configured (localStorage *or* cookie) under the canonical
|
|
27
|
+
* `cfg.access_token` key. Reading raw localStorage keys here (the old
|
|
28
|
+
* `access_token`/`token`/`auth_token` guesses) never matched and always
|
|
29
|
+
* returned null, so the copied cURL had no Authorization header.
|
|
23
30
|
*/
|
|
24
31
|
export function getAuthToken(): string | null {
|
|
25
32
|
if (typeof window === 'undefined') return null;
|
|
26
33
|
|
|
27
34
|
try {
|
|
28
|
-
|
|
29
|
-
const token = localStorage.getItem('access_token') ||
|
|
30
|
-
localStorage.getItem('token') ||
|
|
31
|
-
localStorage.getItem('auth_token');
|
|
32
|
-
|
|
33
|
-
return token;
|
|
35
|
+
return auth.getToken();
|
|
34
36
|
} catch (error) {
|
|
35
37
|
consola.error('Failed to get auth token:', error);
|
|
36
38
|
return null;
|
|
@@ -84,8 +86,20 @@ export function generateCurl(options: CurlOptions): string {
|
|
|
84
86
|
...headers,
|
|
85
87
|
};
|
|
86
88
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
const dpop = dpopEnabled;
|
|
90
|
+
let preamble = '';
|
|
91
|
+
|
|
92
|
+
if (dpop) {
|
|
93
|
+
// DPoP on → the Bearer token is non-replayable from a terminal. Offer the
|
|
94
|
+
// API-key path instead (the supported way to script the API), with a
|
|
95
|
+
// placeholder the user fills from Settings → API keys.
|
|
96
|
+
allHeaders['X-API-Key'] = '<your-api-key>';
|
|
97
|
+
preamble =
|
|
98
|
+
'# This endpoint is DPoP-protected: a copied Bearer token returns 401\n' +
|
|
99
|
+
'# (it is bound to a non-extractable in-browser key, proof expires ~60s).\n' +
|
|
100
|
+
'# For scripts, use an API key — get one at: Settings → API keys.\n';
|
|
101
|
+
} else if (token) {
|
|
102
|
+
// No DPoP → a Bearer token works in a terminal as-is.
|
|
89
103
|
allHeaders['Authorization'] = `Bearer ${token}`;
|
|
90
104
|
}
|
|
91
105
|
|
|
@@ -102,7 +116,7 @@ export function generateCurl(options: CurlOptions): string {
|
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
// Join with line continuation
|
|
105
|
-
return curlParts.join(' \\\n ');
|
|
119
|
+
return preamble + curlParts.join(' \\\n ');
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
/**
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Errors — boundaries + runtime error tracking
|
|
2
|
+
|
|
3
|
+
In-app error surfacing for `@djangocfg/layouts`: React error boundaries, a global
|
|
4
|
+
runtime error tracker, and developer-facing toasts (with a Copy-as-cURL action).
|
|
5
|
+
|
|
6
|
+
## Pieces
|
|
7
|
+
|
|
8
|
+
| File | What it does |
|
|
9
|
+
|------|--------------|
|
|
10
|
+
| `ErrorBoundary.tsx` | React error boundary with a friendly fallback. |
|
|
11
|
+
| `MonitorBoundary.tsx` | Error boundary that also reports to `@djangocfg/monitor`. |
|
|
12
|
+
| `ErrorLayout.tsx` | Full-page error layout (`getErrorContent` / `ERROR_CODES`). |
|
|
13
|
+
| `ErrorsTracker/` | Global runtime tracker — listens for error CustomEvents and shows toasts. |
|
|
14
|
+
|
|
15
|
+
## ErrorsTracker
|
|
16
|
+
|
|
17
|
+
`ErrorTrackingProvider` listens on `window` for typed error events dispatched by
|
|
18
|
+
the generated API client and runtime, then renders a Sonner toast per error.
|
|
19
|
+
|
|
20
|
+
Tracked error types: `validation` (zod response mismatch), `network` (failed/4xx/5xx
|
|
21
|
+
requests, incl. a CORS heuristic), `centrifugo` (WS), and `runtime` (uncaught JS).
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { ErrorTrackingProvider } from '@djangocfg/layouts'; // (or the module path)
|
|
25
|
+
|
|
26
|
+
<ErrorTrackingProvider
|
|
27
|
+
validation={{ enabled: true }}
|
|
28
|
+
network={{ enabled: true, showStatusCode: true }}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</ErrorTrackingProvider>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Toast actions
|
|
35
|
+
|
|
36
|
+
Each toast has **Copy error** (JSON to clipboard) and, for requests, **Copy cURL**.
|
|
37
|
+
|
|
38
|
+
### Copy-as-cURL + DPoP
|
|
39
|
+
|
|
40
|
+
`utils/curl-generator.ts` builds a runnable cURL from the failed request:
|
|
41
|
+
|
|
42
|
+
- **DPoP off** — includes `Authorization: Bearer <token>` (read live via
|
|
43
|
+
`auth.getToken()`), so the command works as-is in a terminal.
|
|
44
|
+
- **DPoP on** (`dpopEnabled` from `@djangocfg/api`) — a copied Bearer token would
|
|
45
|
+
just `401` (it is bound to a non-extractable in-browser key and needs a fresh
|
|
46
|
+
proof that can't leave the browser). So the cURL instead emits an
|
|
47
|
+
`X-API-Key: <your-api-key>` placeholder plus a comment pointing to
|
|
48
|
+
**Settings → API keys** — the supported way to script the API (Stripe/GitHub
|
|
49
|
+
model). See `@djangocfg/nextjs/@docs/DPOP.md`.
|
|
50
|
+
|
|
51
|
+
### DPoP error hint
|
|
52
|
+
|
|
53
|
+
A `401` whose body mentions DPoP (`dpop_proof_required` / `dpop_proof_invalid`)
|
|
54
|
+
gets an extra **dev-only** hint in the toast explaining it's a sender-constraint
|
|
55
|
+
failure, so it isn't mistaken for a plain auth error. End users never see the
|
|
56
|
+
internal proof wording (gated by `isDev`).
|
|
57
|
+
|
|
58
|
+
## Notes
|
|
59
|
+
|
|
60
|
+
- Env/feature flags come from the single source of truth: `isDev` from
|
|
61
|
+
`@djangocfg/ui-core/lib`, `dpopEnabled` from `@djangocfg/api`.
|
|
62
|
+
- Errors also flow to `@djangocfg/monitor` → `@djangocfg/debuger` panel for
|
|
63
|
+
inspection regardless of toast visibility.
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* BaseApp - Core Providers Wrapper
|
|
3
3
|
*
|
|
4
|
+
* The canonical provider stack for ANY React host — it is framework-agnostic and
|
|
5
|
+
* does NOT depend on Next.js routing. Use it directly as the single top-level
|
|
6
|
+
* wrapper in environments that have no Next.js app router: **Wails desktop**,
|
|
7
|
+
* Electron, Vite/SPA, plain React. (In Next.js apps it's mounted once in the
|
|
8
|
+
* root `[locale]/layout.tsx`; per-route shells are chosen by native route-group
|
|
9
|
+
* `layout.tsx` files, not by this component.)
|
|
10
|
+
*
|
|
4
11
|
* Provides essential app-wide providers:
|
|
5
12
|
* - ThemeProvider (light/dark/system theme)
|
|
6
13
|
* - TooltipProvider (tooltip positioning)
|
|
@@ -1,88 +1,103 @@
|
|
|
1
|
-
#
|
|
1
|
+
# BaseApp — provider stack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`BaseApp` is the **single, framework-agnostic provider wrapper** for any React
|
|
4
|
+
host. It mounts theme, auth, i18n, SWR, error tracking, monitor, the debug
|
|
5
|
+
panel, and the router adapter — everything an app needs *above* the page tree.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
There is **no `AppLayout` layout-router** anymore. Picking which shell
|
|
8
|
+
(public / private / admin) wraps a route is the job of **native Next.js
|
|
9
|
+
route-group `layout.tsx` files**, not a runtime path-matcher. This is simpler,
|
|
10
|
+
RSC-friendly, and supports per-segment `loading.tsx` / `error.tsx`.
|
|
11
|
+
|
|
12
|
+
## Where it goes
|
|
13
|
+
|
|
14
|
+
### Next.js — mount once at the app root
|
|
6
15
|
|
|
7
16
|
```tsx
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
>
|
|
27
|
-
{children}
|
|
28
|
-
</AppLayout>
|
|
17
|
+
// app/[locale]/layout.tsx (or app/layout.tsx)
|
|
18
|
+
import { BaseApp } from '@djangocfg/layouts';
|
|
19
|
+
|
|
20
|
+
export default function RootLayout({ children }) {
|
|
21
|
+
return (
|
|
22
|
+
<html><body>
|
|
23
|
+
<BaseApp
|
|
24
|
+
project="my-app"
|
|
25
|
+
theme={{ defaultTheme: 'system', storageKey: 'myapp-theme' }}
|
|
26
|
+
auth={{ apiUrl: process.env.NEXT_PUBLIC_API_URL,
|
|
27
|
+
routes: { auth: '/auth', defaultCallback: '/private' } }}
|
|
28
|
+
// analytics / centrifugo / monitor / errorTracking / debug — all optional
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</BaseApp>
|
|
32
|
+
</body></html>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
29
35
|
```
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
Then each section owns its shell via a route-group layout:
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
```tsx
|
|
40
|
+
// app/[locale]/(pages)/private/layout.tsx
|
|
41
|
+
import { PrivateLayout } from '@djangocfg/layouts';
|
|
42
|
+
export default function PrivateRouteLayout({ children }) {
|
|
43
|
+
return <PrivateLayout sidebar={sidebar} header={header}>{children}</PrivateLayout>;
|
|
44
|
+
}
|
|
34
45
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
// app/[locale]/(marketing)/layout.tsx
|
|
47
|
+
import { PublicLayout } from '@djangocfg/layouts';
|
|
48
|
+
export default function PublicRouteLayout({ children }) {
|
|
49
|
+
return <PublicLayout navbar={<MyNavbar/>} footer={<MyFooter/>}>{children}</PublicLayout>;
|
|
50
|
+
}
|
|
51
|
+
```
|
|
41
52
|
|
|
42
|
-
|
|
53
|
+
`/auth/*` and other fullscreen pages just live outside those groups — no
|
|
54
|
+
`noLayoutPaths` config needed; they simply aren't wrapped.
|
|
43
55
|
|
|
44
|
-
|
|
56
|
+
> **Admin** is not a separate layout — it's `PrivateLayout` with a different
|
|
57
|
+
> `sidebar` config in `app/.../admin/layout.tsx`.
|
|
45
58
|
|
|
46
|
-
|
|
59
|
+
### Non-Next.js (Wails / Electron / Vite / plain React)
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
{ path: '/', theme: 'dark' },
|
|
51
|
-
{ path: '/legal/**', theme: 'light' },
|
|
52
|
-
]
|
|
53
|
-
```
|
|
61
|
+
`BaseApp` does not depend on Next routing — use it directly as the top-level
|
|
62
|
+
wrapper. This is exactly how the Wails desktop app mounts the framework.
|
|
54
63
|
|
|
55
|
-
|
|
64
|
+
```tsx
|
|
65
|
+
import { BaseApp } from '@djangocfg/layouts';
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
export function App() {
|
|
68
|
+
return <BaseApp project="desktop" theme={{ defaultTheme: 'dark' }}>{routes}</BaseApp>;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
58
71
|
|
|
59
|
-
##
|
|
72
|
+
## Config surface
|
|
60
73
|
|
|
61
74
|
```ts
|
|
62
|
-
interface
|
|
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;
|
|
75
|
+
interface BaseAppProps {
|
|
78
76
|
children: ReactNode;
|
|
77
|
+
project?: string; // monitor.project + debug panel title default
|
|
78
|
+
theme?: ThemeConfig; // defaultTheme, storageKey, style preset
|
|
79
|
+
auth?: AuthConfig | false; // false = no AuthProvider (fully public app)
|
|
80
|
+
analytics?: AnalyticsConfig; // Google Analytics
|
|
81
|
+
centrifugo?: CentrifugoConfig;
|
|
82
|
+
errorTracking?: ErrorTrackingConfig;
|
|
83
|
+
errorBoundary?: ErrorBoundaryConfig; // enabled by default
|
|
84
|
+
swr?: SWRConfigOptions;
|
|
85
|
+
monitor?: MonitorConfig;
|
|
86
|
+
debug?: DebugConfig; // debug panel (auto in dev / ?debug=1 in prod)
|
|
87
|
+
i18n?: I18nLayoutConfig; // locale + routing for the link bridge
|
|
79
88
|
}
|
|
80
89
|
```
|
|
81
90
|
|
|
82
|
-
|
|
91
|
+
## Per-route forced theme (optional)
|
|
92
|
+
|
|
93
|
+
To force a theme on a subtree (e.g. an always-dark marketing landing) without
|
|
94
|
+
locking the whole app, wrap that route-group's layout with `<ThemeOverride>` /
|
|
95
|
+
`<ForceTheme>` (re-exported here from `@djangocfg/ui-core/theme`). Because shell
|
|
96
|
+
selection is now per route-group, the override lives right where the route does.
|
|
83
97
|
|
|
84
98
|
## Related
|
|
85
99
|
|
|
86
|
-
- `
|
|
87
|
-
- `
|
|
88
|
-
|
|
100
|
+
- `PrivateLayout` / `PublicLayout` — the per-section shells (see their READMEs).
|
|
101
|
+
- `ErrorLayout` (`@djangocfg/layouts/components`) — universal error page for
|
|
102
|
+
`error.tsx` / `not-found.tsx`.
|
|
103
|
+
- `ForceTheme` / `ThemeOverride` (`@djangocfg/ui-core/theme`) — theme primitives.
|
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Provider stack exports.
|
|
3
|
+
*
|
|
4
|
+
* `BaseApp` is the canonical, framework-agnostic provider wrapper (theme, auth,
|
|
5
|
+
* i18n, monitor, error tracking…). Mount it ONCE at the app root. In Next.js
|
|
6
|
+
* apps, per-route shells are chosen by native route-group `layout.tsx` files
|
|
7
|
+
* (each imports `PrivateLayout` / `PublicLayout` and passes its own config) —
|
|
8
|
+
* there is no runtime layout-router component here anymore.
|
|
3
9
|
*/
|
|
4
10
|
|
|
5
|
-
export { AppLayout } from './AppLayout';
|
|
6
|
-
export type {
|
|
7
|
-
AppLayoutProps,
|
|
8
|
-
AppLayoutLayoutsConfig,
|
|
9
|
-
AppLayoutBaseAppConfig,
|
|
10
|
-
AppLayoutLayoutComponentProps,
|
|
11
|
-
AppLayoutPublicChrome,
|
|
12
|
-
LayoutMode,
|
|
13
|
-
I18nLayoutConfig,
|
|
14
|
-
PublicMainTopSpacing,
|
|
15
|
-
PublicMainBottomSpacing,
|
|
16
|
-
} from './AppLayout';
|
|
17
|
-
|
|
18
|
-
export { mergeAppLayoutPublicChrome } from './AppLayout';
|
|
19
|
-
|
|
20
11
|
export { BaseApp } from './BaseApp';
|
|
21
12
|
export type { BaseAppProps } from './BaseApp';
|
|
22
13
|
|
|
14
|
+
export type { I18nLayoutConfig } from '../types';
|
|
15
|
+
|
|
23
16
|
export {
|
|
24
17
|
LayoutI18nProvider,
|
|
25
18
|
useLayoutI18n,
|
|
@@ -27,8 +20,9 @@ export {
|
|
|
27
20
|
} from './LayoutI18nProvider';
|
|
28
21
|
export type { LayoutI18nProviderProps } from './LayoutI18nProvider';
|
|
29
22
|
|
|
30
|
-
// Re-export theme-override primitives so consumers can
|
|
31
|
-
//
|
|
23
|
+
// Re-export theme-override primitives so consumers can force a theme on a
|
|
24
|
+
// subtree (e.g. an always-dark marketing route) without depending on ui-core
|
|
25
|
+
// internals directly.
|
|
32
26
|
export {
|
|
33
27
|
ThemeOverride,
|
|
34
28
|
resolveForcedTheme,
|
|
@@ -40,4 +34,3 @@ export type {
|
|
|
40
34
|
ThemeOverrideProps,
|
|
41
35
|
ForcedTheme,
|
|
42
36
|
} from '@djangocfg/ui-core/theme';
|
|
43
|
-
|
|
@@ -13,7 +13,6 @@ import React, { ReactNode } from 'react';
|
|
|
13
13
|
import { Preloader } from '@djangocfg/ui-core/components';
|
|
14
14
|
import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
|
|
15
15
|
|
|
16
|
-
import type { AppLayoutPublicChrome } from '../AppLayout/AppLayout';
|
|
17
16
|
import type { LayoutVisualConfig } from '../types';
|
|
18
17
|
import { PrivateContent, PrivateSidebar } from './components';
|
|
19
18
|
import { useAuthGuard } from './hooks';
|
|
@@ -17,6 +17,36 @@ The auth guard redirects to `header.authPath` when there's no session. Pass `req
|
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
+
## Wiring (canonical)
|
|
21
|
+
|
|
22
|
+
Mount it in the **route-group `layout.tsx`** that owns the authenticated section
|
|
23
|
+
— a thin config wrapper. Providers (`BaseApp`) live once at the app root; this
|
|
24
|
+
file just chooses the private shell and feeds it sidebar/header config:
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
// app/[locale]/(pages)/private/layout.tsx
|
|
28
|
+
'use client';
|
|
29
|
+
import { PrivateLayout } from '@djangocfg/layouts';
|
|
30
|
+
import { usePathname } from '@djangocfg/nextjs/i18n/navigation';
|
|
31
|
+
|
|
32
|
+
export default function PrivateRouteLayout({ children }) {
|
|
33
|
+
const pathname = usePathname();
|
|
34
|
+
return (
|
|
35
|
+
<PrivateLayout sidebar={sidebar} header={header} pathname={pathname}>
|
|
36
|
+
{children}
|
|
37
|
+
</PrivateLayout>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Admin** is the same component with a different `sidebar` — there is no separate
|
|
43
|
+
`AdminLayout`. Put it in `app/.../admin/layout.tsx` with an admin menu config.
|
|
44
|
+
|
|
45
|
+
See the demo app (`apps/demo/app/_layouts/PrivateLayout.tsx`) for a complete
|
|
46
|
+
thin-wrapper reference, and the **BaseApp README** for the provider root.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
20
50
|
## Visual variants
|
|
21
51
|
|
|
22
52
|
`boxed` (default) — `<SidebarInset>` becomes a rounded card; the wrapper paints `bg-sidebar` so the brand colour bleeds to the viewport edges. Mobile (<md) degrades to full-bleed automatically.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Private layout main column — on narrow viewports a fixed menu FAB (`SidebarTrigger`) + scrollable area.
|
|
3
|
-
* On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `Drawer` from ui-
|
|
3
|
+
* On viewports below `md`, the desktop sidebar is off-canvas; the trigger opens the `Drawer` from the ui-core sidebar.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
'use client';
|
|
@@ -33,17 +33,26 @@ export function useAuthGuard({
|
|
|
33
33
|
const router = useRouter();
|
|
34
34
|
const [isRedirecting, setIsRedirecting] = useState(false);
|
|
35
35
|
|
|
36
|
+
// Auth state lives in the browser (storage), so the server always renders
|
|
37
|
+
// unauthenticated → the preloader. Keep the first client render identical to
|
|
38
|
+
// the server (preloader) until mounted, then switch to the real auth result.
|
|
39
|
+
// Without this, the SSR preloader vs the hydrated shell is a hydration
|
|
40
|
+
// mismatch (logged as "Invalid HTML tag nesting" / regenerated tree).
|
|
41
|
+
const [mounted, setMounted] = useState(false);
|
|
42
|
+
useEffect(() => setMounted(true), []);
|
|
43
|
+
|
|
36
44
|
useEffect(() => {
|
|
37
|
-
if (!requireAuth) return;
|
|
45
|
+
if (!mounted || !requireAuth) return;
|
|
38
46
|
if (!authLoading && !isAuthenticated && !isRedirecting) {
|
|
39
47
|
const currentUrl = window.location.pathname + window.location.search;
|
|
40
48
|
saveRedirectUrl(currentUrl);
|
|
41
49
|
setIsRedirecting(true);
|
|
42
50
|
router.push(authPath);
|
|
43
51
|
}
|
|
44
|
-
}, [requireAuth, isAuthenticated, authLoading, isRedirecting, router, saveRedirectUrl, authPath]);
|
|
52
|
+
}, [mounted, requireAuth, isAuthenticated, authLoading, isRedirecting, router, saveRedirectUrl, authPath]);
|
|
45
53
|
|
|
46
|
-
const isLoading =
|
|
54
|
+
const isLoading =
|
|
55
|
+
!mounted || (requireAuth && (authLoading || isRedirecting || !isAuthenticated));
|
|
47
56
|
const loadingText = isRedirecting ? 'Redirecting to login...' : 'Authenticating...';
|
|
48
57
|
|
|
49
58
|
return {
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import type { ReactNode } from 'react';
|
|
8
8
|
import type { LucideIcon } from 'lucide-react';
|
|
9
9
|
|
|
10
|
-
import type { AppLayoutPublicChrome } from '../AppLayout/AppLayout';
|
|
11
10
|
import type { SettingsDialogProps } from '../SettingsLayout/types';
|
|
12
11
|
import type { LayoutVisualConfig } from '../types';
|
|
13
12
|
import type { UserMenuConfig } from '../types';
|
|
@@ -224,8 +223,6 @@ export interface PrivateLayoutProps {
|
|
|
224
223
|
* embeds where there's no real session. Default `true` (guard on).
|
|
225
224
|
*/
|
|
226
225
|
requireAuth?: boolean;
|
|
227
|
-
/** Reserved for `AppLayout` passthrough (`publicChrome`); unused in this layout. */
|
|
228
|
-
publicChrome?: AppLayoutPublicChrome;
|
|
229
226
|
/**
|
|
230
227
|
* Mount the global SettingsDialog (Claude-style master/detail settings modal,
|
|
231
228
|
* hash-URL driven, openable via `useSettingsDialog()`). Pass a config object
|