@djangocfg/layouts 2.1.266 → 2.1.267
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 +73 -3
- package/package.json +18 -18
- package/src/hooks/index.ts +1 -1
- package/src/hooks/usePathnameWithoutLocale.ts +35 -19
- package/src/layouts/AppLayout/AppLayout.tsx +15 -4
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +24 -13
- package/src/layouts/ProfileLayout/components/DeleteAccountSection.tsx +22 -106
- package/src/layouts/ProfileLayout/context.tsx +2 -10
- package/src/layouts/PublicLayout/PublicLayout.tsx +18 -0
- package/src/layouts/PublicLayout/components/NavActions.tsx +50 -0
- package/src/layouts/PublicLayout/components/NavBrand.tsx +26 -0
- package/src/layouts/PublicLayout/components/NavDesktopItems.tsx +207 -0
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +1 -1
- package/src/layouts/PublicLayout/components/PublicNavbar.tsx +44 -6
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +199 -396
- package/src/layouts/PublicLayout/hooks/index.ts +5 -1
- package/src/layouts/PublicLayout/hooks/useDropdownMenu.ts +58 -0
- package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +61 -0
- package/src/layouts/PublicLayout/hooks/useNavbarViewportVars.ts +46 -0
- package/src/layouts/PublicLayout/index.ts +4 -0
- package/src/layouts/PublicLayout/navbarTypes.ts +17 -0
- package/src/utils/pathMatcher.ts +6 -3
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ import { AppLayout } from '@djangocfg/layouts';
|
|
|
104
104
|
public: { component: PublicLayout, enabledPath: ['/', '/legal', '/contact'] },
|
|
105
105
|
private: { component: PrivateLayout, enabledPath: ['/dashboard'] },
|
|
106
106
|
admin: { component: AdminLayout, enabledPath: '/admin' },
|
|
107
|
-
noLayoutPaths: ['/embed'],
|
|
107
|
+
noLayoutPaths: ['/embed', '/ui'], // fullscreen, no navbar/footer
|
|
108
108
|
}}
|
|
109
109
|
baseApp={{ project: 'my-app', theme: { defaultTheme: 'system' }, auth: { apiUrl: '…' } }}
|
|
110
110
|
i18n={{ locale, locales, onLocaleChange }}
|
|
@@ -113,21 +113,91 @@ import { AppLayout } from '@djangocfg/layouts';
|
|
|
113
113
|
</AppLayout>
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
> **`i18n` is required for correct path matching.**
|
|
117
|
+
> `AppLayout` strips the locale prefix from the pathname before matching `enabledPath` / `noLayoutPaths`.
|
|
118
|
+
> It uses `i18n.locale` for exact stripping — without it the fallback regex can misfire on
|
|
119
|
+
> 2-letter path segments (e.g. `/ui/*` being treated as locale `ui`).
|
|
120
|
+
> Always pass `i18n` when using next-intl or any locale-prefixed routing.
|
|
121
|
+
|
|
116
122
|
---
|
|
117
123
|
|
|
118
124
|
## Layouts (import from `@djangocfg/layouts`)
|
|
119
125
|
|
|
120
126
|
| Component | Use |
|
|
121
127
|
|-----------|-----|
|
|
122
|
-
| **PublicLayout** | Marketing / docs — slots: **`navbar`**, **`footer`**, **`contentTopSpacing`**, **`contentBottomSpacing`** |
|
|
123
|
-
| **PublicNavbar** / **PublicFooter** |
|
|
128
|
+
| **PublicLayout** | Marketing / docs — slots: **`navbar`**, **`footer`**, **`backgroundSlot`**, **`contentTopSpacing`**, **`contentBottomSpacing`** |
|
|
129
|
+
| **PublicNavbar** / **PublicFooter** | See `PublicNavbarConfig` below |
|
|
124
130
|
| **PrivateLayout** | App shell — sidebar + header |
|
|
125
131
|
| **AuthLayout** | Sign-in flows |
|
|
126
132
|
| **AdminLayout** | Admin console |
|
|
127
133
|
| **ProfileLayout** | Profile page with avatar, editable fields, 2FA, and slot/tab system |
|
|
128
134
|
|
|
135
|
+
**`PublicLayout` — `backgroundSlot`**
|
|
136
|
+
|
|
137
|
+
Pass any `ReactNode` as `backgroundSlot` to render a full-viewport layer *behind* the sticky navbar and all page content — without affecting layout flow.
|
|
138
|
+
The recommended pattern is `fixed inset-0 -z-10 pointer-events-none`:
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
<PublicLayout
|
|
142
|
+
contentTopSpacing="none"
|
|
143
|
+
contentBottomSpacing="none"
|
|
144
|
+
backgroundSlot={
|
|
145
|
+
<div
|
|
146
|
+
className="pointer-events-none fixed inset-0 -z-10"
|
|
147
|
+
style={{
|
|
148
|
+
background:
|
|
149
|
+
'radial-gradient(ellipse 55% 50% at 10% 0%, rgba(139,92,246,0.13) 0%, transparent 55%),' +
|
|
150
|
+
'radial-gradient(ellipse 40% 30% at 85% 75%, rgba(6,182,212,0.06) 0%, transparent 55%)',
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
}
|
|
154
|
+
navbar={<PublicNavbar config={…} />}
|
|
155
|
+
>
|
|
156
|
+
<HeroSection />
|
|
157
|
+
</PublicLayout>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Because the element is `fixed`, it covers the full viewport including the sticky navbar area, and `backdrop-blur` on the navbar lets the gradient show through.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
129
164
|
**Brand:** `ThemeBrandMark` / **`ThemeBrandMarkImg`** for logo slots.
|
|
130
165
|
|
|
166
|
+
### PublicNavbar — layout variants & scroll behaviour
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
<PublicNavbar config={{
|
|
170
|
+
brand: <Logo />,
|
|
171
|
+
navigation: navItems,
|
|
172
|
+
userMenu,
|
|
173
|
+
|
|
174
|
+
// Visual
|
|
175
|
+
navbarVariant: 'floating', // 'floating' | 'flush'
|
|
176
|
+
navbarPosition: 'sticky', // 'sticky' | 'fixed' | 'static'
|
|
177
|
+
navbarHeight: 'md', // 'sm' | 'md' | 'lg'
|
|
178
|
+
|
|
179
|
+
// Desktop nav arrangement
|
|
180
|
+
navLayout: 'default',
|
|
181
|
+
// 'default' → brand left | nav centered (absolute) | actions right
|
|
182
|
+
// 'brand-left' → brand left | nav after brand | actions pushed right
|
|
183
|
+
// 'centered' → brand + nav + actions all centered in one row
|
|
184
|
+
// 'split' → brand left | actions right | no desktop nav (drawer only)
|
|
185
|
+
|
|
186
|
+
// Scroll behaviour
|
|
187
|
+
hideNavOnScroll: true, // slide up on scroll-down, back on scroll-up
|
|
188
|
+
transparent: true, // transparent at page top, opaque after threshold
|
|
189
|
+
transparentThreshold: 40, // px, default 40
|
|
190
|
+
}} />
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Exported hooks** (for custom navbars):
|
|
194
|
+
|
|
195
|
+
| Hook | Role |
|
|
196
|
+
|------|------|
|
|
197
|
+
| `useNavbarScroll(opts)` | Returns `{ hidden, scrolled }` driven by scroll position |
|
|
198
|
+
| `useDropdownMenu()` | Hover open/close timers for desktop dropdowns |
|
|
199
|
+
| `useNavbarViewportVars(ref, deps)` | Sets `--public-navbar-mobile-drawer-top/max-height` CSS vars |
|
|
200
|
+
|
|
131
201
|
### ProfileLayout — slots & tabs
|
|
132
202
|
|
|
133
203
|
```tsx
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.267",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -74,14 +74,14 @@
|
|
|
74
74
|
"check": "tsc --noEmit"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@djangocfg/api": "^2.1.
|
|
78
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
79
|
-
"@djangocfg/
|
|
80
|
-
"@djangocfg/
|
|
81
|
-
"@djangocfg/
|
|
82
|
-
"@djangocfg/ui-core": "^2.1.
|
|
83
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
84
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
77
|
+
"@djangocfg/api": "^2.1.267",
|
|
78
|
+
"@djangocfg/centrifugo": "^2.1.267",
|
|
79
|
+
"@djangocfg/debuger": "^2.1.267",
|
|
80
|
+
"@djangocfg/i18n": "^2.1.267",
|
|
81
|
+
"@djangocfg/monitor": "^2.1.267",
|
|
82
|
+
"@djangocfg/ui-core": "^2.1.267",
|
|
83
|
+
"@djangocfg/ui-nextjs": "^2.1.267",
|
|
84
|
+
"@djangocfg/ui-tools": "^2.1.267",
|
|
85
85
|
"@hookform/resolvers": "^5.2.2",
|
|
86
86
|
"consola": "^3.4.2",
|
|
87
87
|
"lucide-react": "^0.545.0",
|
|
@@ -110,15 +110,15 @@
|
|
|
110
110
|
"uuid": "^11.1.0"
|
|
111
111
|
},
|
|
112
112
|
"devDependencies": {
|
|
113
|
-
"@djangocfg/api": "^2.1.
|
|
114
|
-
"@djangocfg/
|
|
115
|
-
"@djangocfg/
|
|
116
|
-
"@djangocfg/
|
|
117
|
-
"@djangocfg/
|
|
118
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
119
|
-
"@djangocfg/ui-core": "^2.1.
|
|
120
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
121
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
113
|
+
"@djangocfg/api": "^2.1.267",
|
|
114
|
+
"@djangocfg/centrifugo": "^2.1.267",
|
|
115
|
+
"@djangocfg/debuger": "^2.1.267",
|
|
116
|
+
"@djangocfg/i18n": "^2.1.267",
|
|
117
|
+
"@djangocfg/monitor": "^2.1.267",
|
|
118
|
+
"@djangocfg/typescript-config": "^2.1.267",
|
|
119
|
+
"@djangocfg/ui-core": "^2.1.267",
|
|
120
|
+
"@djangocfg/ui-nextjs": "^2.1.267",
|
|
121
|
+
"@djangocfg/ui-tools": "^2.1.267",
|
|
122
122
|
"@types/node": "^24.7.2",
|
|
123
123
|
"@types/react": "^19.1.0",
|
|
124
124
|
"@types/react-dom": "^19.1.0",
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { usePathnameWithoutLocale
|
|
1
|
+
export { usePathnameWithoutLocale } from './usePathnameWithoutLocale';
|
|
2
2
|
export { useLogout } from './useLogout';
|
|
@@ -4,36 +4,52 @@ import { usePathname } from 'next/navigation';
|
|
|
4
4
|
import { useMemo } from 'react';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Normalize pathname: remove trailing slash except for root "/".
|
|
8
|
+
* Works regardless of Next.js trailingSlash config.
|
|
9
9
|
*/
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (match) {
|
|
15
|
-
const rest = pathname.slice(match[0].length - 1) || '/';
|
|
16
|
-
return rest.startsWith('/') ? rest : '/' + rest;
|
|
17
|
-
}
|
|
10
|
+
function normalize(p: string): string {
|
|
11
|
+
return p.length > 1 ? p.replace(/\/+$/, '') : p;
|
|
12
|
+
}
|
|
18
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Strip locale prefix from pathname using known locale string.
|
|
16
|
+
*/
|
|
17
|
+
function stripLocale(pathname: string, locale: string): string {
|
|
18
|
+
const prefix = '/' + locale;
|
|
19
|
+
if (pathname === prefix || pathname === prefix + '/') return '/';
|
|
20
|
+
if (pathname.startsWith(prefix + '/')) return pathname.slice(prefix.length);
|
|
21
|
+
// Path already has no locale prefix (next-intl localePrefix:'as-needed' for default locale)
|
|
19
22
|
return pathname;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* usePathnameWithoutLocale
|
|
24
27
|
*
|
|
25
|
-
* Returns pathname without locale prefix.
|
|
26
|
-
*
|
|
28
|
+
* Returns the current pathname without locale prefix, normalized (no trailing slash).
|
|
29
|
+
*
|
|
30
|
+
* Pass `locale` (from i18n config / useLocaleSwitcher) for exact stripping.
|
|
31
|
+
* Without locale, falls back to regex — known to misfire on 2-letter path segments like /ui.
|
|
27
32
|
*
|
|
28
33
|
* @example
|
|
29
|
-
* // URL: /
|
|
30
|
-
*
|
|
31
|
-
* // pathname = '/private/dashboard'
|
|
34
|
+
* // URL: /en/ui/playground, locale="en" → "/ui/playground"
|
|
35
|
+
* // URL: /ui/playground/ (no prefix) → "/ui/playground"
|
|
32
36
|
*/
|
|
33
|
-
export function usePathnameWithoutLocale(): string {
|
|
37
|
+
export function usePathnameWithoutLocale(locale?: string): string {
|
|
34
38
|
const pathname = usePathname();
|
|
35
39
|
|
|
36
|
-
return useMemo(() =>
|
|
37
|
-
|
|
40
|
+
return useMemo(() => {
|
|
41
|
+
if (locale) {
|
|
42
|
+
return normalize(stripLocale(pathname, locale));
|
|
43
|
+
}
|
|
38
44
|
|
|
39
|
-
|
|
45
|
+
// Fallback regex — only when locale is unknown
|
|
46
|
+
const match = pathname.match(/^\/[a-z]{2}(-[A-Z]{2})?(\/|$)/);
|
|
47
|
+
if (match) {
|
|
48
|
+
const rest = pathname.slice(match[0].length - 1) || '/';
|
|
49
|
+
const withSlash = rest.startsWith('/') ? rest : '/' + rest;
|
|
50
|
+
return normalize(withSlash);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return normalize(pathname);
|
|
54
|
+
}, [pathname, locale]);
|
|
55
|
+
}
|
|
@@ -75,6 +75,14 @@ export interface AppLayoutPublicChrome {
|
|
|
75
75
|
topSpacing?: PublicMainTopSpacing;
|
|
76
76
|
bottomSpacing?: PublicMainBottomSpacing;
|
|
77
77
|
};
|
|
78
|
+
/**
|
|
79
|
+
* Full-viewport background layer rendered behind the navbar and all page content.
|
|
80
|
+
* Pass a `fixed inset-0 -z-10 pointer-events-none` element — it covers the whole
|
|
81
|
+
* viewport (including the sticky navbar area) without affecting layout flow.
|
|
82
|
+
*
|
|
83
|
+
* Set per-page via `AppLayout publicChrome` or directly on `PublicSiteLayout`.
|
|
84
|
+
*/
|
|
85
|
+
backgroundSlot?: ReactNode;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
function mergePartialNavbar(
|
|
@@ -120,8 +128,10 @@ export function mergeAppLayoutPublicChrome(
|
|
|
120
128
|
root?.main || fromLayouts?.main
|
|
121
129
|
? { ...root?.main, ...fromLayouts?.main }
|
|
122
130
|
: undefined;
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
// backgroundSlot: layouts-level overrides root (more specific wins)
|
|
132
|
+
const backgroundSlot = fromLayouts?.backgroundSlot ?? root?.backgroundSlot;
|
|
133
|
+
if (!navbar && !footer && !main && !backgroundSlot) return undefined;
|
|
134
|
+
return { navbar, footer, ...(main ? { main } : {}), ...(backgroundSlot ? { backgroundSlot } : {}) };
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
/**
|
|
@@ -291,8 +301,9 @@ function AppLayoutContent({
|
|
|
291
301
|
i18n,
|
|
292
302
|
publicChrome,
|
|
293
303
|
}: AppLayoutContentProps) {
|
|
294
|
-
// Use pathname without locale prefix for route matching
|
|
295
|
-
|
|
304
|
+
// Use pathname without locale prefix for route matching.
|
|
305
|
+
// Pass locale from i18n so strip is exact — avoids regex misfiring on /ui, /ko, etc.
|
|
306
|
+
const pathname = usePathnameWithoutLocale(i18n?.locale);
|
|
296
307
|
|
|
297
308
|
// Merge authPath into noLayoutPaths — auth pages are always fullscreen
|
|
298
309
|
const effectiveNoLayoutPaths = useMemo(() => {
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { LogOut, MoreHorizontal, Trash2 } from 'lucide-react';
|
|
4
4
|
import moment from 'moment';
|
|
5
|
-
import React, { useEffect } from 'react';
|
|
5
|
+
import React, { useCallback, useEffect } from 'react';
|
|
6
6
|
|
|
7
|
-
import { useAuth } from '@djangocfg/api/auth';
|
|
7
|
+
import { useAuth, useDeleteAccount } from '@djangocfg/api/auth';
|
|
8
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
8
9
|
import {
|
|
9
10
|
Button,
|
|
10
11
|
DropdownMenu,
|
|
@@ -21,7 +22,6 @@ import {
|
|
|
21
22
|
|
|
22
23
|
import {
|
|
23
24
|
AvatarSection,
|
|
24
|
-
DeleteAccountScreen,
|
|
25
25
|
EditableField,
|
|
26
26
|
Section,
|
|
27
27
|
TwoFactorSection,
|
|
@@ -71,8 +71,25 @@ function ProfileHeader({ slots, enableDeleteAccount }: {
|
|
|
71
71
|
slots?: ProfileSlots;
|
|
72
72
|
enableDeleteAccount?: boolean;
|
|
73
73
|
}) {
|
|
74
|
-
const { labels, onLogout
|
|
75
|
-
const { user } = useAuth();
|
|
74
|
+
const { labels, onLogout } = useProfileContext();
|
|
75
|
+
const { user, logout } = useAuth();
|
|
76
|
+
const { deleteAccount } = useDeleteAccount();
|
|
77
|
+
const t = useAppT();
|
|
78
|
+
|
|
79
|
+
const handleDeleteAccount = useCallback(async () => {
|
|
80
|
+
const confirmationWord = t('layouts.profilePage.confirmationWord');
|
|
81
|
+
const value = await window.dialog.prompt({
|
|
82
|
+
title: t('layouts.profilePage.deleteAccountTitle'),
|
|
83
|
+
message: t('layouts.profilePage.deleteAccountDesc'),
|
|
84
|
+
placeholder: confirmationWord,
|
|
85
|
+
confirmText: t('layouts.profilePage.deleteAccount'),
|
|
86
|
+
cancelText: t('layouts.profilePage.cancel'),
|
|
87
|
+
variant: 'destructive',
|
|
88
|
+
});
|
|
89
|
+
if (value?.toUpperCase() !== confirmationWord.toUpperCase()) return;
|
|
90
|
+
const result = await deleteAccount();
|
|
91
|
+
if (result.success) logout();
|
|
92
|
+
}, [t, deleteAccount, logout]);
|
|
76
93
|
|
|
77
94
|
if (!user) return null;
|
|
78
95
|
|
|
@@ -121,7 +138,7 @@ function ProfileHeader({ slots, enableDeleteAccount }: {
|
|
|
121
138
|
<>
|
|
122
139
|
<DropdownMenuSeparator />
|
|
123
140
|
<DropdownMenuItem
|
|
124
|
-
onClick={
|
|
141
|
+
onClick={handleDeleteAccount}
|
|
125
142
|
className="gap-2 text-destructive focus:text-destructive"
|
|
126
143
|
>
|
|
127
144
|
<Trash2 className="w-4 h-4" />
|
|
@@ -260,14 +277,8 @@ function ProfileContent({
|
|
|
260
277
|
// Router + Export
|
|
261
278
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
262
279
|
|
|
263
|
-
function ProfileRouter(props: ProfileLayoutProps) {
|
|
264
|
-
const { step } = useProfileContext();
|
|
265
|
-
if (step === 'delete-account') return <DeleteAccountScreen />;
|
|
266
|
-
return <ProfileContent {...props} />;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
280
|
export const ProfileLayout: React.FC<ProfileLayoutProps> = ({ title, ...props }) => (
|
|
270
281
|
<ProfileProvider title={title}>
|
|
271
|
-
<
|
|
282
|
+
<ProfileContent title={title} {...props} />
|
|
272
283
|
</ProfileProvider>
|
|
273
284
|
);
|
|
@@ -1,128 +1,44 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import React
|
|
3
|
+
import { Trash2 } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
5
|
|
|
6
6
|
import { useAuth, useDeleteAccount } from '@djangocfg/api/auth';
|
|
7
7
|
import { useAppT } from '@djangocfg/i18n';
|
|
8
|
-
import { Alert, AlertDescription, Button, Input } from '@djangocfg/ui-core/components';
|
|
9
8
|
|
|
10
|
-
import { useProfileContext } from '../context';
|
|
11
9
|
import { ActionButton } from './ActionButton';
|
|
12
10
|
|
|
13
|
-
// ─── Entry point: single action row on main screen ───────────────────────────
|
|
14
|
-
|
|
15
11
|
export const DeleteAccountSection: React.FC = () => {
|
|
16
|
-
const { setStep } = useProfileContext();
|
|
17
|
-
const t = useAppT();
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<ActionButton
|
|
21
|
-
icon={<Trash2 className="w-4 h-4 text-destructive" />}
|
|
22
|
-
label={<span className="text-destructive">{t('layouts.profilePage.deleteAccount')}</span>}
|
|
23
|
-
onClick={() => setStep('delete-account')}
|
|
24
|
-
/>
|
|
25
|
-
);
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
// ─── Full delete screen ───────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
export const DeleteAccountScreen: React.FC = () => {
|
|
31
|
-
const { back } = useProfileContext();
|
|
32
12
|
const { logout } = useAuth();
|
|
33
|
-
const
|
|
13
|
+
const { deleteAccount } = useDeleteAccount();
|
|
34
14
|
const t = useAppT();
|
|
35
15
|
|
|
36
|
-
const { isLoading, error, deleteAccount, clearError } = useDeleteAccount();
|
|
37
|
-
|
|
38
16
|
const confirmationWord = t('layouts.profilePage.confirmationWord');
|
|
39
|
-
const isValid = confirmationInput.toUpperCase() === confirmationWord.toUpperCase();
|
|
40
17
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
18
|
+
const handleClick = async () => {
|
|
19
|
+
const value = await window.dialog.prompt({
|
|
20
|
+
title: t('layouts.profilePage.deleteAccountTitle'),
|
|
21
|
+
message: t('layouts.profilePage.deleteAccountDesc'),
|
|
22
|
+
placeholder: confirmationWord,
|
|
23
|
+
confirmText: t('layouts.profilePage.deleteAccount'),
|
|
24
|
+
cancelText: t('layouts.profilePage.cancel'),
|
|
25
|
+
variant: 'destructive',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (value?.toUpperCase() !== confirmationWord.toUpperCase()) return;
|
|
50
29
|
|
|
51
|
-
const handleDelete = async () => {
|
|
52
|
-
clearError();
|
|
53
30
|
const result = await deleteAccount();
|
|
54
31
|
if (result.success) logout();
|
|
55
32
|
};
|
|
56
33
|
|
|
57
|
-
const handleBack = () => {
|
|
58
|
-
clearError();
|
|
59
|
-
setConfirmationInput('');
|
|
60
|
-
back();
|
|
61
|
-
};
|
|
62
|
-
|
|
63
34
|
return (
|
|
64
|
-
<
|
|
65
|
-
{
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
className="flex items-center gap-1 text-sm text-muted-foreground mb-8 hover:text-foreground transition-colors"
|
|
70
|
-
>
|
|
71
|
-
<ChevronLeft className="w-4 h-4" />
|
|
72
|
-
{labels.back}
|
|
73
|
-
</button>
|
|
74
|
-
|
|
75
|
-
{/* Header */}
|
|
76
|
-
<div className="flex items-center gap-3 mb-2">
|
|
77
|
-
<div className="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
|
78
|
-
<AlertTriangle className="w-5 h-5 text-destructive" />
|
|
79
|
-
</div>
|
|
80
|
-
<h1 className="text-xl font-semibold">{labels.title}</h1>
|
|
81
|
-
</div>
|
|
82
|
-
<p className="text-sm text-muted-foreground mb-8">{labels.desc}</p>
|
|
83
|
-
|
|
84
|
-
{/* Confirmation */}
|
|
85
|
-
<div className="space-y-3">
|
|
86
|
-
{error && (
|
|
87
|
-
<Alert variant="destructive">
|
|
88
|
-
<AlertDescription>{error}</AlertDescription>
|
|
89
|
-
</Alert>
|
|
90
|
-
)}
|
|
91
|
-
|
|
92
|
-
<p className="text-sm text-muted-foreground">{labels.typeToConfirm}</p>
|
|
93
|
-
|
|
94
|
-
<Input
|
|
95
|
-
value={confirmationInput}
|
|
96
|
-
onChange={(e) => setConfirmationInput(e.target.value)}
|
|
97
|
-
placeholder={confirmationWord}
|
|
98
|
-
disabled={isLoading}
|
|
99
|
-
autoFocus
|
|
100
|
-
autoComplete="off"
|
|
101
|
-
className="font-mono"
|
|
102
|
-
/>
|
|
103
|
-
|
|
104
|
-
<Button
|
|
105
|
-
variant="destructive"
|
|
106
|
-
className="w-full"
|
|
107
|
-
onClick={handleDelete}
|
|
108
|
-
disabled={isLoading || !isValid}
|
|
109
|
-
>
|
|
110
|
-
{isLoading ? (
|
|
111
|
-
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />{labels.deleting}</>
|
|
112
|
-
) : (
|
|
113
|
-
labels.deleteAccount
|
|
114
|
-
)}
|
|
115
|
-
</Button>
|
|
116
|
-
|
|
117
|
-
<Button
|
|
118
|
-
variant="ghost"
|
|
119
|
-
className="w-full"
|
|
120
|
-
onClick={handleBack}
|
|
121
|
-
disabled={isLoading}
|
|
122
|
-
>
|
|
123
|
-
{labels.cancel}
|
|
124
|
-
</Button>
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
35
|
+
<ActionButton
|
|
36
|
+
icon={<Trash2 className="w-4 h-4 text-destructive" />}
|
|
37
|
+
label={<span className="text-destructive">{t('layouts.profilePage.deleteAccount')}</span>}
|
|
38
|
+
onClick={handleClick}
|
|
39
|
+
/>
|
|
127
40
|
);
|
|
128
41
|
};
|
|
42
|
+
|
|
43
|
+
// Keep export so nothing breaks — no longer used but exported for backwards compat
|
|
44
|
+
export const DeleteAccountScreen: React.FC = () => null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { createContext, useCallback, useContext, useMemo
|
|
3
|
+
import React, { createContext, useCallback, useContext, useMemo } from 'react';
|
|
4
4
|
|
|
5
5
|
import { useAppT } from '@djangocfg/i18n';
|
|
6
6
|
import { toast } from '@djangocfg/ui-core/hooks';
|
|
@@ -13,8 +13,6 @@ import { useLogout } from '../../hooks';
|
|
|
13
13
|
// Types
|
|
14
14
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
15
|
|
|
16
|
-
export type ProfileStep = 'main' | 'delete-account';
|
|
17
|
-
|
|
18
16
|
export interface ProfileLabels {
|
|
19
17
|
title: string;
|
|
20
18
|
personalInfo: string;
|
|
@@ -43,9 +41,6 @@ export interface ProfileLabels {
|
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
interface ProfileContextValue {
|
|
46
|
-
step: ProfileStep;
|
|
47
|
-
setStep: (step: ProfileStep) => void;
|
|
48
|
-
back: () => void;
|
|
49
44
|
labels: ProfileLabels;
|
|
50
45
|
onLogout: () => void;
|
|
51
46
|
onFieldSave: (field: string, value: string) => Promise<void>;
|
|
@@ -77,13 +72,10 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({
|
|
|
77
72
|
children,
|
|
78
73
|
title,
|
|
79
74
|
}) => {
|
|
80
|
-
const [step, setStep] = useState<ProfileStep>('main');
|
|
81
75
|
const { updateProfile } = useAuth();
|
|
82
76
|
const t = useAppT();
|
|
83
77
|
const onLogout = useLogout();
|
|
84
78
|
|
|
85
|
-
const back = useCallback(() => setStep('main'), []);
|
|
86
|
-
|
|
87
79
|
const labels = useMemo<ProfileLabels>(() => ({
|
|
88
80
|
title: title || t('layouts.profilePage.title'),
|
|
89
81
|
personalInfo: t('layouts.profilePage.personalInfo'),
|
|
@@ -124,7 +116,7 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({
|
|
|
124
116
|
}, [updateProfile, labels]);
|
|
125
117
|
|
|
126
118
|
return (
|
|
127
|
-
<ProfileContext.Provider value={{
|
|
119
|
+
<ProfileContext.Provider value={{ labels, onLogout, onFieldSave }}>
|
|
128
120
|
{children}
|
|
129
121
|
</ProfileContext.Provider>
|
|
130
122
|
);
|
|
@@ -53,6 +53,20 @@ export interface PublicLayoutProps {
|
|
|
53
53
|
*/
|
|
54
54
|
navbar?: ReactNode;
|
|
55
55
|
footer?: ReactNode;
|
|
56
|
+
/**
|
|
57
|
+
* Optional background layer rendered behind navbar and content.
|
|
58
|
+
* Use `position: fixed; inset: 0; pointer-events: none` (or similar) on the element
|
|
59
|
+
* so it fills the viewport without affecting layout flow.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* backgroundSlot={
|
|
64
|
+
* <div className="fixed inset-0 -z-10 pointer-events-none"
|
|
65
|
+
* style={{ background: 'radial-gradient(ellipse 60% 50% at 10% 0%, violet, transparent)' }} />
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
backgroundSlot?: ReactNode;
|
|
56
70
|
/**
|
|
57
71
|
* When `auto` (default), `<main>` gets a small top offset from `PublicNavigation` surface
|
|
58
72
|
* (`floating` vs `flush`). Set `none` if the page controls spacing itself.
|
|
@@ -105,6 +119,7 @@ export function PublicLayout({
|
|
|
105
119
|
children,
|
|
106
120
|
navbar,
|
|
107
121
|
footer,
|
|
122
|
+
backgroundSlot,
|
|
108
123
|
contentTopSpacing = 'auto',
|
|
109
124
|
contentBottomSpacing = 'auto',
|
|
110
125
|
}: PublicLayoutProps) {
|
|
@@ -134,6 +149,9 @@ export function PublicLayout({
|
|
|
134
149
|
|
|
135
150
|
return (
|
|
136
151
|
<PublicLayoutProvider value={contextValue}>
|
|
152
|
+
{/* Background slot — renders behind everything, including the sticky navbar */}
|
|
153
|
+
{backgroundSlot ?? null}
|
|
154
|
+
|
|
137
155
|
<div className="min-h-screen flex flex-col">
|
|
138
156
|
{navbar ?? null}
|
|
139
157
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Menu, X } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
7
|
+
|
|
8
|
+
import { UserMenu } from '../../_components/UserMenu';
|
|
9
|
+
import type { UserMenuConfig } from '../../types';
|
|
10
|
+
|
|
11
|
+
interface NavActionsProps {
|
|
12
|
+
userMenu?: UserMenuConfig;
|
|
13
|
+
mobileMenuOpen: boolean;
|
|
14
|
+
onMobileMenuToggle: () => void;
|
|
15
|
+
toggleMobileLabel: string;
|
|
16
|
+
/** When true, mobile trigger is always visible (not hidden on lg+). Used for `split` layout. */
|
|
17
|
+
forceShowMobileTrigger?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function NavActions({
|
|
21
|
+
userMenu,
|
|
22
|
+
mobileMenuOpen,
|
|
23
|
+
onMobileMenuToggle,
|
|
24
|
+
toggleMobileLabel,
|
|
25
|
+
forceShowMobileTrigger = false,
|
|
26
|
+
}: NavActionsProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex items-center gap-4">
|
|
29
|
+
<div className="hidden lg:flex">
|
|
30
|
+
<UserMenu
|
|
31
|
+
variant="desktop"
|
|
32
|
+
groups={userMenu?.groups}
|
|
33
|
+
authPath={userMenu?.authPath}
|
|
34
|
+
i18n={userMenu?.i18n}
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<Button
|
|
39
|
+
variant="ghost"
|
|
40
|
+
size="icon"
|
|
41
|
+
aria-label={toggleMobileLabel}
|
|
42
|
+
data-mobile-menu-trigger="true"
|
|
43
|
+
className={forceShowMobileTrigger ? 'rounded-full' : 'lg:hidden rounded-full'}
|
|
44
|
+
onClick={onMobileMenuToggle}
|
|
45
|
+
>
|
|
46
|
+
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
|
47
|
+
</Button>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import React, { type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
interface NavBrandProps {
|
|
7
|
+
brand?: ReactNode;
|
|
8
|
+
brandHref?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function NavBrand({ brand, brandHref = '/' }: NavBrandProps) {
|
|
12
|
+
if (brand == null || brand === '' || brand === false) return null;
|
|
13
|
+
|
|
14
|
+
if (typeof brand === 'string') {
|
|
15
|
+
return (
|
|
16
|
+
<Link
|
|
17
|
+
href={brandHref}
|
|
18
|
+
className="font-bold text-[15px] text-foreground hover:opacity-90 transition-opacity"
|
|
19
|
+
>
|
|
20
|
+
{brand}
|
|
21
|
+
</Link>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return <>{brand}</>;
|
|
26
|
+
}
|