@djangocfg/layouts 2.1.427 → 2.1.429

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 CHANGED
@@ -13,38 +13,45 @@ pnpm add @djangocfg/layouts
13
13
  ```
14
14
 
15
15
  ```css
16
- /* before @import "tailwindcss" */
17
- @import "@djangocfg/ui-nextjs/styles";
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`, `@djangocfg/ui-nextjs`, React 19, Next.js 16+, Tailwind CSS 4.
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
- `AppLayout` wraps `BaseApp` (theme, auth, analytics, SWR, toasts) and routes the page to the right shell based on path:
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
- <AppLayout
32
- layouts={{
33
- public: { component: PublicLayout, enabledPath: ['/', '/legal'] },
34
- private: { component: PrivateLayout, enabledPath: ['/dashboard'] },
35
- admin: { component: AdminLayout, enabledPath: '/admin' },
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
- </AppLayout>
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
- Use `BaseApp` directly when you don't need route-based layout switching — see [AppLayout README](./src/layouts/AppLayout/README.md) for the matching rules, `noLayoutPaths`, `publicChrome`, and `i18n.routing` plumbing.
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. Without it, the path matcher can mis-strip 2-letter segments (e.g. `/ui/*` treated as locale `ui`).
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
- | **`AdminLayout`** | Admin console. | — |
59
- | **`ProfileLayout`** | Profile pagesee below. | |
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
- <AppLayout i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}>...</AppLayout>
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.427",
3
+ "version": "2.1.429",
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.427",
93
- "@djangocfg/centrifugo": "^2.1.427",
94
- "@djangocfg/debuger": "^2.1.427",
95
- "@djangocfg/i18n": "^2.1.427",
96
- "@djangocfg/monitor": "^2.1.427",
97
- "@djangocfg/ui-core": "^2.1.427",
92
+ "@djangocfg/api": "^2.1.429",
93
+ "@djangocfg/centrifugo": "^2.1.429",
94
+ "@djangocfg/debuger": "^2.1.429",
95
+ "@djangocfg/i18n": "^2.1.429",
96
+ "@djangocfg/monitor": "^2.1.429",
97
+ "@djangocfg/ui-core": "^2.1.429",
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.427",
129
- "@djangocfg/centrifugo": "^2.1.427",
130
- "@djangocfg/debuger": "^2.1.427",
131
- "@djangocfg/i18n": "^2.1.427",
132
- "@djangocfg/monitor": "^2.1.427",
133
- "@djangocfg/typescript-config": "^2.1.427",
134
- "@djangocfg/ui-core": "^2.1.427",
135
- "@djangocfg/ui-tools": "^2.1.427",
128
+ "@djangocfg/api": "^2.1.429",
129
+ "@djangocfg/centrifugo": "^2.1.429",
130
+ "@djangocfg/debuger": "^2.1.429",
131
+ "@djangocfg/i18n": "^2.1.429",
132
+ "@djangocfg/monitor": "^2.1.429",
133
+ "@djangocfg/typescript-config": "^2.1.429",
134
+ "@djangocfg/ui-core": "^2.1.429",
135
+ "@djangocfg/ui-tools": "^2.1.429",
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 authentication token from localStorage
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
- // Priority order: access_token > token > auth_token
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
- // Add Authorization header if token exists
88
- if (token) {
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
- # AppLayout
1
+ # BaseApp — provider stack
2
2
 
3
- Smart layout router + all-in-one providers wrapper for Next.js apps.
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
- 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.
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
- 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>
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
- ## Path matcher
37
+ Then each section owns its shell via a route-group layout:
32
38
 
33
- All `enabledPath` / `themeOverrides[].path` fields use the same matcher:
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
- | 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 |
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
- Pathname is stripped of the locale prefix before matching (`/ru/dashboard` `/dashboard`).
53
+ `/auth/*` and other fullscreen pages just live outside those groups no
54
+ `noLayoutPaths` config needed; they simply aren't wrapped.
43
55
 
44
- ## themeOverrides
56
+ > **Admin** is not a separate layout — it's `PrivateLayout` with a different
57
+ > `sidebar` config in `app/.../admin/layout.tsx`.
45
58
 
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`.
59
+ ### Non-Next.js (Wails / Electron / Vite / plain React)
47
60
 
48
- ```ts
49
- themeOverrides: [
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
- 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.
64
+ ```tsx
65
+ import { BaseApp } from '@djangocfg/layouts';
56
66
 
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.
67
+ export function App() {
68
+ return <BaseApp project="desktop" theme={{ defaultTheme: 'dark' }}>{routes}</BaseApp>;
69
+ }
70
+ ```
58
71
 
59
- ## Full config surface
72
+ ## Config surface
60
73
 
61
74
  ```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;
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
- Top-level shortcuts (`theme`, `auth`, `analytics`, `publicLayout`, …) mirror the nested fields and are merged — nested wins.
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
- - `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`.
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
- * AppLayout exports
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 import both the
31
- // AppLayout config surface and the rule/theme types from one place.
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-nextjs sidebar.
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 = requireAuth && (authLoading || isRedirecting || !isAuthenticated);
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