@djangocfg/layouts 2.1.449 → 2.1.452
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.452",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -89,12 +89,12 @@
|
|
|
89
89
|
"check": "tsc --noEmit"
|
|
90
90
|
},
|
|
91
91
|
"peerDependencies": {
|
|
92
|
-
"@djangocfg/api": "^2.1.
|
|
93
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
94
|
-
"@djangocfg/debuger": "^2.1.
|
|
95
|
-
"@djangocfg/i18n": "^2.1.
|
|
96
|
-
"@djangocfg/monitor": "^2.1.
|
|
97
|
-
"@djangocfg/ui-core": "^2.1.
|
|
92
|
+
"@djangocfg/api": "^2.1.452",
|
|
93
|
+
"@djangocfg/centrifugo": "^2.1.452",
|
|
94
|
+
"@djangocfg/debuger": "^2.1.452",
|
|
95
|
+
"@djangocfg/i18n": "^2.1.452",
|
|
96
|
+
"@djangocfg/monitor": "^2.1.452",
|
|
97
|
+
"@djangocfg/ui-core": "^2.1.452",
|
|
98
98
|
"@hookform/resolvers": "^5.2.2",
|
|
99
99
|
"consola": "^3.4.2",
|
|
100
100
|
"lucide-react": "^0.545.0",
|
|
@@ -125,14 +125,14 @@
|
|
|
125
125
|
"uuid": "^11.1.0"
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@djangocfg/api": "^2.1.
|
|
129
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
130
|
-
"@djangocfg/debuger": "^2.1.
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
132
|
-
"@djangocfg/monitor": "^2.1.
|
|
133
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
134
|
-
"@djangocfg/ui-core": "^2.1.
|
|
135
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
128
|
+
"@djangocfg/api": "^2.1.452",
|
|
129
|
+
"@djangocfg/centrifugo": "^2.1.452",
|
|
130
|
+
"@djangocfg/debuger": "^2.1.452",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.452",
|
|
132
|
+
"@djangocfg/monitor": "^2.1.452",
|
|
133
|
+
"@djangocfg/typescript-config": "^2.1.452",
|
|
134
|
+
"@djangocfg/ui-core": "^2.1.452",
|
|
135
|
+
"@djangocfg/ui-tools": "^2.1.452",
|
|
136
136
|
"@types/node": "^25.2.3",
|
|
137
137
|
"@types/react": "^19.2.15",
|
|
138
138
|
"@types/react-dom": "^19.2.3",
|
|
@@ -9,11 +9,23 @@
|
|
|
9
9
|
import { useEffect, useState } from 'react';
|
|
10
10
|
import { useRouter } from 'next/navigation';
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
useAuth,
|
|
14
|
+
resolveGuardIsLoading,
|
|
15
|
+
resolveGuardIsAuthenticated,
|
|
16
|
+
shouldRedirectToAuth,
|
|
17
|
+
} from '@djangocfg/api/auth';
|
|
13
18
|
|
|
14
19
|
interface UseAuthGuardOptions {
|
|
15
20
|
requireAuth?: boolean;
|
|
16
21
|
authPath?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Safety net: if auth is still "loading" this many ms after mount, stop
|
|
24
|
+
* waiting and let the guard decide from whatever token state we have. Prevents
|
|
25
|
+
* an endless preloader if AuthContext initialization ever wedges (a hung
|
|
26
|
+
* request, a CSP-swallowed response, an unresolved promise). Default 8s.
|
|
27
|
+
*/
|
|
28
|
+
loadingTimeoutMs?: number;
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
interface UseAuthGuardResult {
|
|
@@ -28,6 +40,7 @@ interface UseAuthGuardResult {
|
|
|
28
40
|
export function useAuthGuard({
|
|
29
41
|
requireAuth = true,
|
|
30
42
|
authPath = '/auth',
|
|
43
|
+
loadingTimeoutMs = 8000,
|
|
31
44
|
}: UseAuthGuardOptions): UseAuthGuardResult {
|
|
32
45
|
const { isAuthenticated, isLoading: authLoading, saveRedirectUrl } = useAuth();
|
|
33
46
|
const router = useRouter();
|
|
@@ -41,23 +54,46 @@ export function useAuthGuard({
|
|
|
41
54
|
const [mounted, setMounted] = useState(false);
|
|
42
55
|
useEffect(() => setMounted(true), []);
|
|
43
56
|
|
|
57
|
+
// Safety-net timeout: force auth to be considered "resolved" if it hasn't
|
|
58
|
+
// settled within loadingTimeoutMs, so the guard can act (render or redirect)
|
|
59
|
+
// instead of spinning forever. Only armed while actually loading.
|
|
60
|
+
const [authTimedOut, setAuthTimedOut] = useState(false);
|
|
44
61
|
useEffect(() => {
|
|
45
|
-
if (!mounted || !
|
|
46
|
-
|
|
62
|
+
if (!mounted || !authLoading || authTimedOut) return;
|
|
63
|
+
const t = setTimeout(() => setAuthTimedOut(true), loadingTimeoutMs);
|
|
64
|
+
return () => clearTimeout(t);
|
|
65
|
+
}, [mounted, authLoading, authTimedOut, loadingTimeoutMs]);
|
|
66
|
+
|
|
67
|
+
// Guard state derives from a single pure resolver (see @djangocfg/api/auth
|
|
68
|
+
// utils/guard) so the "spinner vs redirect vs render" decision is unit-tested
|
|
69
|
+
// in isolation and can't silently regress into the "stuck on Authenticating…"
|
|
70
|
+
// state where a resolved-but-unauthenticated session never redirects.
|
|
71
|
+
// `authLoading` is forced false once the safety-net timeout fires.
|
|
72
|
+
const effectiveAuthLoading = authLoading && !authTimedOut;
|
|
73
|
+
const guardState = {
|
|
74
|
+
mounted,
|
|
75
|
+
requireAuth,
|
|
76
|
+
authLoading: effectiveAuthLoading,
|
|
77
|
+
isRedirecting,
|
|
78
|
+
isAuthenticated,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (shouldRedirectToAuth(guardState)) {
|
|
47
83
|
const currentUrl = window.location.pathname + window.location.search;
|
|
48
84
|
saveRedirectUrl(currentUrl);
|
|
49
85
|
setIsRedirecting(true);
|
|
50
86
|
router.push(authPath);
|
|
51
87
|
}
|
|
52
|
-
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
}, [mounted, requireAuth, isAuthenticated, effectiveAuthLoading, isRedirecting, router, saveRedirectUrl, authPath]);
|
|
53
90
|
|
|
54
|
-
const isLoading =
|
|
55
|
-
!mounted || (requireAuth && (authLoading || isRedirecting || !isAuthenticated));
|
|
91
|
+
const isLoading = resolveGuardIsLoading(guardState);
|
|
56
92
|
const loadingText = isRedirecting ? 'Redirecting to login...' : 'Authenticating...';
|
|
57
93
|
|
|
58
94
|
return {
|
|
59
95
|
isLoading,
|
|
60
|
-
isAuthenticated:
|
|
96
|
+
isAuthenticated: resolveGuardIsAuthenticated(guardState),
|
|
61
97
|
loadingText,
|
|
62
98
|
};
|
|
63
99
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import React, { useState } from 'react';
|
|
3
|
+
import { Loader2, Upload } from 'lucide-react';
|
|
4
|
+
import React, { useRef, useState } from 'react';
|
|
5
5
|
import { toast } from '@djangocfg/ui-core/hooks';
|
|
6
6
|
|
|
7
7
|
import { useAuth } from '@djangocfg/api/auth';
|
|
@@ -9,144 +9,124 @@ import { Avatar, AvatarFallback, Button } from '@djangocfg/ui-core/components';
|
|
|
9
9
|
|
|
10
10
|
import { profileLogger } from '../../../utils/logger';
|
|
11
11
|
|
|
12
|
+
const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Avatar header — a centered profile identity block that sits *above* the
|
|
16
|
+
* settings rows, not inside one. Avatar isn't a form field like "First name";
|
|
17
|
+
* it's the user's identity, so it gets the canonical Apple-ID / Slack / Linear
|
|
18
|
+
* treatment: large centered avatar → name/email → a single "Change avatar"
|
|
19
|
+
* action.
|
|
20
|
+
*
|
|
21
|
+
* UX: the avatar is a preview only; "Change avatar" opens the picker and
|
|
22
|
+
* uploads immediately (no Save/Cancel step). The preview is optimistic — shown
|
|
23
|
+
* instantly from a local object URL — and rolls back if the upload fails.
|
|
24
|
+
* The whole avatar is also clickable for a faster path.
|
|
25
|
+
*/
|
|
12
26
|
export const AvatarSection = () => {
|
|
13
27
|
const { user, uploadAvatar } = useAuth();
|
|
14
|
-
const
|
|
15
|
-
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
|
28
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
16
29
|
const [isUploading, setIsUploading] = useState(false);
|
|
30
|
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
17
31
|
|
|
18
|
-
const
|
|
19
|
-
if (!name) return 'UN';
|
|
20
|
-
return name
|
|
21
|
-
.split(' ')
|
|
22
|
-
.map((word) => word[0])
|
|
23
|
-
.join('')
|
|
24
|
-
.toUpperCase()
|
|
25
|
-
.slice(0, 2);
|
|
26
|
-
};
|
|
32
|
+
const handlePick = () => inputRef.current?.click();
|
|
27
33
|
|
|
28
|
-
const
|
|
29
|
-
return (bytes / 1024 / 1024).toFixed(2);
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const handleAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
34
|
+
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
33
35
|
const file = event.target.files?.[0];
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
reader.onload = (e) => setAvatarPreview(e.target?.result as string);
|
|
46
|
-
reader.readAsDataURL(file);
|
|
47
|
-
} else {
|
|
48
|
-
setAvatarFile(null);
|
|
49
|
-
setAvatarPreview(null);
|
|
36
|
+
// Reset so picking the same file twice still fires onChange.
|
|
37
|
+
event.target.value = '';
|
|
38
|
+
if (!file) return;
|
|
39
|
+
|
|
40
|
+
if (!file.type.startsWith('image/')) {
|
|
41
|
+
toast.error('Please select an image file');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (file.size > MAX_AVATAR_BYTES) {
|
|
45
|
+
toast.error('File size must be less than 5MB');
|
|
46
|
+
return;
|
|
50
47
|
}
|
|
51
|
-
};
|
|
52
48
|
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
const objectUrl = URL.createObjectURL(file);
|
|
50
|
+
setPreviewUrl(objectUrl);
|
|
55
51
|
setIsUploading(true);
|
|
56
52
|
try {
|
|
57
|
-
await uploadAvatar(
|
|
58
|
-
toast.success('Avatar updated
|
|
59
|
-
setAvatarFile(null);
|
|
60
|
-
setAvatarPreview(null);
|
|
53
|
+
await uploadAvatar(file);
|
|
54
|
+
toast.success('Avatar updated');
|
|
61
55
|
} catch (error) {
|
|
56
|
+
setPreviewUrl(null); // roll back optimistic preview
|
|
62
57
|
toast.error('Failed to upload avatar');
|
|
63
58
|
profileLogger.error('Avatar upload error:', error);
|
|
64
59
|
} finally {
|
|
65
60
|
setIsUploading(false);
|
|
61
|
+
URL.revokeObjectURL(objectUrl);
|
|
62
|
+
setPreviewUrl(null); // fall back to the fresh server user.avatar
|
|
66
63
|
}
|
|
67
64
|
};
|
|
68
65
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
const displayUrl = previewUrl ?? user?.avatar ?? null;
|
|
67
|
+
const displayName = user?.display_username || user?.email || '';
|
|
68
|
+
// Server computes `initials` for the avatar fallback — use it verbatim
|
|
69
|
+
// instead of re-deriving (keeps the letters consistent with the backend).
|
|
70
|
+
const initials = user?.initials || 'UN';
|
|
73
71
|
|
|
74
72
|
return (
|
|
75
|
-
<div className="flex flex-col items-center
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
/>
|
|
87
|
-
) : user?.avatar ? (
|
|
88
|
-
<img
|
|
89
|
-
src={user.avatar}
|
|
90
|
-
alt="User avatar"
|
|
91
|
-
className="w-full h-full object-cover"
|
|
92
|
-
style={{ borderRadius: '50%' }}
|
|
93
|
-
/>
|
|
73
|
+
<div className="flex flex-col items-center gap-3 py-2">
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={handlePick}
|
|
77
|
+
disabled={isUploading}
|
|
78
|
+
className="group relative rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
79
|
+
aria-label="Change avatar"
|
|
80
|
+
>
|
|
81
|
+
<Avatar className="h-16 w-16 overflow-hidden rounded-full ring-1 ring-foreground/10 transition-transform group-hover:scale-105 md:h-20 md:w-20">
|
|
82
|
+
{displayUrl ? (
|
|
83
|
+
<img src={displayUrl} alt="Avatar" className="h-full w-full object-cover" />
|
|
94
84
|
) : (
|
|
95
|
-
<AvatarFallback className="
|
|
96
|
-
{
|
|
85
|
+
<AvatarFallback className="bg-gradient-to-br from-primary to-primary/80 text-xl font-semibold text-primary-foreground">
|
|
86
|
+
{initials}
|
|
97
87
|
</AvatarFallback>
|
|
98
88
|
)}
|
|
99
89
|
</Avatar>
|
|
100
90
|
|
|
101
|
-
{/*
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
</label>
|
|
113
|
-
|
|
114
|
-
{/* Action buttons - appear when file is selected */}
|
|
115
|
-
{avatarFile && (
|
|
116
|
-
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 flex items-center space-x-1 bg-card border border-border rounded-full shadow-lg p-1">
|
|
117
|
-
<Button
|
|
118
|
-
size="sm"
|
|
119
|
-
onClick={handleAvatarUpload}
|
|
120
|
-
disabled={isUploading}
|
|
121
|
-
className="h-7 px-3 rounded-full"
|
|
122
|
-
>
|
|
123
|
-
{isUploading ? (
|
|
124
|
-
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
|
|
125
|
-
) : (
|
|
126
|
-
<Check className="w-3 h-3" />
|
|
127
|
-
)}
|
|
128
|
-
</Button>
|
|
129
|
-
<Button
|
|
130
|
-
size="sm"
|
|
131
|
-
variant="outline"
|
|
132
|
-
onClick={resetAvatar}
|
|
133
|
-
className="h-7 w-7 rounded-full p-0"
|
|
134
|
-
>
|
|
135
|
-
<X className="w-3 h-3" />
|
|
136
|
-
</Button>
|
|
137
|
-
</div>
|
|
138
|
-
)}
|
|
139
|
-
</div>
|
|
91
|
+
{/* Hover / uploading overlay */}
|
|
92
|
+
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/45 text-white opacity-0 transition-opacity group-hover:opacity-100 data-[busy=true]:opacity-100"
|
|
93
|
+
data-busy={isUploading}
|
|
94
|
+
>
|
|
95
|
+
{isUploading ? (
|
|
96
|
+
<Loader2 className="h-5 w-5 animate-spin" />
|
|
97
|
+
) : (
|
|
98
|
+
<Upload className="h-5 w-5" />
|
|
99
|
+
)}
|
|
100
|
+
</span>
|
|
101
|
+
</button>
|
|
140
102
|
|
|
141
|
-
{
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
103
|
+
{displayName && (
|
|
104
|
+
<div className="text-center">
|
|
105
|
+
<p className="text-sm font-medium text-foreground">{displayName}</p>
|
|
106
|
+
{user?.email && user.display_username && (
|
|
107
|
+
<p className="text-xs text-muted-foreground">{user.email}</p>
|
|
108
|
+
)}
|
|
147
109
|
</div>
|
|
148
110
|
)}
|
|
111
|
+
|
|
112
|
+
<Button
|
|
113
|
+
type="button"
|
|
114
|
+
variant="outline"
|
|
115
|
+
size="sm"
|
|
116
|
+
onClick={handlePick}
|
|
117
|
+
disabled={isUploading}
|
|
118
|
+
className="h-8"
|
|
119
|
+
>
|
|
120
|
+
{isUploading ? 'Uploading…' : 'Change avatar'}
|
|
121
|
+
</Button>
|
|
122
|
+
|
|
123
|
+
<input
|
|
124
|
+
ref={inputRef}
|
|
125
|
+
type="file"
|
|
126
|
+
accept="image/*"
|
|
127
|
+
onChange={handleFileChange}
|
|
128
|
+
className="hidden"
|
|
129
|
+
/>
|
|
149
130
|
</div>
|
|
150
131
|
);
|
|
151
132
|
};
|
|
152
|
-
|
|
@@ -33,16 +33,11 @@ export const AccountSection: React.FC<AccountSectionProps> = ({
|
|
|
33
33
|
|
|
34
34
|
return (
|
|
35
35
|
<div className="mx-auto max-w-2xl space-y-7">
|
|
36
|
+
{/* Avatar is the profile's identity header — centered above the fields,
|
|
37
|
+
not a form row. */}
|
|
38
|
+
<AvatarSection />
|
|
39
|
+
|
|
36
40
|
<SettingsBlock title={labels.personalInfo}>
|
|
37
|
-
<SettingRow label="Avatar">
|
|
38
|
-
{/* AvatarSection ships a 56–80px avatar; constrain to ~40px without
|
|
39
|
-
forking it (the fixed box drives row height, inner scale shrinks). */}
|
|
40
|
-
<div className="flex h-10 w-10 items-center justify-center">
|
|
41
|
-
<div className="origin-center scale-[0.5]">
|
|
42
|
-
<AvatarSection />
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|
|
45
|
-
</SettingRow>
|
|
46
41
|
<SettingRow
|
|
47
42
|
label={labels.firstName}
|
|
48
43
|
value={user.first_name || ''}
|