@djangocfg/layouts 2.1.592 → 2.1.594
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -12
- package/src/components/errors/ErrorLayout.tsx +1 -164
- package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
- package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
- package/src/components/errors/errorGlyphs.tsx +167 -0
- package/src/layouts/AppLayout/BaseApp.tsx +1 -1
- package/src/layouts/AppLayout/index.ts +2 -2
- package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
- package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
- package/src/layouts/AuthLayout/layoutContext.ts +25 -0
- package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +10 -51
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
- package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
- package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
- package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
- package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
- package/src/layouts/PublicLayout/shared/index.ts +1 -1
- package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
- package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +20 -5
- package/src/layouts/SettingsLayout/SettingsForm.tsx +6 -2
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
- package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +1 -1
- package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +1 -1
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +14 -29
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
- package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
- package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
- package/src/layouts/SettingsLayout/index.ts +3 -0
- package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +1 -1
- package/src/layouts/SettingsLayout/store.ts +31 -1
- package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
- package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.594",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -90,11 +90,11 @@
|
|
|
90
90
|
"check": "tsc --noEmit"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
|
-
"@djangocfg/analytics": "^2.1.
|
|
94
|
-
"@djangocfg/api": "^2.1.
|
|
95
|
-
"@djangocfg/devtools": "^2.1.
|
|
96
|
-
"@djangocfg/i18n": "^2.1.
|
|
97
|
-
"@djangocfg/ui-core": "^2.1.
|
|
93
|
+
"@djangocfg/analytics": "^2.1.594",
|
|
94
|
+
"@djangocfg/api": "^2.1.594",
|
|
95
|
+
"@djangocfg/devtools": "^2.1.594",
|
|
96
|
+
"@djangocfg/i18n": "^2.1.594",
|
|
97
|
+
"@djangocfg/ui-core": "^2.1.594",
|
|
98
98
|
"@hookform/resolvers": "^5.2.2",
|
|
99
99
|
"consola": "^3.4.2",
|
|
100
100
|
"lucide-react": "^0.545.0",
|
|
@@ -124,12 +124,12 @@
|
|
|
124
124
|
"uuid": "^11.1.1"
|
|
125
125
|
},
|
|
126
126
|
"devDependencies": {
|
|
127
|
-
"@djangocfg/analytics": "^2.1.
|
|
128
|
-
"@djangocfg/api": "^2.1.
|
|
129
|
-
"@djangocfg/devtools": "^2.1.
|
|
130
|
-
"@djangocfg/i18n": "^2.1.
|
|
131
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
132
|
-
"@djangocfg/ui-core": "^2.1.
|
|
127
|
+
"@djangocfg/analytics": "^2.1.594",
|
|
128
|
+
"@djangocfg/api": "^2.1.594",
|
|
129
|
+
"@djangocfg/devtools": "^2.1.594",
|
|
130
|
+
"@djangocfg/i18n": "^2.1.594",
|
|
131
|
+
"@djangocfg/typescript-config": "^2.1.594",
|
|
132
|
+
"@djangocfg/ui-core": "^2.1.594",
|
|
133
133
|
"@storybook/react-vite": "^10.5.0",
|
|
134
134
|
"@types/node": "^25.9.5",
|
|
135
135
|
"@types/react": "19.2.15",
|
|
@@ -23,6 +23,7 @@ import { Button } from '@djangocfg/ui-core/components';
|
|
|
23
23
|
import { useAppT } from '@djangocfg/i18n';
|
|
24
24
|
|
|
25
25
|
import { getErrorContent } from './errorConfig';
|
|
26
|
+
import { getErrorIcon } from './errorGlyphs';
|
|
26
27
|
|
|
27
28
|
export interface ErrorLayoutProps {
|
|
28
29
|
/** Error code (e.g., "404", "500", "403") - if provided, auto-configures title/description/icon */
|
|
@@ -69,170 +70,6 @@ export interface ErrorLayoutProps {
|
|
|
69
70
|
* weight comes from a soft radial glow on the wrapper, not the icon itself.
|
|
70
71
|
*/
|
|
71
72
|
|
|
72
|
-
interface GlyphSpec {
|
|
73
|
-
/** Accent tint used for the glow and the gradient stop. */
|
|
74
|
-
accent: string;
|
|
75
|
-
/** viewBox for the inner SVG. Default 24 24. */
|
|
76
|
-
viewBox?: string;
|
|
77
|
-
/** Inner SVG children. Must use `currentColor` for stroke. */
|
|
78
|
-
paths: React.ReactNode;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const GLYPH_SPECS: Record<string, GlyphSpec> = {
|
|
82
|
-
// 404 — magnifier over a page corner (not-found)
|
|
83
|
-
'404': {
|
|
84
|
-
accent: 'rgb(139 92 246)', // violet-500
|
|
85
|
-
paths: (
|
|
86
|
-
<>
|
|
87
|
-
<path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7.5" />
|
|
88
|
-
<path d="M13.5 3 19 8.5" />
|
|
89
|
-
<path d="M13 3v4a2 2 0 0 0 2 2h4" />
|
|
90
|
-
<circle cx="11" cy="14.5" r="2.5" />
|
|
91
|
-
<path d="m13 16.5 2 2" />
|
|
92
|
-
</>
|
|
93
|
-
),
|
|
94
|
-
},
|
|
95
|
-
// 500 — server stack with a warning bolt (server-side failure)
|
|
96
|
-
'500': {
|
|
97
|
-
accent: 'rgb(239 68 68)', // red-500
|
|
98
|
-
paths: (
|
|
99
|
-
<>
|
|
100
|
-
<rect x="3" y="4" width="18" height="6" rx="1.5" />
|
|
101
|
-
<rect x="3" y="14" width="18" height="6" rx="1.5" />
|
|
102
|
-
<path d="M7 7h.01" />
|
|
103
|
-
<path d="M7 17h.01" />
|
|
104
|
-
<path d="m15 6-2 4h3l-2 4" strokeLinejoin="round" />
|
|
105
|
-
</>
|
|
106
|
-
),
|
|
107
|
-
},
|
|
108
|
-
// 502 — two servers with a broken link between
|
|
109
|
-
'502': {
|
|
110
|
-
accent: 'rgb(249 115 22)', // orange-500
|
|
111
|
-
paths: (
|
|
112
|
-
<>
|
|
113
|
-
<rect x="3" y="4" width="8" height="16" rx="1.5" />
|
|
114
|
-
<rect x="13" y="4" width="8" height="16" rx="1.5" />
|
|
115
|
-
<path d="M6 8h.01" />
|
|
116
|
-
<path d="M16 8h.01" />
|
|
117
|
-
<path d="M11 11.5h-1" />
|
|
118
|
-
<path d="M14 12.5h-1" />
|
|
119
|
-
<path d="m11 10 2 4" strokeLinejoin="round" />
|
|
120
|
-
</>
|
|
121
|
-
),
|
|
122
|
-
},
|
|
123
|
-
// 503 — power off / plug pulled
|
|
124
|
-
'503': {
|
|
125
|
-
accent: 'rgb(249 115 22)', // orange-500
|
|
126
|
-
paths: (
|
|
127
|
-
<>
|
|
128
|
-
<path d="M12 2v10" />
|
|
129
|
-
<path d="M18.4 6.6a9 9 0 1 1-12.77 0" />
|
|
130
|
-
</>
|
|
131
|
-
),
|
|
132
|
-
},
|
|
133
|
-
// 504 — clock with slash (gateway timeout)
|
|
134
|
-
'504': {
|
|
135
|
-
accent: 'rgb(234 179 8)', // yellow-500
|
|
136
|
-
paths: (
|
|
137
|
-
<>
|
|
138
|
-
<circle cx="12" cy="12" r="9" />
|
|
139
|
-
<path d="M12 7v5l3 2" />
|
|
140
|
-
</>
|
|
141
|
-
),
|
|
142
|
-
},
|
|
143
|
-
// 408 — clock (request timeout)
|
|
144
|
-
'408': {
|
|
145
|
-
accent: 'rgb(234 179 8)', // yellow-500
|
|
146
|
-
paths: (
|
|
147
|
-
<>
|
|
148
|
-
<circle cx="12" cy="12" r="9" />
|
|
149
|
-
<path d="M12 7v5l3 2" />
|
|
150
|
-
</>
|
|
151
|
-
),
|
|
152
|
-
},
|
|
153
|
-
// 403 — shield with a prohibition line (forbidden)
|
|
154
|
-
'403': {
|
|
155
|
-
accent: 'rgb(239 68 68)', // red-500
|
|
156
|
-
paths: (
|
|
157
|
-
<>
|
|
158
|
-
<path d="M12 2 4 5v6c0 5 3.5 9 8 11 4.5-2 8-6 8-11V5l-8-3Z" />
|
|
159
|
-
<path d="m9 9 6 6" strokeLinejoin="round" />
|
|
160
|
-
</>
|
|
161
|
-
),
|
|
162
|
-
},
|
|
163
|
-
// 401 — key (auth required)
|
|
164
|
-
'401': {
|
|
165
|
-
accent: 'rgb(59 130 246)', // blue-500
|
|
166
|
-
paths: (
|
|
167
|
-
<>
|
|
168
|
-
<circle cx="8" cy="15" r="4" />
|
|
169
|
-
<path d="m10.8 12.2 8.2-8.2" />
|
|
170
|
-
<path d="m17 5 3 3" />
|
|
171
|
-
<path d="m15 7 2 2" />
|
|
172
|
-
</>
|
|
173
|
-
),
|
|
174
|
-
},
|
|
175
|
-
// 400 — general alert triangle (bad request)
|
|
176
|
-
'400': {
|
|
177
|
-
accent: 'rgb(234 179 8)', // yellow-500
|
|
178
|
-
paths: (
|
|
179
|
-
<>
|
|
180
|
-
<path d="m12 3 10 18H2L12 3Z" strokeLinejoin="round" />
|
|
181
|
-
<path d="M12 9v5" />
|
|
182
|
-
<path d="M12 17.5h.01" />
|
|
183
|
-
</>
|
|
184
|
-
),
|
|
185
|
-
},
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const DEFAULT_GLYPH: GlyphSpec = {
|
|
189
|
-
accent: 'rgb(113 113 122)', // zinc-500
|
|
190
|
-
paths: (
|
|
191
|
-
<>
|
|
192
|
-
<circle cx="12" cy="12" r="9" />
|
|
193
|
-
<path d="M12 8v4" />
|
|
194
|
-
<path d="M12 16h.01" />
|
|
195
|
-
</>
|
|
196
|
-
),
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Render the glyph for a given code, wrapped in a container with a soft radial
|
|
201
|
-
* glow behind the icon. Returns `null` if nothing should be shown.
|
|
202
|
-
*/
|
|
203
|
-
function getErrorIcon(code?: string | number): React.ReactNode {
|
|
204
|
-
if (code === undefined || code === null || code === '') return null;
|
|
205
|
-
const c = String(code);
|
|
206
|
-
const spec = GLYPH_SPECS[c] ?? DEFAULT_GLYPH;
|
|
207
|
-
|
|
208
|
-
return (
|
|
209
|
-
<div
|
|
210
|
-
className="relative mx-auto flex size-28 items-center justify-center"
|
|
211
|
-
aria-hidden="true"
|
|
212
|
-
>
|
|
213
|
-
{/* soft radial glow */}
|
|
214
|
-
<div
|
|
215
|
-
className="pointer-events-none absolute inset-0 rounded-full blur-2xl opacity-60"
|
|
216
|
-
style={{
|
|
217
|
-
background: `radial-gradient(circle at 50% 50%, ${spec.accent} 0%, transparent 65%)`,
|
|
218
|
-
}}
|
|
219
|
-
/>
|
|
220
|
-
{/* icon */}
|
|
221
|
-
<svg
|
|
222
|
-
className="relative size-20"
|
|
223
|
-
viewBox={spec.viewBox ?? '0 0 24 24'}
|
|
224
|
-
fill="none"
|
|
225
|
-
stroke="currentColor"
|
|
226
|
-
strokeWidth={1.25}
|
|
227
|
-
strokeLinecap="round"
|
|
228
|
-
style={{ color: spec.accent }}
|
|
229
|
-
>
|
|
230
|
-
{spec.paths}
|
|
231
|
-
</svg>
|
|
232
|
-
</div>
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
73
|
/**
|
|
237
74
|
* ErrorLayout Component
|
|
238
75
|
*/
|
|
@@ -33,6 +33,7 @@ import React, {
|
|
|
33
33
|
import { toast } from '@djangocfg/ui-core/hooks';
|
|
34
34
|
import { useDebugMode, isProduction } from '@djangocfg/devtools';
|
|
35
35
|
|
|
36
|
+
import { generateErrorId, shouldShowError, shouldThrottleProdToast } from './errorDedupe';
|
|
36
37
|
import { createErrorToast } from '../components/ErrorToast';
|
|
37
38
|
import { generateShortErrorId, getProductionToast } from '../utils/formatters';
|
|
38
39
|
import {
|
|
@@ -59,106 +60,6 @@ import type {
|
|
|
59
60
|
} from '../types';
|
|
60
61
|
const ErrorTrackingContext = createContext<ErrorTrackingContextValue | undefined>(undefined);
|
|
61
62
|
|
|
62
|
-
/**
|
|
63
|
-
* Generate unique ID for error
|
|
64
|
-
*/
|
|
65
|
-
let errorIdCounter = 0;
|
|
66
|
-
function generateErrorId(type: string): string {
|
|
67
|
-
return `${type}-error-${Date.now()}-${++errorIdCounter}`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Generate deduplication key for error
|
|
72
|
-
* Used to prevent duplicate toasts for the same error
|
|
73
|
-
*/
|
|
74
|
-
function getErrorDedupeKey(detail: ErrorDetail): string {
|
|
75
|
-
switch (detail.type) {
|
|
76
|
-
case 'validation':
|
|
77
|
-
return `validation:${detail.path}:${detail.method}`;
|
|
78
|
-
case 'cors':
|
|
79
|
-
return `cors:${detail.url}`;
|
|
80
|
-
case 'network':
|
|
81
|
-
return `network:${detail.url}:${detail.statusCode || 'unknown'}`;
|
|
82
|
-
case 'runtime':
|
|
83
|
-
return `runtime:${detail.source}:${detail.message.slice(0, 50)}`;
|
|
84
|
-
default:
|
|
85
|
-
return `unknown:${Date.now()}`;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Deduplication window in ms - same errors within this window are ignored */
|
|
90
|
-
const DEDUPE_WINDOW_MS = 5000;
|
|
91
|
-
|
|
92
|
-
/** Max entries in deduplication map before forced cleanup */
|
|
93
|
-
const DEDUPE_MAX_ENTRIES = 50;
|
|
94
|
-
|
|
95
|
-
/** Map to track recent errors for deduplication (browser-only) */
|
|
96
|
-
let recentErrors: Map<string, number> | null = null;
|
|
97
|
-
|
|
98
|
-
function getRecentErrorsMap(): Map<string, number> {
|
|
99
|
-
// Lazy init - only create in browser
|
|
100
|
-
if (typeof window === 'undefined') {
|
|
101
|
-
// Return dummy map for SSR (won't persist)
|
|
102
|
-
return new Map();
|
|
103
|
-
}
|
|
104
|
-
if (!recentErrors) {
|
|
105
|
-
recentErrors = new Map();
|
|
106
|
-
}
|
|
107
|
-
return recentErrors;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Check if error should be shown (not a duplicate)
|
|
112
|
-
* Returns true if error should be shown, false if it's a duplicate
|
|
113
|
-
*/
|
|
114
|
-
function shouldShowError(detail: ErrorDetail): boolean {
|
|
115
|
-
const map = getRecentErrorsMap();
|
|
116
|
-
const key = getErrorDedupeKey(detail);
|
|
117
|
-
const now = Date.now();
|
|
118
|
-
const lastSeen = map.get(key);
|
|
119
|
-
|
|
120
|
-
// Clean up old entries when map gets too large
|
|
121
|
-
if (map.size > DEDUPE_MAX_ENTRIES) {
|
|
122
|
-
for (const [k, timestamp] of map.entries()) {
|
|
123
|
-
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
|
124
|
-
map.delete(k);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
// If still too large after cleanup, clear oldest half
|
|
128
|
-
if (map.size > DEDUPE_MAX_ENTRIES) {
|
|
129
|
-
const entries = Array.from(map.entries())
|
|
130
|
-
.sort((a, b) => a[1] - b[1])
|
|
131
|
-
.slice(0, Math.floor(map.size / 2));
|
|
132
|
-
for (const [k] of entries) {
|
|
133
|
-
map.delete(k);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Check if we've seen this error recently
|
|
139
|
-
if (lastSeen && now - lastSeen < DEDUPE_WINDOW_MS) {
|
|
140
|
-
return false; // Duplicate, don't show
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Mark as seen
|
|
144
|
-
map.set(key, now);
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Throttle production toasts — max 1 per type within this window */
|
|
149
|
-
const PROD_TOAST_THROTTLE_MS = 3000;
|
|
150
|
-
let lastProdToastByType: Map<string, number> | null = null;
|
|
151
|
-
|
|
152
|
-
function shouldThrottleProdToast(type: string): boolean {
|
|
153
|
-
if (typeof window === 'undefined') return false;
|
|
154
|
-
if (!lastProdToastByType) lastProdToastByType = new Map();
|
|
155
|
-
const now = Date.now();
|
|
156
|
-
const last = lastProdToastByType.get(type);
|
|
157
|
-
if (last && now - last < PROD_TOAST_THROTTLE_MS) return true;
|
|
158
|
-
lastProdToastByType.set(type, now);
|
|
159
|
-
return false;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
63
|
export interface ErrorTrackingProviderProps {
|
|
163
64
|
children: ReactNode;
|
|
164
65
|
validation?: Partial<ValidationErrorConfig>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ErrorDetail } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate unique ID for error
|
|
5
|
+
*/
|
|
6
|
+
let errorIdCounter = 0;
|
|
7
|
+
export function generateErrorId(type: string): string {
|
|
8
|
+
return `${type}-error-${Date.now()}-${++errorIdCounter}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate deduplication key for error
|
|
13
|
+
* Used to prevent duplicate toasts for the same error
|
|
14
|
+
*/
|
|
15
|
+
function getErrorDedupeKey(detail: ErrorDetail): string {
|
|
16
|
+
switch (detail.type) {
|
|
17
|
+
case 'validation':
|
|
18
|
+
return `validation:${detail.path}:${detail.method}`;
|
|
19
|
+
case 'cors':
|
|
20
|
+
return `cors:${detail.url}`;
|
|
21
|
+
case 'network':
|
|
22
|
+
return `network:${detail.url}:${detail.statusCode || 'unknown'}`;
|
|
23
|
+
case 'runtime':
|
|
24
|
+
return `runtime:${detail.source}:${detail.message.slice(0, 50)}`;
|
|
25
|
+
default:
|
|
26
|
+
return `unknown:${Date.now()}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Deduplication window in ms - same errors within this window are ignored */
|
|
31
|
+
const DEDUPE_WINDOW_MS = 5000;
|
|
32
|
+
|
|
33
|
+
/** Max entries in deduplication map before forced cleanup */
|
|
34
|
+
const DEDUPE_MAX_ENTRIES = 50;
|
|
35
|
+
|
|
36
|
+
/** Map to track recent errors for deduplication (browser-only) */
|
|
37
|
+
let recentErrors: Map<string, number> | null = null;
|
|
38
|
+
|
|
39
|
+
function getRecentErrorsMap(): Map<string, number> {
|
|
40
|
+
// Lazy init - only create in browser
|
|
41
|
+
if (typeof window === 'undefined') {
|
|
42
|
+
// Return dummy map for SSR (won't persist)
|
|
43
|
+
return new Map();
|
|
44
|
+
}
|
|
45
|
+
if (!recentErrors) {
|
|
46
|
+
recentErrors = new Map();
|
|
47
|
+
}
|
|
48
|
+
return recentErrors;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if error should be shown (not a duplicate)
|
|
53
|
+
* Returns true if error should be shown, false if it's a duplicate
|
|
54
|
+
*/
|
|
55
|
+
export function shouldShowError(detail: ErrorDetail): boolean {
|
|
56
|
+
const map = getRecentErrorsMap();
|
|
57
|
+
const key = getErrorDedupeKey(detail);
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const lastSeen = map.get(key);
|
|
60
|
+
|
|
61
|
+
// Clean up old entries when map gets too large
|
|
62
|
+
if (map.size > DEDUPE_MAX_ENTRIES) {
|
|
63
|
+
for (const [k, timestamp] of map.entries()) {
|
|
64
|
+
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
|
65
|
+
map.delete(k);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// If still too large after cleanup, clear oldest half
|
|
69
|
+
if (map.size > DEDUPE_MAX_ENTRIES) {
|
|
70
|
+
const entries = Array.from(map.entries())
|
|
71
|
+
.sort((a, b) => a[1] - b[1])
|
|
72
|
+
.slice(0, Math.floor(map.size / 2));
|
|
73
|
+
for (const [k] of entries) {
|
|
74
|
+
map.delete(k);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check if we've seen this error recently
|
|
80
|
+
if (lastSeen && now - lastSeen < DEDUPE_WINDOW_MS) {
|
|
81
|
+
return false; // Duplicate, don't show
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Mark as seen
|
|
85
|
+
map.set(key, now);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Throttle production toasts — max 1 per type within this window */
|
|
90
|
+
const PROD_TOAST_THROTTLE_MS = 3000;
|
|
91
|
+
let lastProdToastByType: Map<string, number> | null = null;
|
|
92
|
+
|
|
93
|
+
export function shouldThrottleProdToast(type: string): boolean {
|
|
94
|
+
if (typeof window === 'undefined') return false;
|
|
95
|
+
if (!lastProdToastByType) lastProdToastByType = new Map();
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const last = lastProdToastByType.get(type);
|
|
98
|
+
if (last && now - last < PROD_TOAST_THROTTLE_MS) return true;
|
|
99
|
+
lastProdToastByType.set(type, now);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
interface GlyphSpec {
|
|
6
|
+
/** Accent tint used for the glow and the gradient stop. */
|
|
7
|
+
accent: string;
|
|
8
|
+
/** viewBox for the inner SVG. Default 24 24. */
|
|
9
|
+
viewBox?: string;
|
|
10
|
+
/** Inner SVG children. Must use `currentColor` for stroke. */
|
|
11
|
+
paths: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const GLYPH_SPECS: Record<string, GlyphSpec> = {
|
|
15
|
+
// 404 — magnifier over a page corner (not-found)
|
|
16
|
+
'404': {
|
|
17
|
+
accent: 'rgb(139 92 246)', // violet-500
|
|
18
|
+
paths: (
|
|
19
|
+
<>
|
|
20
|
+
<path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7.5" />
|
|
21
|
+
<path d="M13.5 3 19 8.5" />
|
|
22
|
+
<path d="M13 3v4a2 2 0 0 0 2 2h4" />
|
|
23
|
+
<circle cx="11" cy="14.5" r="2.5" />
|
|
24
|
+
<path d="m13 16.5 2 2" />
|
|
25
|
+
</>
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
// 500 — server stack with a warning bolt (server-side failure)
|
|
29
|
+
'500': {
|
|
30
|
+
accent: 'rgb(239 68 68)', // red-500
|
|
31
|
+
paths: (
|
|
32
|
+
<>
|
|
33
|
+
<rect x="3" y="4" width="18" height="6" rx="1.5" />
|
|
34
|
+
<rect x="3" y="14" width="18" height="6" rx="1.5" />
|
|
35
|
+
<path d="M7 7h.01" />
|
|
36
|
+
<path d="M7 17h.01" />
|
|
37
|
+
<path d="m15 6-2 4h3l-2 4" strokeLinejoin="round" />
|
|
38
|
+
</>
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
// 502 — two servers with a broken link between
|
|
42
|
+
'502': {
|
|
43
|
+
accent: 'rgb(249 115 22)', // orange-500
|
|
44
|
+
paths: (
|
|
45
|
+
<>
|
|
46
|
+
<rect x="3" y="4" width="8" height="16" rx="1.5" />
|
|
47
|
+
<rect x="13" y="4" width="8" height="16" rx="1.5" />
|
|
48
|
+
<path d="M6 8h.01" />
|
|
49
|
+
<path d="M16 8h.01" />
|
|
50
|
+
<path d="M11 11.5h-1" />
|
|
51
|
+
<path d="M14 12.5h-1" />
|
|
52
|
+
<path d="m11 10 2 4" strokeLinejoin="round" />
|
|
53
|
+
</>
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
// 503 — power off / plug pulled
|
|
57
|
+
'503': {
|
|
58
|
+
accent: 'rgb(249 115 22)', // orange-500
|
|
59
|
+
paths: (
|
|
60
|
+
<>
|
|
61
|
+
<path d="M12 2v10" />
|
|
62
|
+
<path d="M18.4 6.6a9 9 0 1 1-12.77 0" />
|
|
63
|
+
</>
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
// 504 — clock with slash (gateway timeout)
|
|
67
|
+
'504': {
|
|
68
|
+
accent: 'rgb(234 179 8)', // yellow-500
|
|
69
|
+
paths: (
|
|
70
|
+
<>
|
|
71
|
+
<circle cx="12" cy="12" r="9" />
|
|
72
|
+
<path d="M12 7v5l3 2" />
|
|
73
|
+
</>
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
// 408 — clock (request timeout)
|
|
77
|
+
'408': {
|
|
78
|
+
accent: 'rgb(234 179 8)', // yellow-500
|
|
79
|
+
paths: (
|
|
80
|
+
<>
|
|
81
|
+
<circle cx="12" cy="12" r="9" />
|
|
82
|
+
<path d="M12 7v5l3 2" />
|
|
83
|
+
</>
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
// 403 — shield with a prohibition line (forbidden)
|
|
87
|
+
'403': {
|
|
88
|
+
accent: 'rgb(239 68 68)', // red-500
|
|
89
|
+
paths: (
|
|
90
|
+
<>
|
|
91
|
+
<path d="M12 2 4 5v6c0 5 3.5 9 8 11 4.5-2 8-6 8-11V5l-8-3Z" />
|
|
92
|
+
<path d="m9 9 6 6" strokeLinejoin="round" />
|
|
93
|
+
</>
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
// 401 — key (auth required)
|
|
97
|
+
'401': {
|
|
98
|
+
accent: 'rgb(59 130 246)', // blue-500
|
|
99
|
+
paths: (
|
|
100
|
+
<>
|
|
101
|
+
<circle cx="8" cy="15" r="4" />
|
|
102
|
+
<path d="m10.8 12.2 8.2-8.2" />
|
|
103
|
+
<path d="m17 5 3 3" />
|
|
104
|
+
<path d="m15 7 2 2" />
|
|
105
|
+
</>
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
// 400 — general alert triangle (bad request)
|
|
109
|
+
'400': {
|
|
110
|
+
accent: 'rgb(234 179 8)', // yellow-500
|
|
111
|
+
paths: (
|
|
112
|
+
<>
|
|
113
|
+
<path d="m12 3 10 18H2L12 3Z" strokeLinejoin="round" />
|
|
114
|
+
<path d="M12 9v5" />
|
|
115
|
+
<path d="M12 17.5h.01" />
|
|
116
|
+
</>
|
|
117
|
+
),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const DEFAULT_GLYPH: GlyphSpec = {
|
|
122
|
+
accent: 'rgb(113 113 122)', // zinc-500
|
|
123
|
+
paths: (
|
|
124
|
+
<>
|
|
125
|
+
<circle cx="12" cy="12" r="9" />
|
|
126
|
+
<path d="M12 8v4" />
|
|
127
|
+
<path d="M12 16h.01" />
|
|
128
|
+
</>
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render the glyph for a given code, wrapped in a container with a soft radial
|
|
134
|
+
* glow behind the icon. Returns `null` if nothing should be shown.
|
|
135
|
+
*/
|
|
136
|
+
export function getErrorIcon(code?: string | number): React.ReactNode {
|
|
137
|
+
if (code === undefined || code === null || code === '') return null;
|
|
138
|
+
const c = String(code);
|
|
139
|
+
const spec = GLYPH_SPECS[c] ?? DEFAULT_GLYPH;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
className="relative mx-auto flex size-28 items-center justify-center"
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
>
|
|
146
|
+
{/* soft radial glow */}
|
|
147
|
+
<div
|
|
148
|
+
className="pointer-events-none absolute inset-0 rounded-full blur-2xl opacity-60"
|
|
149
|
+
style={{
|
|
150
|
+
background: `radial-gradient(circle at 50% 50%, ${spec.accent} 0%, transparent 65%)`,
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
{/* icon */}
|
|
154
|
+
<svg
|
|
155
|
+
className="relative size-20"
|
|
156
|
+
viewBox={spec.viewBox ?? '0 0 24 24'}
|
|
157
|
+
fill="none"
|
|
158
|
+
stroke="currentColor"
|
|
159
|
+
strokeWidth={1.25}
|
|
160
|
+
strokeLinecap="round"
|
|
161
|
+
style={{ color: spec.accent }}
|
|
162
|
+
>
|
|
163
|
+
{spec.paths}
|
|
164
|
+
</svg>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -61,7 +61,7 @@ import { AnalyticsProvider } from '../../snippets/Analytics';
|
|
|
61
61
|
import { AuthDialog } from '../../snippets/AuthDialog';
|
|
62
62
|
|
|
63
63
|
import type { BaseLayoutProps } from '../types/layout.types';
|
|
64
|
-
import { LayoutI18nProvider } from '
|
|
64
|
+
import { LayoutI18nProvider } from '../../shared/LayoutI18nProvider';
|
|
65
65
|
|
|
66
66
|
import React from 'react';
|
|
67
67
|
|
|
@@ -17,8 +17,8 @@ export {
|
|
|
17
17
|
LayoutI18nProvider,
|
|
18
18
|
useLayoutI18n,
|
|
19
19
|
useLayoutI18nOptional,
|
|
20
|
-
} from '
|
|
21
|
-
export type { LayoutI18nProviderProps } from '
|
|
20
|
+
} from '../../shared/LayoutI18nProvider';
|
|
21
|
+
export type { LayoutI18nProviderProps } from '../../shared/LayoutI18nProvider';
|
|
22
22
|
|
|
23
23
|
// Re-export theme-override primitives so consumers can force a theme on a
|
|
24
24
|
// subtree (e.g. an always-dark marketing route) without depending on ui-core
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
'use client';
|
|
15
15
|
|
|
16
|
-
import React, {
|
|
16
|
+
import React, { memo, useMemo } from 'react';
|
|
17
17
|
|
|
18
18
|
import { consumeSavedRedirect, useAuth, useCfgRouter } from '@djangocfg/api/auth';
|
|
19
19
|
import { useAnalytics } from '@djangocfg/analytics';
|
|
@@ -34,31 +34,11 @@ import './styles/split-shell.css';
|
|
|
34
34
|
import './styles/fullsplit-shell.css';
|
|
35
35
|
|
|
36
36
|
import type { AuthLayoutProps } from './types';
|
|
37
|
+
import { AuthLayoutContext } from './layoutContext';
|
|
38
|
+
export { useAuthLayoutContext } from './layoutContext';
|
|
37
39
|
|
|
38
40
|
// ─── Layout-level context (header suppression, etc.) ─────────────────────────
|
|
39
41
|
|
|
40
|
-
interface AuthLayoutContextValue {
|
|
41
|
-
/** True when the consumer passed custom children — step headers should be hidden */
|
|
42
|
-
hideHeader: boolean;
|
|
43
|
-
/**
|
|
44
|
-
* True when the brand is already rendered on the media half, so the form
|
|
45
|
-
* header must not repeat it.
|
|
46
|
-
*
|
|
47
|
-
* A statement about the DESKTOP layout only: `fullsplit` drops the media half
|
|
48
|
-
* below 1024px, where the logo above the form is the only brand left. CSS
|
|
49
|
-
* owns that half of the decision on the same breakpoint — see
|
|
50
|
-
* `.auth-header[data-brand-elsewhere]` in `auth.css`.
|
|
51
|
-
*/
|
|
52
|
-
hasBrandPanel: boolean;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const AuthLayoutContext = createContext<AuthLayoutContextValue>({
|
|
56
|
-
hideHeader: false,
|
|
57
|
-
hasBrandPanel: false,
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
export const useAuthLayoutContext = (): AuthLayoutContextValue => useContext(AuthLayoutContext);
|
|
61
|
-
|
|
62
42
|
/**
|
|
63
43
|
* The provider itself, for harnesses that mount the steps without `AuthLayout`
|
|
64
44
|
* (Storybook does, via `AuthShell`). Without it those previews always read the
|
|
@@ -8,7 +8,7 @@ import { useAppT } from '@djangocfg/i18n';
|
|
|
8
8
|
import { Link } from '@djangocfg/ui-core/components';
|
|
9
9
|
|
|
10
10
|
import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
|
|
11
|
-
import { useLayoutI18nOptional } from '
|
|
11
|
+
import { useLayoutI18nOptional } from '../../../../shared/LayoutI18nProvider';
|
|
12
12
|
import { AUTH } from '../../constants';
|
|
13
13
|
|
|
14
14
|
export interface AuthTopBarProps {
|
|
@@ -7,7 +7,7 @@ import { useGithubAuth } from '@djangocfg/api/auth';
|
|
|
7
7
|
import { useAppT } from '@djangocfg/i18n';
|
|
8
8
|
import { Checkbox, Input } from '@djangocfg/ui-core/components';
|
|
9
9
|
|
|
10
|
-
import { useAuthLayoutContext } from '../../
|
|
10
|
+
import { useAuthLayoutContext } from '../../layoutContext';
|
|
11
11
|
import { useAuthFormContext } from '../../context';
|
|
12
12
|
import {
|
|
13
13
|
AuthButton,
|