@djangocfg/layouts 2.1.320 → 2.1.321
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 +41 -102
- package/package.json +27 -18
- package/src/configurator/index.ts +14 -0
- package/src/configurator/private/index.ts +6 -0
- package/src/configurator/private/schema.ts +190 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +35 -1
- package/src/layouts/PrivateLayout/README.md +129 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +1 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +207 -50
- package/src/layouts/PrivateLayout/index.ts +10 -1
- package/src/layouts/_components/PrivateSidebarAccount.tsx +12 -3
- package/src/layouts/_components/SidebarFeatured.tsx +70 -0
package/README.md
CHANGED
|
@@ -23,115 +23,68 @@ Peers: `@djangocfg/ui-core`, `@djangocfg/ui-nextjs`, React 19, Next.js 16+, Tail
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## Quick start
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
```tsx
|
|
31
|
-
<BaseApp
|
|
32
|
-
project="my-app"
|
|
33
|
-
theme={{ defaultTheme: 'system', storageKey: 'my-theme' }}
|
|
34
|
-
auth={{ apiUrl: process.env.NEXT_PUBLIC_API_URL }}
|
|
35
|
-
>
|
|
36
|
-
{children}
|
|
37
|
-
</BaseApp>
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
| Prop | Role |
|
|
41
|
-
|---|---|
|
|
42
|
-
| `project` | Name for monitor / debug. Enables `window.monitor`. |
|
|
43
|
-
| `theme` | `defaultTheme`, `storageKey`, and optional **`style`** (preset + CSS-var overrides). |
|
|
44
|
-
| `auth`, `analytics`, `centrifugo`, `errorTracking`, `errorBoundary`, `swr`, `pwaInstall`, `monitor`, `debug` | Optional integrations. |
|
|
45
|
-
|
|
46
|
-
### `theme.style`
|
|
47
|
-
|
|
48
|
-
Mounts `ThemeStyleBridge` → injects `<style id="djangocfg-baseapp-theme-style">` with `--*` variables. Merge order: imported globals → preset → `vars` (strongest).
|
|
49
|
-
|
|
50
|
-
| Piece | Meaning |
|
|
51
|
-
|---|---|
|
|
52
|
-
| `preset` | One of `THEME_STYLE_PRESET_ORDER`. Bundles live in `THEME_STYLE_PRESETS`. |
|
|
53
|
-
| `vars.light` / `vars.dark` | Partial `ThemeCssVarMap` — HSL triplets (`192 90% 35%`); `radius` accepts any CSS length. |
|
|
54
|
-
|
|
55
|
-
Presets: `default` · `django-cfg` · `ios` · `soft` · `dense` · `high-contrast`.
|
|
56
|
-
|
|
57
|
-
Playground-only buckets (shadows, typography, spacing) are **not** injected — export full CSS from the Theme Configurator when you need them.
|
|
58
|
-
|
|
59
|
-
Exports: `ThemeStyleConfig`, `ThemeCssVarKey`, `ThemeCssVarMap`, `ThemeStylePresetId`, `THEME_STYLE_PRESETS`, `THEME_STYLE_PRESET_ORDER`, `buildThemeStyleSheet`, `ThemeStyleBridge`.
|
|
60
|
-
|
|
61
|
-
---
|
|
62
|
-
|
|
63
|
-
## AppLayout
|
|
64
|
-
|
|
65
|
-
Wraps `BaseApp` and picks **admin → private → public** layout by path (`matchesPath` / `enabledPath`). `noLayoutPaths` skips the shell entirely (fullscreen / embeds). `publicChrome` merges navbar/footer/main spacing defaults.
|
|
28
|
+
`AppLayout` wraps `BaseApp` (theme, auth, analytics, SWR, toasts) and routes the page to the right shell based on path:
|
|
66
29
|
|
|
67
30
|
```tsx
|
|
68
31
|
<AppLayout
|
|
69
32
|
layouts={{
|
|
70
|
-
public: { component: PublicLayout, enabledPath: ['/', '/legal'
|
|
33
|
+
public: { component: PublicLayout, enabledPath: ['/', '/legal'] },
|
|
71
34
|
private: { component: PrivateLayout, enabledPath: ['/dashboard'] },
|
|
72
35
|
admin: { component: AdminLayout, enabledPath: '/admin' },
|
|
73
|
-
noLayoutPaths: ['/embed'
|
|
36
|
+
noLayoutPaths: ['/embed'],
|
|
74
37
|
}}
|
|
75
38
|
baseApp={{ project: 'my-app', theme: { defaultTheme: 'system' }, auth: { apiUrl: '…' } }}
|
|
76
|
-
i18n={{ locale, locales, onLocaleChange }}
|
|
39
|
+
i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}
|
|
77
40
|
>
|
|
78
41
|
{children}
|
|
79
42
|
</AppLayout>
|
|
80
43
|
```
|
|
81
44
|
|
|
82
|
-
|
|
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.
|
|
46
|
+
|
|
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`).
|
|
83
48
|
|
|
84
49
|
---
|
|
85
50
|
|
|
86
51
|
## Layouts
|
|
87
52
|
|
|
88
|
-
| Component | Use |
|
|
89
|
-
|
|
90
|
-
| **`PublicLayout`** | Marketing / docs. Slots for navbar
|
|
91
|
-
| **`PrivateLayout`** |
|
|
92
|
-
| **`AuthLayout`** | Sign-in flows. |
|
|
93
|
-
| **`AdminLayout`** | Admin console. |
|
|
94
|
-
| **`ProfileLayout`** | Profile page —
|
|
53
|
+
| Component | Use | Docs |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| **`PublicLayout`** | Marketing / docs. Slots for navbar (`Floating`/`Flush`/`Minimal`) + footer + locale + auth controls. | [README](./src/layouts/PublicLayout/README.md) |
|
|
56
|
+
| **`PrivateLayout`** | Authenticated app shell — sidebar (collapsible icon rail, accordion groups, rail/featured/CTA slots) + popover account footer. | [README](./src/layouts/PrivateLayout/README.md) |
|
|
57
|
+
| **`AuthLayout`** | Sign-in / sign-up flows. | [README](./src/layouts/AuthLayout/README.md) |
|
|
58
|
+
| **`AdminLayout`** | Admin console. | — |
|
|
59
|
+
| **`ProfileLayout`** | Profile page — see below. | |
|
|
95
60
|
|
|
96
|
-
### `
|
|
61
|
+
### `ProfileLayout`
|
|
97
62
|
|
|
98
63
|
```tsx
|
|
99
|
-
<
|
|
100
|
-
sidebar={sidebar}
|
|
101
|
-
header={header}
|
|
102
|
-
visual={{ variant: 'boxed', inset: 12, radius: '2xl', border: true }}
|
|
103
|
-
>
|
|
104
|
-
{children}
|
|
105
|
-
</PrivateLayout>
|
|
64
|
+
<ProfileLayout enable2FA enableDeleteAccount tabs={tabs} slots={slots} />
|
|
106
65
|
```
|
|
107
66
|
|
|
108
|
-
|
|
109
|
-
|
|
67
|
+
| Prop | Role |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `enable2FA` | Show Security tab with 2FA management. |
|
|
70
|
+
| `enableDeleteAccount` | Show Delete account in `⋯` menu. |
|
|
71
|
+
| `tabs` | Extra `ProfileTab[]` appended after built-ins. |
|
|
72
|
+
| `slots.headerBadge` / `headerMenuItems` / `headerAfter` / `footer` | Slot content around the avatar row, menu, and tab body. |
|
|
110
73
|
|
|
111
|
-
|
|
112
|
-
|---|---|---|---|
|
|
113
|
-
| `variant` | `'full-bleed' \| 'boxed'` | `'boxed'` | Switch between the two shells. |
|
|
114
|
-
| `inset` | `number \| { x?: number; y?: number }` | `12` | Gap (px) between the card and the viewport edges (md+). |
|
|
115
|
-
| `radius` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | `'2xl'` | Card corner radius. |
|
|
116
|
-
| `background` | `'sidebar' \| 'muted' \| 'card' \| 'background'` | `'sidebar'` | Canvas colour painted *behind* the boxed card. |
|
|
117
|
-
| `border` | `boolean` | `true` | 1px border on the card. |
|
|
118
|
-
| `maxWidth` | `'none' \| '7xl' \| 'screen-xl' \| 'screen-2xl'` | `'none'` | Optional inner content width cap. |
|
|
74
|
+
---
|
|
119
75
|
|
|
120
|
-
|
|
76
|
+
## Theme
|
|
121
77
|
|
|
122
|
-
|
|
123
|
-
<ProfileLayout enable2FA enableDeleteAccount tabs={tabs} slots={slots} />
|
|
124
|
-
```
|
|
78
|
+
`baseApp.theme.style` injects `<style id="djangocfg-baseapp-theme-style">` with `--*` CSS variables. Merge order: imported globals → preset → `vars` (strongest).
|
|
125
79
|
|
|
126
|
-
|
|
|
127
|
-
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
| `slots.footer` | `ReactNode` | Below all tab content. |
|
|
80
|
+
| Piece | Meaning |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `preset` | `default` · `django-cfg` · `ios` · `soft` · `dense` · `high-contrast`. |
|
|
83
|
+
| `vars.light` / `vars.dark` | Partial `ThemeCssVarMap` — HSL triplets (`192 90% 35%`); `radius` accepts any CSS length. |
|
|
84
|
+
|
|
85
|
+
Playground-only buckets (shadows, typography, spacing) are **not** injected — export full CSS from the Theme Configurator when you need them.
|
|
86
|
+
|
|
87
|
+
Exports: `ThemeStyleConfig`, `ThemeCssVarKey`, `ThemeCssVarMap`, `ThemeStylePresetId`, `THEME_STYLE_PRESETS`, `THEME_STYLE_PRESET_ORDER`, `buildThemeStyleSheet`, `ThemeStyleBridge`.
|
|
135
88
|
|
|
136
89
|
---
|
|
137
90
|
|
|
@@ -142,29 +95,16 @@ import { useLocaleSwitcher } from '@djangocfg/nextjs/i18n/client';
|
|
|
142
95
|
import { routing } from '@djangocfg/nextjs/i18n/routing';
|
|
143
96
|
|
|
144
97
|
const { locale, locales, changeLocale } = useLocaleSwitcher();
|
|
145
|
-
<AppLayout i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}>
|
|
146
|
-
{children}
|
|
147
|
-
</AppLayout>
|
|
98
|
+
<AppLayout i18n={{ locale, locales, onLocaleChange: changeLocale, routing }}>...</AppLayout>
|
|
148
99
|
```
|
|
149
100
|
|
|
150
|
-
`
|
|
101
|
+
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.
|
|
151
102
|
|
|
152
103
|
---
|
|
153
104
|
|
|
154
105
|
## Monitor & debug
|
|
155
106
|
|
|
156
|
-
`project`
|
|
157
|
-
|
|
158
|
-
---
|
|
159
|
-
|
|
160
|
-
## Utilities
|
|
161
|
-
|
|
162
|
-
| API | Role |
|
|
163
|
-
|---|---|
|
|
164
|
-
| `useErrorEmitter`, `emitRuntimeError` | Error tracking hooks. |
|
|
165
|
-
| `ErrorLayout` (`/components/errors`) | 404 / error pages. |
|
|
166
|
-
| `RedirectPage` | Auth redirect helper. |
|
|
167
|
-
| `PrivacyPage`, `TermsPage`, … (`/pages/legal`) | Legal templates. |
|
|
107
|
+
`baseApp.project` enables `window.monitor`. Debug panel: `Cmd+D` or `?debug=1`.
|
|
168
108
|
|
|
169
109
|
---
|
|
170
110
|
|
|
@@ -174,13 +114,12 @@ const { locale, locales, changeLocale } = useLocaleSwitcher();
|
|
|
174
114
|
|---|---|
|
|
175
115
|
| `@djangocfg/layouts` | Barrel. |
|
|
176
116
|
| `@djangocfg/layouts/layouts` | Layout components. |
|
|
177
|
-
| `@djangocfg/layouts/components` | Misc
|
|
178
|
-
| `@djangocfg/layouts/pages/legal` | Legal
|
|
117
|
+
| `@djangocfg/layouts/components` | Misc + `ErrorLayout`, `RedirectPage`. |
|
|
118
|
+
| `@djangocfg/layouts/pages/legal` | Legal page templates. |
|
|
119
|
+
| `@djangocfg/layouts/configurator` | JSON Schemas for `<JsonSchemaForm>`-driven configurator UIs (PrivateLayout for now). Pair with `@djangocfg/ui-tools`. |
|
|
179
120
|
| `@djangocfg/layouts/styles` | Base CSS. |
|
|
180
121
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
`@djangocfg/ext-newsletter`, `ext-knowbase`, `ext-leads`, `ext-payments`, `ext-support`, …
|
|
122
|
+
Extensions: `@djangocfg/ext-newsletter`, `ext-knowbase`, `ext-leads`, `ext-payments`, `ext-support`, …
|
|
184
123
|
|
|
185
124
|
## License
|
|
186
125
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.321",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -60,6 +60,16 @@
|
|
|
60
60
|
"import": "./src/pages/index.ts",
|
|
61
61
|
"require": "./src/pages/index.ts"
|
|
62
62
|
},
|
|
63
|
+
"./configurator": {
|
|
64
|
+
"types": "./src/configurator/index.ts",
|
|
65
|
+
"import": "./src/configurator/index.ts",
|
|
66
|
+
"require": "./src/configurator/index.ts"
|
|
67
|
+
},
|
|
68
|
+
"./configurator/private": {
|
|
69
|
+
"types": "./src/configurator/private/index.ts",
|
|
70
|
+
"import": "./src/configurator/private/index.ts",
|
|
71
|
+
"require": "./src/configurator/private/index.ts"
|
|
72
|
+
},
|
|
63
73
|
"./styles": "./src/styles/index.css",
|
|
64
74
|
"./styles/dashboard": "./src/styles/dashboard.css"
|
|
65
75
|
},
|
|
@@ -74,14 +84,13 @@
|
|
|
74
84
|
"check": "tsc --noEmit"
|
|
75
85
|
},
|
|
76
86
|
"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.320",
|
|
87
|
+
"@djangocfg/api": "^2.1.321",
|
|
88
|
+
"@djangocfg/centrifugo": "^2.1.321",
|
|
89
|
+
"@djangocfg/debuger": "^2.1.321",
|
|
90
|
+
"@djangocfg/i18n": "^2.1.321",
|
|
91
|
+
"@djangocfg/monitor": "^2.1.321",
|
|
92
|
+
"@djangocfg/ui-core": "^2.1.321",
|
|
93
|
+
"@djangocfg/ui-nextjs": "^2.1.321",
|
|
85
94
|
"@hookform/resolvers": "^5.2.2",
|
|
86
95
|
"consola": "^3.4.2",
|
|
87
96
|
"lucide-react": "^0.545.0",
|
|
@@ -111,15 +120,15 @@
|
|
|
111
120
|
"uuid": "^11.1.0"
|
|
112
121
|
},
|
|
113
122
|
"devDependencies": {
|
|
114
|
-
"@djangocfg/api": "^2.1.
|
|
115
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
116
|
-
"@djangocfg/debuger": "^2.1.
|
|
117
|
-
"@djangocfg/i18n": "^2.1.
|
|
118
|
-
"@djangocfg/monitor": "^2.1.
|
|
119
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
120
|
-
"@djangocfg/ui-core": "^2.1.
|
|
121
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
122
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
123
|
+
"@djangocfg/api": "^2.1.321",
|
|
124
|
+
"@djangocfg/centrifugo": "^2.1.321",
|
|
125
|
+
"@djangocfg/debuger": "^2.1.321",
|
|
126
|
+
"@djangocfg/i18n": "^2.1.321",
|
|
127
|
+
"@djangocfg/monitor": "^2.1.321",
|
|
128
|
+
"@djangocfg/typescript-config": "^2.1.321",
|
|
129
|
+
"@djangocfg/ui-core": "^2.1.321",
|
|
130
|
+
"@djangocfg/ui-nextjs": "^2.1.321",
|
|
131
|
+
"@djangocfg/ui-tools": "^2.1.321",
|
|
123
132
|
"@types/node": "^24.7.2",
|
|
124
133
|
"@types/react": "^19.1.0",
|
|
125
134
|
"@types/react-dom": "^19.1.0",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema descriptors for `<JsonSchemaForm>`-driven layout configurators.
|
|
3
|
+
*
|
|
4
|
+
* Drop a schema + uiSchema into `@djangocfg/ui-tools`'s `<JsonSchemaForm>` and
|
|
5
|
+
* you get a ready-made sidebar that edits the matching `<Layout>` props live.
|
|
6
|
+
*
|
|
7
|
+
* Public surface today:
|
|
8
|
+
* - `private` — `PrivateLayout` configurator (shell + sidebar + header)
|
|
9
|
+
*
|
|
10
|
+
* Public + theme configurators are not exported yet — they currently live in
|
|
11
|
+
* the demo app while the schema shape is iterated on.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export * from './private';
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema + uiSchema for a `PrivateLayout` configurator UI.
|
|
3
|
+
*
|
|
4
|
+
* The shape mirrors the real `PrivateLayout` runtime API:
|
|
5
|
+
* - `shell.*` → `visual` prop (`LayoutVisualConfig`)
|
|
6
|
+
* - `sidebar.*` → flags consumed by `SidebarConfig` (caller still maps these
|
|
7
|
+
* into a real `SidebarConfig` with concrete `groups`)
|
|
8
|
+
* - `header.*` → fields consumed by `HeaderConfig`
|
|
9
|
+
*
|
|
10
|
+
* Pair with `<JsonSchemaForm density="compact" schema={...} uiSchema={...}>`
|
|
11
|
+
* from `@djangocfg/ui-tools` to get a ready-made sidebar configurator. The
|
|
12
|
+
* `defaultPrivateLayoutConfiguratorData` is a safe initial value that already
|
|
13
|
+
* matches the schema (no nullable fields, no missing keys).
|
|
14
|
+
*
|
|
15
|
+
* Playground-only knobs (sample item count, banner toggles, etc.) live in the
|
|
16
|
+
* consuming app, not here — this stays a clean view of the real layout API.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { CustomJsonSchema7, CustomJsonUiSchema7 } from '@djangocfg/ui-core/lib';
|
|
20
|
+
|
|
21
|
+
/** Strongly-typed mirror of the schema — feed this back into PrivateLayout. */
|
|
22
|
+
export interface PrivateLayoutConfiguratorData {
|
|
23
|
+
shell: {
|
|
24
|
+
variant: 'full-bleed' | 'boxed';
|
|
25
|
+
inset: number;
|
|
26
|
+
radius: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
|
|
27
|
+
background: 'sidebar' | 'muted' | 'card' | 'background';
|
|
28
|
+
border: boolean;
|
|
29
|
+
maxWidth: 'none' | '7xl' | 'screen-xl' | 'screen-2xl';
|
|
30
|
+
};
|
|
31
|
+
sidebar: {
|
|
32
|
+
activeIndicator: 'background' | 'rail' | 'both';
|
|
33
|
+
groupLabelStyle: 'uppercase' | 'plain';
|
|
34
|
+
collapsibleGroups: boolean;
|
|
35
|
+
showFeatured: boolean;
|
|
36
|
+
};
|
|
37
|
+
header: {
|
|
38
|
+
userPlan: string;
|
|
39
|
+
showSecondaryAction: boolean;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const privateLayoutConfiguratorSchema: CustomJsonSchema7 = {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
shell: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
title: 'Shell',
|
|
49
|
+
properties: {
|
|
50
|
+
variant: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
title: 'Variant',
|
|
53
|
+
enum: ['full-bleed', 'boxed'],
|
|
54
|
+
description: '`boxed` paints a sidebar-coloured canvas around a rounded content card.',
|
|
55
|
+
},
|
|
56
|
+
inset: {
|
|
57
|
+
type: 'integer',
|
|
58
|
+
title: 'Inset (md+)',
|
|
59
|
+
minimum: 0,
|
|
60
|
+
maximum: 32,
|
|
61
|
+
description: 'Gap between the card and viewport edges.',
|
|
62
|
+
},
|
|
63
|
+
radius: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
title: 'Radius',
|
|
66
|
+
enum: ['sm', 'md', 'lg', 'xl', '2xl', '3xl'],
|
|
67
|
+
},
|
|
68
|
+
background: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
title: 'Canvas background',
|
|
71
|
+
enum: ['sidebar', 'muted', 'card', 'background'],
|
|
72
|
+
description: 'Colour painted behind the boxed card.',
|
|
73
|
+
},
|
|
74
|
+
border: {
|
|
75
|
+
type: 'boolean',
|
|
76
|
+
title: 'Border on card',
|
|
77
|
+
description: '1px hairline border around the boxed card.',
|
|
78
|
+
},
|
|
79
|
+
maxWidth: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
title: 'Inner max-width',
|
|
82
|
+
enum: ['none', '7xl', 'screen-xl', 'screen-2xl'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
sidebar: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
title: 'Sidebar',
|
|
89
|
+
properties: {
|
|
90
|
+
activeIndicator: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
title: 'Active indicator',
|
|
93
|
+
enum: ['background', 'rail', 'both'],
|
|
94
|
+
description: 'Visual treatment for the active nav item.',
|
|
95
|
+
},
|
|
96
|
+
groupLabelStyle: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
title: 'Group label style',
|
|
99
|
+
enum: ['uppercase', 'plain'],
|
|
100
|
+
description: 'Collapsible groups always render `plain` regardless of this setting.',
|
|
101
|
+
},
|
|
102
|
+
collapsibleGroups: {
|
|
103
|
+
type: 'boolean',
|
|
104
|
+
title: 'Collapsible groups',
|
|
105
|
+
description: 'Mailersend-style accordion: label becomes a clickable trigger.',
|
|
106
|
+
},
|
|
107
|
+
showFeatured: {
|
|
108
|
+
type: 'boolean',
|
|
109
|
+
title: 'Featured CTA tile',
|
|
110
|
+
description: 'Accent-tinted tile rendered below groups.',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
header: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
title: 'Header / account footer',
|
|
117
|
+
properties: {
|
|
118
|
+
userPlan: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
title: 'User plan',
|
|
121
|
+
enum: ['', 'Free plan', 'Pro plan', 'Max plan', 'Enterprise'],
|
|
122
|
+
description: 'Subtitle under the display name in the footer trigger. Empty hides it.',
|
|
123
|
+
},
|
|
124
|
+
showSecondaryAction: {
|
|
125
|
+
type: 'boolean',
|
|
126
|
+
title: 'Footer secondary action',
|
|
127
|
+
description: 'Adds a download-style icon button inside the footer trigger with a pulsing accent dot.',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const privateLayoutConfiguratorUiSchema: CustomJsonUiSchema7 = {
|
|
135
|
+
shell: {
|
|
136
|
+
'ui:collapsible': true,
|
|
137
|
+
inset: {
|
|
138
|
+
'ui:widget': 'slider',
|
|
139
|
+
'ui:options': { unit: 'px', step: 2, showInput: false },
|
|
140
|
+
'ui:disabledWhen': { path: 'shell.variant', notEq: 'boxed' },
|
|
141
|
+
},
|
|
142
|
+
radius: {
|
|
143
|
+
'ui:disabledWhen': { path: 'shell.variant', notEq: 'boxed' },
|
|
144
|
+
},
|
|
145
|
+
background: {
|
|
146
|
+
'ui:disabledWhen': { path: 'shell.variant', notEq: 'boxed' },
|
|
147
|
+
},
|
|
148
|
+
border: {
|
|
149
|
+
'ui:widget': 'switch',
|
|
150
|
+
'ui:disabledWhen': { path: 'shell.variant', notEq: 'boxed' },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
sidebar: {
|
|
154
|
+
'ui:collapsible': true,
|
|
155
|
+
'ui:groups': [
|
|
156
|
+
{
|
|
157
|
+
title: 'Visual',
|
|
158
|
+
fields: ['activeIndicator', 'groupLabelStyle', 'collapsibleGroups', 'showFeatured'],
|
|
159
|
+
defaultOpen: true,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
collapsibleGroups: { 'ui:widget': 'switch' },
|
|
163
|
+
showFeatured: { 'ui:widget': 'switch' },
|
|
164
|
+
},
|
|
165
|
+
header: {
|
|
166
|
+
'ui:collapsible': true,
|
|
167
|
+
showSecondaryAction: { 'ui:widget': 'switch' },
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const defaultPrivateLayoutConfiguratorData: PrivateLayoutConfiguratorData = {
|
|
172
|
+
shell: {
|
|
173
|
+
variant: 'boxed',
|
|
174
|
+
inset: 12,
|
|
175
|
+
radius: '2xl',
|
|
176
|
+
background: 'sidebar',
|
|
177
|
+
border: true,
|
|
178
|
+
maxWidth: 'none',
|
|
179
|
+
},
|
|
180
|
+
sidebar: {
|
|
181
|
+
activeIndicator: 'background',
|
|
182
|
+
groupLabelStyle: 'uppercase',
|
|
183
|
+
collapsibleGroups: false,
|
|
184
|
+
showFeatured: false,
|
|
185
|
+
},
|
|
186
|
+
header: {
|
|
187
|
+
userPlan: 'Pro plan',
|
|
188
|
+
showSecondaryAction: false,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
@@ -13,7 +13,7 @@ import { useRouter } from 'next/navigation';
|
|
|
13
13
|
|
|
14
14
|
import { useAuth } from '@djangocfg/api/auth';
|
|
15
15
|
import { Preloader } from '@djangocfg/ui-core/components';
|
|
16
|
-
import { SidebarInset, SidebarProvider } from '@djangocfg/ui-
|
|
16
|
+
import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
|
|
17
17
|
|
|
18
18
|
import type { AppLayoutPublicChrome } from '../AppLayout/AppLayout';
|
|
19
19
|
import type { LayoutVisualConfig } from '../types';
|
|
@@ -27,6 +27,8 @@ export interface SidebarItem {
|
|
|
27
27
|
href: string;
|
|
28
28
|
icon?: string | LucideIconType;
|
|
29
29
|
badge?: string | number;
|
|
30
|
+
/** Visual style of `badge`: `'count'` (default, neutral pill) or `'pill'` (accent-tinted, e.g. "lite"/"new"). */
|
|
31
|
+
badgeVariant?: 'count' | 'pill';
|
|
30
32
|
/** Collapsed rail: shown in tooltip; defaults to `label`. */
|
|
31
33
|
tooltip?: string;
|
|
32
34
|
}
|
|
@@ -38,6 +40,32 @@ export interface SidebarGroupConfig {
|
|
|
38
40
|
items: SidebarItem[];
|
|
39
41
|
/** If true, group is only shown when it has items (for dynamic groups like extensions) */
|
|
40
42
|
dynamic?: boolean;
|
|
43
|
+
/** Render group as an accordion (Mailersend/Vercel-style). Label becomes a clickable trigger. */
|
|
44
|
+
collapsible?: boolean;
|
|
45
|
+
/** Initial open state for collapsible groups. Auto-expanded if any child is active. Default `false`. */
|
|
46
|
+
defaultOpen?: boolean;
|
|
47
|
+
/** Icon for the group trigger (only when `collapsible`). */
|
|
48
|
+
icon?: string | LucideIconType;
|
|
49
|
+
/**
|
|
50
|
+
* Hide per-item icons inside the group. Defaults to `true` when `collapsible`,
|
|
51
|
+
* `false` otherwise (Mailersend convention: icons live on the trigger, not on children).
|
|
52
|
+
*/
|
|
53
|
+
hideItemIcons?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Active-state visual treatment for sidebar nav items. */
|
|
57
|
+
export type SidebarActiveIndicator = 'background' | 'rail' | 'both';
|
|
58
|
+
|
|
59
|
+
/** Rendering of group labels. `'uppercase'` is the legacy ultra-light caps; `'plain'` is sm bold. */
|
|
60
|
+
export type SidebarGroupLabelStyle = 'uppercase' | 'plain';
|
|
61
|
+
|
|
62
|
+
/** Featured CTA tile rendered below groups. */
|
|
63
|
+
export interface SidebarFeaturedConfig {
|
|
64
|
+
icon?: string | LucideIconType;
|
|
65
|
+
label: string;
|
|
66
|
+
href: string;
|
|
67
|
+
badge?: string;
|
|
68
|
+
accent?: 'green' | 'blue' | 'amber' | 'primary';
|
|
41
69
|
}
|
|
42
70
|
|
|
43
71
|
export interface SidebarConfig {
|
|
@@ -65,6 +93,12 @@ export interface SidebarConfig {
|
|
|
65
93
|
menuEndShowOnCollapsed?: boolean;
|
|
66
94
|
/** Custom footer component rendered at the bottom of the sidebar */
|
|
67
95
|
footer?: ReactNode;
|
|
96
|
+
/** Active-state visual on nav items. Default `'background'` (legacy). */
|
|
97
|
+
activeIndicator?: SidebarActiveIndicator;
|
|
98
|
+
/** Style of group labels. Default `'uppercase'` (legacy). Collapsible groups always use `plain`. */
|
|
99
|
+
groupLabelStyle?: SidebarGroupLabelStyle;
|
|
100
|
+
/** Featured CTA tile rendered below all groups, above `menuEnd`. */
|
|
101
|
+
featured?: SidebarFeaturedConfig;
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
export interface HeaderConfig {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# PrivateLayout
|
|
2
|
+
|
|
3
|
+
Authenticated app shell — collapsible sidebar (icon-rail vs expanded) + scrollable content area.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<PrivateLayout
|
|
7
|
+
sidebar={sidebar}
|
|
8
|
+
header={header}
|
|
9
|
+
pathname={usePathname()}
|
|
10
|
+
visual={{ variant: 'boxed', inset: 12, radius: '2xl', border: true }}
|
|
11
|
+
>
|
|
12
|
+
{children}
|
|
13
|
+
</PrivateLayout>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The auth guard redirects to `header.authPath` when there's no session. Pass `requireAuth={false}` to render without it (static showcases / playground).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Visual variants
|
|
21
|
+
|
|
22
|
+
`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.
|
|
23
|
+
|
|
24
|
+
`full-bleed` — content stretches edge-to-edge next to the sidebar (legacy look). Opt in with `visual={{ variant: 'full-bleed' }}`.
|
|
25
|
+
|
|
26
|
+
| Field | Type | Default | Notes |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| `variant` | `'full-bleed' \| 'boxed'` | `'boxed'` | Switch between the two shells. |
|
|
29
|
+
| `inset` | `number \| { x?: number; y?: number }` | `12` | Gap (px) between the card and the viewport edges (md+). |
|
|
30
|
+
| `radius` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | `'2xl'` | Card corner radius. |
|
|
31
|
+
| `background` | `'sidebar' \| 'muted' \| 'card' \| 'background'` | `'sidebar'` | Canvas colour painted *behind* the boxed card. |
|
|
32
|
+
| `border` | `boolean` | `true` | 1px border on the card. |
|
|
33
|
+
| `maxWidth` | `'none' \| '7xl' \| 'screen-xl' \| 'screen-2xl'` | `'none'` | Optional inner content width cap. |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Sidebar config
|
|
38
|
+
|
|
39
|
+
`SidebarConfig` (passed as `sidebar` prop):
|
|
40
|
+
|
|
41
|
+
| Field | Type | Notes |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `groups` | `SidebarGroupConfig[]` | Nav items grouped under labels. Set `group.label = ''` to render flat (no header). |
|
|
44
|
+
| `homeHref` | `string` | Brand link target. |
|
|
45
|
+
| `menuStart` / `menuEnd` | `ReactNode` | Free slots above / below `groups` in the scrollable column. |
|
|
46
|
+
| `menuStartShowOnCollapsed` / `menuEndShowOnCollapsed` | `boolean` | Keep slots visible on the icon rail. |
|
|
47
|
+
| `footer` | `ReactNode` | Custom footer node above the account block. |
|
|
48
|
+
| `activeIndicator` | `'background' \| 'rail' \| 'both'` | Active item visual. Default `'background'` (legacy). `'rail'` paints a 2px primary stripe on the right edge (Vercel-style). |
|
|
49
|
+
| `groupLabelStyle` | `'uppercase' \| 'plain'` | Label typography. Default `'uppercase'` (legacy ultra-light caps). Collapsible groups always render `plain`. |
|
|
50
|
+
| `featured` | `SidebarFeaturedConfig` | Accent-tinted CTA tile rendered below groups (Mailersend-style). |
|
|
51
|
+
|
|
52
|
+
`SidebarGroupConfig`:
|
|
53
|
+
|
|
54
|
+
| Field | Type | Notes |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `label` | `string` | Group title. Empty string → flat group (no header). |
|
|
57
|
+
| `items` | `SidebarItem[]` | Nav links. |
|
|
58
|
+
| `dynamic` | `boolean` | Hide group when `items` is empty (for extension-driven groups). |
|
|
59
|
+
| `collapsible` | `boolean` | Render as accordion. Label becomes a clickable trigger; auto-expanded if a child is active. Disabled on the icon rail. |
|
|
60
|
+
| `defaultOpen` | `boolean` | Initial open state for collapsible groups. |
|
|
61
|
+
| `icon` | `string \| LucideIcon` | Icon for the trigger (collapsible only). |
|
|
62
|
+
| `hideItemIcons` | `boolean` | Hide per-item icons inside the group. Defaults to `true` when `collapsible`. |
|
|
63
|
+
|
|
64
|
+
`SidebarItem`:
|
|
65
|
+
|
|
66
|
+
| Field | Type | Notes |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `label`, `href`, `icon`, `tooltip` | — | Standard nav item fields. |
|
|
69
|
+
| `badge` | `string \| number` | Trailing badge content. |
|
|
70
|
+
| `badgeVariant` | `'count' \| 'pill'` | Visual style. `'pill'` is accent-tinted (e.g. "lite"/"new"); `'count'` is neutral (default). |
|
|
71
|
+
|
|
72
|
+
`SidebarFeaturedConfig`:
|
|
73
|
+
|
|
74
|
+
| Field | Type | Notes |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `icon`, `label`, `href` | — | Tile content. |
|
|
77
|
+
| `badge` | `string` | Inline pill (e.g. "lite"). |
|
|
78
|
+
| `accent` | `'green' \| 'blue' \| 'amber' \| 'primary'` | Tile tint. Default `'green'`. |
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Header / account footer
|
|
83
|
+
|
|
84
|
+
`HeaderConfig` (passed as `header` prop):
|
|
85
|
+
|
|
86
|
+
| Field | Type | Notes |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `brand`, `title`, `brandIcon`, `brandLetter` | — | Sidebar header brand. |
|
|
89
|
+
| `groups` | `UserMenuGroup[]` | Account links rendered inside the footer dropdown. |
|
|
90
|
+
| `authPath` | `string` | Sign-in redirect target. |
|
|
91
|
+
| `userPlan` | `string` | Subtitle under the display name (e.g. `"Max plan"`). |
|
|
92
|
+
| `footerSecondaryAction` | `{ icon, href?, onClick?, ariaLabel, pulse? }` | Optional secondary icon button inside the footer trigger (e.g. "Get apps" with pulsing dot). |
|
|
93
|
+
|
|
94
|
+
The footer button opens a popover (`DropdownMenu`, `side="top"`) with: email, account links, **Language** (opens fullscreen `LocaleSwitcherDialog` if `LayoutI18nProvider` is mounted), **Theme** (cycles `light → dark → system`), and **Log out**. In dev mode the footer renders a `Guest (dev)` placeholder when there's no authenticated user, so debug controls stay reachable; in production the block hides itself.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Sample
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
const sidebar: SidebarConfig = {
|
|
102
|
+
homeHref: '/',
|
|
103
|
+
activeIndicator: 'rail',
|
|
104
|
+
groupLabelStyle: 'plain',
|
|
105
|
+
groups: [
|
|
106
|
+
{
|
|
107
|
+
label: 'Email',
|
|
108
|
+
collapsible: true,
|
|
109
|
+
icon: 'Mail',
|
|
110
|
+
defaultOpen: true,
|
|
111
|
+
items: [
|
|
112
|
+
{ label: 'Domains', href: '/email/domains' },
|
|
113
|
+
{ label: 'Activity', href: '/email/activity' },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{ label: 'SMS', collapsible: true, icon: 'Smartphone', items: [...] },
|
|
117
|
+
],
|
|
118
|
+
featured: { icon: 'Sparkles', label: 'Marketing', href: '/marketing', badge: 'lite', accent: 'green' },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const header: HeaderConfig = {
|
|
122
|
+
title: 'Dashboard',
|
|
123
|
+
brandIcon: 'Layers',
|
|
124
|
+
authPath: '/auth',
|
|
125
|
+
userPlan: 'Pro plan',
|
|
126
|
+
footerSecondaryAction: { icon: 'Download', href: '/apps', ariaLabel: 'Get apps', pulse: true },
|
|
127
|
+
groups: [{ title: 'Account', items: [{ label: 'Settings', href: '/settings' }] }],
|
|
128
|
+
};
|
|
129
|
+
```
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import React, { ReactNode } from 'react';
|
|
9
9
|
|
|
10
|
-
import { SidebarTrigger, useSidebar } from '@djangocfg/ui-
|
|
10
|
+
import { SidebarTrigger, useSidebar } from '@djangocfg/ui-core/components';
|
|
11
11
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
12
|
|
|
13
13
|
import type { LayoutVisualConfig } from '../../types';
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
'use client';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
Collapsible,
|
|
10
|
+
CollapsibleContent,
|
|
11
|
+
CollapsibleTrigger,
|
|
12
|
+
Link,
|
|
13
|
+
} from '@djangocfg/ui-core/components';
|
|
14
|
+
import { ChevronDown } from 'lucide-react';
|
|
9
15
|
import { usePathname as useNextPathname } from 'next/navigation';
|
|
10
16
|
import React from 'react';
|
|
11
17
|
|
|
@@ -23,13 +29,21 @@ import {
|
|
|
23
29
|
SidebarMenuItem,
|
|
24
30
|
SidebarTrigger,
|
|
25
31
|
useSidebar,
|
|
26
|
-
} from '@djangocfg/ui-
|
|
32
|
+
} from '@djangocfg/ui-core/components';
|
|
27
33
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
28
34
|
|
|
29
35
|
import { PrivateSidebarAccount } from '../../_components/PrivateSidebarAccount';
|
|
30
36
|
import { LucideIcon } from '../../../components';
|
|
31
37
|
|
|
32
|
-
import type {
|
|
38
|
+
import type {
|
|
39
|
+
HeaderConfig,
|
|
40
|
+
SidebarActiveIndicator,
|
|
41
|
+
SidebarConfig,
|
|
42
|
+
SidebarGroupConfig,
|
|
43
|
+
SidebarGroupLabelStyle,
|
|
44
|
+
SidebarItem,
|
|
45
|
+
} from '../PrivateLayout';
|
|
46
|
+
import { SidebarFeatured } from '../../_components/SidebarFeatured';
|
|
33
47
|
|
|
34
48
|
/** Few items → roomier rows; many items → tighter. Same breakpoints for demo, CarAPIS, etc. */
|
|
35
49
|
const DENSITY_COMFORTABLE_MAX = 6;
|
|
@@ -46,17 +60,36 @@ function navDensityFromCount(n: number): NavDensity {
|
|
|
46
60
|
/**
|
|
47
61
|
* Nav rows use semantic sidebar tokens so light/dark follows ui-core theme vars.
|
|
48
62
|
*/
|
|
49
|
-
const
|
|
50
|
-
'group/nav border-0 font-medium shadow-none transition-colors',
|
|
63
|
+
const navItemBaseClass = cn(
|
|
64
|
+
'group/nav relative border-0 font-medium shadow-none transition-colors',
|
|
51
65
|
'text-sidebar-foreground/80',
|
|
52
|
-
'data-[active=true]:font-semibold data-[active=true]:
|
|
66
|
+
'data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground',
|
|
53
67
|
'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
|
|
54
|
-
'data-[active=true]:hover:bg-sidebar-accent',
|
|
55
68
|
'[&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/70 [&>svg]:opacity-85',
|
|
56
69
|
'[&>svg]:transition-transform [&>svg]:duration-200 group-hover/nav:[&>svg]:scale-110 group-active/nav:[&>svg]:scale-95',
|
|
57
70
|
'data-[active=true]:[&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:opacity-100',
|
|
58
71
|
);
|
|
59
72
|
|
|
73
|
+
const ACTIVE_INDICATOR_CLASS: Record<SidebarActiveIndicator, string> = {
|
|
74
|
+
background: cn(
|
|
75
|
+
'data-[active=true]:bg-sidebar-accent',
|
|
76
|
+
'data-[active=true]:hover:bg-sidebar-accent',
|
|
77
|
+
),
|
|
78
|
+
rail: cn(
|
|
79
|
+
'data-[active=true]:after:absolute data-[active=true]:after:right-0',
|
|
80
|
+
'data-[active=true]:after:top-1.5 data-[active=true]:after:bottom-1.5',
|
|
81
|
+
'data-[active=true]:after:w-[2px] data-[active=true]:after:rounded-l-full',
|
|
82
|
+
'data-[active=true]:after:bg-primary',
|
|
83
|
+
),
|
|
84
|
+
both: cn(
|
|
85
|
+
'data-[active=true]:bg-sidebar-accent data-[active=true]:hover:bg-sidebar-accent',
|
|
86
|
+
'data-[active=true]:after:absolute data-[active=true]:after:right-0',
|
|
87
|
+
'data-[active=true]:after:top-1.5 data-[active=true]:after:bottom-1.5',
|
|
88
|
+
'data-[active=true]:after:w-[2px] data-[active=true]:after:rounded-l-full',
|
|
89
|
+
'data-[active=true]:after:bg-primary',
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
|
|
60
93
|
const DENSITY = {
|
|
61
94
|
comfortable: {
|
|
62
95
|
menu: 'gap-1.5',
|
|
@@ -174,49 +207,39 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
|
|
|
174
207
|
|
|
175
208
|
const sidebarContentClass = cn('gap-2', menuNav.group);
|
|
176
209
|
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
{hasLabel ? (
|
|
211
|
-
<SidebarGroupLabel className={groupLabelClass}>{group.label}</SidebarGroupLabel>
|
|
212
|
-
) : null}
|
|
213
|
-
<SidebarGroupContent>
|
|
214
|
-
<SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
|
|
215
|
-
</SidebarGroupContent>
|
|
216
|
-
</SidebarGroup>
|
|
217
|
-
);
|
|
218
|
-
});
|
|
219
|
-
}, [sidebar.groups, isActive, menuNav]);
|
|
210
|
+
const activeIndicator: SidebarActiveIndicator = sidebar.activeIndicator ?? 'background';
|
|
211
|
+
const groupLabelStyle: SidebarGroupLabelStyle = sidebar.groupLabelStyle ?? 'uppercase';
|
|
212
|
+
const navButtonClass = cn(navItemBaseClass, ACTIVE_INDICATOR_CLASS[activeIndicator], menuNav.extraButton);
|
|
213
|
+
const groupLabelUppercaseClass = cn('px-2', menuNav.label);
|
|
214
|
+
const groupLabelPlainClass = cn(
|
|
215
|
+
'px-2 text-sm font-semibold text-sidebar-foreground',
|
|
216
|
+
'h-7 leading-none',
|
|
217
|
+
);
|
|
218
|
+
const sidebarGroupClass = cn('gap-0', menuNav.groupPad);
|
|
219
|
+
|
|
220
|
+
const renderedGroups = sidebar.groups.map((group) => {
|
|
221
|
+
if (group.dynamic && group.items.length === 0) return null;
|
|
222
|
+
return (
|
|
223
|
+
<SidebarGroupRenderer
|
|
224
|
+
key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
|
|
225
|
+
group={group}
|
|
226
|
+
isActive={isActive}
|
|
227
|
+
navButtonClass={navButtonClass}
|
|
228
|
+
sidebarGroupClass={sidebarGroupClass}
|
|
229
|
+
groupLabelUppercaseClass={groupLabelUppercaseClass}
|
|
230
|
+
groupLabelPlainClass={groupLabelPlainClass}
|
|
231
|
+
groupLabelStyle={groupLabelStyle}
|
|
232
|
+
menuNav={menuNav}
|
|
233
|
+
collapsedRail={collapsedRail}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const featuredSlot = sidebar.featured && !collapsedRail ? (
|
|
239
|
+
<div className="w-full min-w-0 shrink-0 px-2">
|
|
240
|
+
<SidebarFeatured config={sidebar.featured} />
|
|
241
|
+
</div>
|
|
242
|
+
) : null;
|
|
220
243
|
|
|
221
244
|
const expandedHeader = (
|
|
222
245
|
<div className={headerRowClass}>
|
|
@@ -327,6 +350,7 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
|
|
|
327
350
|
<SidebarContent className={sidebarContentClass}>
|
|
328
351
|
{menuStartSlot}
|
|
329
352
|
{renderedGroups}
|
|
353
|
+
{featuredSlot}
|
|
330
354
|
{menuEndSlot}
|
|
331
355
|
</SidebarContent>
|
|
332
356
|
|
|
@@ -337,3 +361,136 @@ export function PrivateSidebar({ sidebar, header, pathname: pathnameProp, varian
|
|
|
337
361
|
</Sidebar>
|
|
338
362
|
);
|
|
339
363
|
}
|
|
364
|
+
|
|
365
|
+
interface SidebarGroupRendererProps {
|
|
366
|
+
group: SidebarGroupConfig;
|
|
367
|
+
isActive: (href: string) => boolean;
|
|
368
|
+
navButtonClass: string;
|
|
369
|
+
sidebarGroupClass: string;
|
|
370
|
+
groupLabelUppercaseClass: string;
|
|
371
|
+
groupLabelPlainClass: string;
|
|
372
|
+
groupLabelStyle: SidebarGroupLabelStyle;
|
|
373
|
+
menuNav: typeof DENSITY[keyof typeof DENSITY];
|
|
374
|
+
collapsedRail: boolean;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function SidebarGroupRenderer({
|
|
378
|
+
group,
|
|
379
|
+
isActive,
|
|
380
|
+
navButtonClass,
|
|
381
|
+
sidebarGroupClass,
|
|
382
|
+
groupLabelUppercaseClass,
|
|
383
|
+
groupLabelPlainClass,
|
|
384
|
+
groupLabelStyle,
|
|
385
|
+
menuNav,
|
|
386
|
+
collapsedRail,
|
|
387
|
+
}: SidebarGroupRendererProps) {
|
|
388
|
+
const hasLabel = Boolean(group.label && group.label.trim().length > 0);
|
|
389
|
+
const isCollapsible = Boolean(group.collapsible) && hasLabel && !collapsedRail;
|
|
390
|
+
const hideItemIcons = group.hideItemIcons ?? isCollapsible;
|
|
391
|
+
|
|
392
|
+
const hasActiveChild = React.useMemo(
|
|
393
|
+
() => group.items.some((item) => isActive(item.href)),
|
|
394
|
+
[group.items, isActive],
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const [open, setOpen] = React.useState<boolean>(
|
|
398
|
+
isCollapsible ? Boolean(group.defaultOpen) || hasActiveChild : true,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
React.useEffect(() => {
|
|
402
|
+
if (isCollapsible && hasActiveChild) setOpen(true);
|
|
403
|
+
}, [isCollapsible, hasActiveChild]);
|
|
404
|
+
|
|
405
|
+
const items = group.items.map((item: SidebarItem) => {
|
|
406
|
+
const tooltipText = item.tooltip ?? item.label;
|
|
407
|
+
const itemIcon = !hideItemIcons && item.icon ? (
|
|
408
|
+
<LucideIcon icon={item.icon} className={menuNav.iconClass} />
|
|
409
|
+
) : null;
|
|
410
|
+
const itemClassName = hideItemIcons
|
|
411
|
+
? cn(navButtonClass, 'pl-8')
|
|
412
|
+
: navButtonClass;
|
|
413
|
+
const badgeNode = item.badge ? (
|
|
414
|
+
<SidebarMenuBadge
|
|
415
|
+
className={item.badgeVariant === 'pill'
|
|
416
|
+
? 'bg-primary/15 text-primary px-1.5 rounded-md font-medium'
|
|
417
|
+
: undefined}
|
|
418
|
+
>
|
|
419
|
+
{item.badge}
|
|
420
|
+
</SidebarMenuBadge>
|
|
421
|
+
) : null;
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<SidebarMenuItem key={item.href}>
|
|
425
|
+
<SidebarMenuButton
|
|
426
|
+
asChild
|
|
427
|
+
isActive={isActive(item.href)}
|
|
428
|
+
size={menuNav.buttonSize}
|
|
429
|
+
tooltip={tooltipText}
|
|
430
|
+
className={itemClassName}
|
|
431
|
+
>
|
|
432
|
+
<Link href={item.href}>
|
|
433
|
+
{itemIcon}
|
|
434
|
+
<span>{item.label}</span>
|
|
435
|
+
{badgeNode}
|
|
436
|
+
</Link>
|
|
437
|
+
</SidebarMenuButton>
|
|
438
|
+
</SidebarMenuItem>
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const labelClass = isCollapsible || groupLabelStyle === 'plain'
|
|
443
|
+
? groupLabelPlainClass
|
|
444
|
+
: groupLabelUppercaseClass;
|
|
445
|
+
|
|
446
|
+
if (isCollapsible) {
|
|
447
|
+
const triggerIcon = group.icon ? (
|
|
448
|
+
<LucideIcon icon={group.icon} className={cn(menuNav.iconClass, 'shrink-0 text-sidebar-foreground/70')} />
|
|
449
|
+
) : null;
|
|
450
|
+
return (
|
|
451
|
+
<SidebarGroup className={sidebarGroupClass}>
|
|
452
|
+
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
|
453
|
+
<CollapsibleTrigger asChild>
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
className={cn(
|
|
457
|
+
'group/trig flex w-full items-center gap-2 rounded-md px-2 py-1.5',
|
|
458
|
+
'text-sm font-semibold text-sidebar-foreground',
|
|
459
|
+
'transition-colors hover:bg-sidebar-accent/40',
|
|
460
|
+
'data-[no-expand]', // marker so rail-expand click handler ignores it (pattern in PrivateSidebar)
|
|
461
|
+
)}
|
|
462
|
+
aria-expanded={open}
|
|
463
|
+
data-no-expand
|
|
464
|
+
>
|
|
465
|
+
{triggerIcon}
|
|
466
|
+
<span className="flex-1 truncate text-left">{group.label}</span>
|
|
467
|
+
<ChevronDown
|
|
468
|
+
className={cn(
|
|
469
|
+
'h-4 w-4 shrink-0 text-sidebar-foreground/55 transition-transform duration-200',
|
|
470
|
+
open && 'rotate-180',
|
|
471
|
+
)}
|
|
472
|
+
aria-hidden
|
|
473
|
+
/>
|
|
474
|
+
</button>
|
|
475
|
+
</CollapsibleTrigger>
|
|
476
|
+
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
|
477
|
+
<SidebarGroupContent>
|
|
478
|
+
<SidebarMenu className={cn(menuNav.menu, 'mt-1')}>{items}</SidebarMenu>
|
|
479
|
+
</SidebarGroupContent>
|
|
480
|
+
</CollapsibleContent>
|
|
481
|
+
</Collapsible>
|
|
482
|
+
</SidebarGroup>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<SidebarGroup className={sidebarGroupClass}>
|
|
488
|
+
{hasLabel ? (
|
|
489
|
+
<SidebarGroupLabel className={labelClass}>{group.label}</SidebarGroupLabel>
|
|
490
|
+
) : null}
|
|
491
|
+
<SidebarGroupContent>
|
|
492
|
+
<SidebarMenu className={menuNav.menu}>{items}</SidebarMenu>
|
|
493
|
+
</SidebarGroupContent>
|
|
494
|
+
</SidebarGroup>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
@@ -3,5 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { PrivateLayout } from './PrivateLayout';
|
|
6
|
-
export type {
|
|
6
|
+
export type {
|
|
7
|
+
PrivateLayoutProps,
|
|
8
|
+
SidebarItem,
|
|
9
|
+
SidebarGroupConfig,
|
|
10
|
+
SidebarConfig,
|
|
11
|
+
HeaderConfig,
|
|
12
|
+
SidebarActiveIndicator,
|
|
13
|
+
SidebarGroupLabelStyle,
|
|
14
|
+
SidebarFeaturedConfig,
|
|
15
|
+
} from './PrivateLayout';
|
|
7
16
|
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
DropdownMenuTrigger,
|
|
26
26
|
} from '@djangocfg/ui-core/components';
|
|
27
27
|
import { cn, isDev } from '@djangocfg/ui-core/lib';
|
|
28
|
-
import { useSidebar } from '@djangocfg/ui-
|
|
28
|
+
import { useSidebar } from '@djangocfg/ui-core/components';
|
|
29
29
|
import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
|
|
30
30
|
|
|
31
31
|
import { useLogout } from '../../hooks';
|
|
@@ -292,10 +292,15 @@ interface SecondaryActionProps {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
|
|
295
|
-
|
|
296
|
-
|
|
295
|
+
// Radix's DropdownMenuTrigger opens on pointerdown, before React onClick fires.
|
|
296
|
+
// Stop the event there too — onClick alone is too late.
|
|
297
|
+
const stop = (e: React.SyntheticEvent) => {
|
|
297
298
|
e.stopPropagation();
|
|
298
299
|
e.preventDefault();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
303
|
+
stop(e);
|
|
299
304
|
onParentExpand();
|
|
300
305
|
action.onClick?.();
|
|
301
306
|
};
|
|
@@ -324,6 +329,8 @@ function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
|
|
|
324
329
|
href={action.href}
|
|
325
330
|
aria-label={action.ariaLabel}
|
|
326
331
|
onClick={handleClick}
|
|
332
|
+
onPointerDown={stop}
|
|
333
|
+
onPointerUp={stop}
|
|
327
334
|
data-no-expand
|
|
328
335
|
>
|
|
329
336
|
{inner}
|
|
@@ -336,6 +343,8 @@ function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
|
|
|
336
343
|
type="button"
|
|
337
344
|
aria-label={action.ariaLabel}
|
|
338
345
|
onClick={handleClick}
|
|
346
|
+
onPointerDown={stop}
|
|
347
|
+
onPointerUp={stop}
|
|
339
348
|
data-no-expand
|
|
340
349
|
>
|
|
341
350
|
{inner}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Featured CTA tile for the sidebar — accent-tinted plate with icon, label,
|
|
3
|
+
* optional inline badge, and a trailing arrow. Mailersend-style.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import { ArrowRight } from 'lucide-react';
|
|
9
|
+
import { Link } from '@djangocfg/ui-core/components';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
|
|
12
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
|
+
|
|
14
|
+
import { LucideIcon } from '../../components';
|
|
15
|
+
|
|
16
|
+
import type { SidebarFeaturedConfig } from '../PrivateLayout/PrivateLayout';
|
|
17
|
+
|
|
18
|
+
const ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
|
|
19
|
+
green: 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 dark:bg-emerald-400/10 dark:hover:bg-emerald-400/15',
|
|
20
|
+
blue: 'bg-sky-500/10 text-sky-700 hover:bg-sky-500/15 dark:text-sky-300 dark:bg-sky-400/10 dark:hover:bg-sky-400/15',
|
|
21
|
+
amber: 'bg-amber-500/10 text-amber-800 hover:bg-amber-500/15 dark:text-amber-300 dark:bg-amber-400/10 dark:hover:bg-amber-400/15',
|
|
22
|
+
primary: 'bg-primary/10 text-primary hover:bg-primary/15',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const BADGE_ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
|
|
26
|
+
green: 'bg-emerald-500 text-white dark:bg-emerald-400 dark:text-emerald-950',
|
|
27
|
+
blue: 'bg-sky-500 text-white dark:bg-sky-400 dark:text-sky-950',
|
|
28
|
+
amber: 'bg-amber-500 text-white dark:bg-amber-400 dark:text-amber-950',
|
|
29
|
+
primary: 'bg-primary text-primary-foreground',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface SidebarFeaturedProps {
|
|
33
|
+
config: SidebarFeaturedConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SidebarFeatured({ config }: SidebarFeaturedProps) {
|
|
37
|
+
const accent = config.accent ?? 'green';
|
|
38
|
+
const tileClass = cn(
|
|
39
|
+
'group/featured flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2',
|
|
40
|
+
'transition-colors',
|
|
41
|
+
ACCENT_CLASS[accent],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const badgeNode = config.badge ? (
|
|
45
|
+
<span
|
|
46
|
+
className={cn(
|
|
47
|
+
'inline-flex shrink-0 items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
|
48
|
+
BADGE_ACCENT_CLASS[accent],
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{config.badge}
|
|
52
|
+
</span>
|
|
53
|
+
) : null;
|
|
54
|
+
|
|
55
|
+
const iconNode = config.icon ? (
|
|
56
|
+
<LucideIcon icon={config.icon} className="h-4 w-4 shrink-0" />
|
|
57
|
+
) : null;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Link href={config.href} className={tileClass}>
|
|
61
|
+
{iconNode}
|
|
62
|
+
{badgeNode}
|
|
63
|
+
<span className="flex-1 truncate text-sm font-medium">{config.label}</span>
|
|
64
|
+
<ArrowRight
|
|
65
|
+
className="h-4 w-4 shrink-0 transition-transform duration-200 group-hover/featured:translate-x-0.5"
|
|
66
|
+
aria-hidden
|
|
67
|
+
/>
|
|
68
|
+
</Link>
|
|
69
|
+
);
|
|
70
|
+
}
|