@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.449",
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.449",
93
- "@djangocfg/centrifugo": "^2.1.449",
94
- "@djangocfg/debuger": "^2.1.449",
95
- "@djangocfg/i18n": "^2.1.449",
96
- "@djangocfg/monitor": "^2.1.449",
97
- "@djangocfg/ui-core": "^2.1.449",
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.449",
129
- "@djangocfg/centrifugo": "^2.1.449",
130
- "@djangocfg/debuger": "^2.1.449",
131
- "@djangocfg/i18n": "^2.1.449",
132
- "@djangocfg/monitor": "^2.1.449",
133
- "@djangocfg/typescript-config": "^2.1.449",
134
- "@djangocfg/ui-core": "^2.1.449",
135
- "@djangocfg/ui-tools": "^2.1.449",
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 { useAuth } from '@djangocfg/api/auth';
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 || !requireAuth) return;
46
- if (!authLoading && !isAuthenticated && !isRedirecting) {
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
- }, [mounted, requireAuth, isAuthenticated, authLoading, isRedirecting, router, saveRedirectUrl, authPath]);
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: !requireAuth || isAuthenticated,
96
+ isAuthenticated: resolveGuardIsAuthenticated(guardState),
61
97
  loadingText,
62
98
  };
63
99
  }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { Check, Upload, X } from 'lucide-react';
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 [avatarFile, setAvatarFile] = useState<File | null>(null);
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 getInitials = (name: string) => {
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 formatFileSize = (bytes: number) => {
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
- if (file) {
35
- if (!file.type.startsWith('image/')) {
36
- toast.error('Please select an image file');
37
- return;
38
- }
39
- if (file.size > 5 * 1024 * 1024) {
40
- toast.error('File size must be less than 5MB');
41
- return;
42
- }
43
- setAvatarFile(file);
44
- const reader = new FileReader();
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
- const handleAvatarUpload = async () => {
54
- if (!avatarFile) return;
49
+ const objectUrl = URL.createObjectURL(file);
50
+ setPreviewUrl(objectUrl);
55
51
  setIsUploading(true);
56
52
  try {
57
- await uploadAvatar(avatarFile);
58
- toast.success('Avatar updated successfully');
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 resetAvatar = () => {
70
- setAvatarFile(null);
71
- setAvatarPreview(null);
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 flex-shrink-0">
76
- <div className="relative group">
77
- <Avatar
78
- className="w-14 h-14 md:w-20 md:h-20 aspect-square rounded-full overflow-hidden ring-1 ring-foreground/20 transition-transform group-hover:scale-105"
79
- >
80
- {avatarPreview ? (
81
- <img
82
- src={avatarPreview}
83
- alt="Avatar preview"
84
- className="w-full h-full object-cover"
85
- style={{ borderRadius: '50%' }}
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="text-2xl font-semibold bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
96
- {getInitials(user?.display_username || user?.email || '')}
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
- {/* Upload overlay - appears on hover */}
102
- <label className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer">
103
- <div className="p-3 rounded-full bg-primary/80 text-primary-foreground hover:bg-primary transition-colors">
104
- <Upload className="w-5 h-5" />
105
- </div>
106
- <input
107
- type="file"
108
- accept="image/*"
109
- onChange={handleAvatarChange}
110
- className="hidden"
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
- {/* File info - shows when file is selected */}
142
- {avatarFile && (
143
- <div className="mt-3 text-center">
144
- <p className="text-xs text-muted-foreground">
145
- {avatarFile.name} ({formatFileSize(avatarFile.size)} MB)
146
- </p>
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 || ''}