Skip to content
File

Blob: src/client/components/profile-settings.tsx

typescript240 lines
1import { useState, useCallback, useMemo, useRef } from "react";
2import { Link } from "@tanstack/react-router";
3import { ArrowLeft, Save, Loader2, Camera, X, User as UserIcon } from "lucide-react";
4import { Button } from "@/client/components/ui/button";
5import { useAuthStore } from "@/client/stores/auth-store";
6import { useMemberWorkspaces } from "@/client/stores/workspace-directory";
7import { useWorkspaceReplica } from "@/client/stores/workspace-replica";
8import { useLastVisitedWorkspaceId } from "@/client/stores/workspace-navigation";
9import { api, toApiError } from "@/client/lib/api";
10import { SESSION_MODES } from "@/client/lib/constants";
11import { useDocumentTitle } from "@/client/hooks/use-document-title";
12import { Skeleton } from "@/client/components/ui/skeleton";
13import { isWorkspaceWriterRole } from "@/shared/entitlements";
14 
15function ProfileSettingsSkeleton() {
16 return (
17 <div className="mx-auto max-w-2xl px-8 py-10" aria-busy="true">
18 <Skeleton className="mb-6 h-5 w-24 rounded-md" />
19 <Skeleton className="mb-8 h-6 w-24 rounded-md" />
20 <div className="space-y-6">
21 <div className="flex items-center gap-6">
22 <Skeleton className="h-20 w-20 rounded-full" />
23 <div className="flex-1 space-y-2">
24 <Skeleton className="h-4 w-28 rounded-md" />
25 <Skeleton className="h-3 w-48 rounded-md" />
26 </div>
27 </div>
28 <div className="space-y-2">
29 <Skeleton className="h-4 w-16 rounded-md" />
30 <Skeleton className="h-10 w-full rounded-lg" />
31 </div>
32 <div className="space-y-2">
33 <Skeleton className="h-4 w-16 rounded-md" />
34 <Skeleton className="h-10 w-full rounded-lg" />
35 </div>
36 <Skeleton className="h-9 w-28 rounded-lg" />
37 </div>
38 </div>
39 );
40}
41 
42export function ProfileSettings() {
43 useDocumentTitle("Profile");
44 const user = useAuthStore((s) => s.user);
45 const sessionMode = useAuthStore((s) => s.sessionMode);
46 const refreshState = useAuthStore((s) => s.refreshState);
47 const lastWsId = useLastVisitedWorkspaceId();
48 const lastWsReplica = useWorkspaceReplica(lastWsId);
49 const memberWorkspaces = useMemberWorkspaces();
50 // `currentWorkspace` drives the Back link (keep pointing at the last-visited
51 // workspace — shared or member). Avatar upload target is derived separately.
52 const currentWorkspace = lastWsReplica?.workspace ?? null;
53 
54 // Avatar presign borrows workspace-level upload authorization, which only
55 // succeeds for writer roles. Prefer the last-visited workspace when it is
56 // writable, else fall back to any writable workspace on the account. When
57 // none exists, the upload affordance is disabled with explanatory copy.
58 const avatarUploadWorkspace = useMemo(() => {
59 const preferred = lastWsId ? memberWorkspaces.find((w) => w.id === lastWsId) : undefined;
60 if (preferred && isWorkspaceWriterRole(preferred.role)) return preferred;
61 return memberWorkspaces.find((w) => isWorkspaceWriterRole(w.role)) ?? null;
62 }, [lastWsId, memberWorkspaces]);
63 
64 const [name, setName] = useState(user?.name ?? "");
65 const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
66 const [saving, setSaving] = useState(false);
67 const [error, setError] = useState<string | null>(null);
68 const [success, setSuccess] = useState(false);
69 const [uploading, setUploading] = useState(false);
70 const fileInputRef = useRef<HTMLInputElement>(null);
71 
72 const canMutateProfile = sessionMode === SESSION_MODES.AUTHENTICATED && refreshState !== "refreshing";
73 const canUploadAvatar = !!avatarUploadWorkspace && canMutateProfile;
74 const hasChanges = name.trim() !== (user?.name ?? "") || (avatarUrl.trim() || null) !== (user?.avatar_url ?? null);
75 
76 const handleAvatarUpload = useCallback(
77 async (e: React.ChangeEvent<HTMLInputElement>) => {
78 const file = e.target.files?.[0];
79 if (!file || !avatarUploadWorkspace || !canMutateProfile) return;
80 
81 if (!file.type.startsWith("image/")) {
82 setError("Please select an image file");
83 return;
84 }
85 
86 if (file.size > 2 * 1024 * 1024) {
87 setError("Image must be under 2MB");
88 return;
89 }
90 
91 setUploading(true);
92 setError(null);
93 try {
94 const presign = await api.uploads.presign(avatarUploadWorkspace.id, {
95 filename: file.name,
96 content_type: file.type as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
97 size_bytes: file.size,
98 });
99 await api.uploads.uploadData(presign.upload_url, file);
100 setAvatarUrl(presign.url);
101 } catch (err) {
102 setError(toApiError(err).message);
103 } finally {
104 setUploading(false);
105 }
106 },
107 [avatarUploadWorkspace, canMutateProfile],
108 );
109 
110 const handleSave = useCallback(async () => {
111 if (saving || !name.trim() || !canMutateProfile) return;
112 setSaving(true);
113 setError(null);
114 setSuccess(false);
115 try {
116 const updated = await api.profile.update({
117 name: name.trim(),
118 avatar_url: avatarUrl.trim() || null,
119 });
120 useAuthStore.getState().setAuth(useAuthStore.getState().accessToken!, updated);
121 setSuccess(true);
122 setTimeout(() => setSuccess(false), 2000);
123 } catch (err) {
124 setError(toApiError(err).message);
125 } finally {
126 setSaving(false);
127 }
128 }, [name, avatarUrl, saving, canMutateProfile]);
129 
130 if (!user) return <ProfileSettingsSkeleton />;
131 
132 const backSlug = currentWorkspace?.slug;
133 
134 return (
135 <div className="mx-auto max-w-2xl px-8 py-10">
136 {backSlug ? (
137 <Link
138 to="/$workspaceSlug"
139 params={{ workspaceSlug: backSlug }}
140 className="mb-6 flex items-center gap-2 text-sm text-zinc-400 transition-colors hover:text-zinc-200"
141 >
142 <ArrowLeft className="h-4 w-4" />
143 Back
144 </Link>
145 ) : (
146 <Link
147 to="/"
148 className="mb-6 flex items-center gap-2 text-sm text-zinc-400 transition-colors hover:text-zinc-200"
149 >
150 <ArrowLeft className="h-4 w-4" />
151 Back
152 </Link>
153 )}
154
155 <h1 className="mb-8 text-xl font-semibold text-zinc-100">Profile</h1>
156
157 <div className="space-y-6">
158 <div className="flex items-center gap-6">
159 <div className="relative">
160 <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border-2 border-zinc-700 bg-zinc-800">
161 {avatarUrl ? (
162 <img src={avatarUrl} alt={user.name} className="h-full w-full object-cover" />
163 ) : (
164 <UserIcon className="h-8 w-8 text-zinc-500" />
165 )}
166 </div>
167 <button
168 onClick={() => fileInputRef.current?.click()}
169 disabled={uploading || !canUploadAvatar}
170 className="absolute -bottom-1 -right-1 flex h-7 w-7 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-200 disabled:opacity-50"
171 aria-label="Change avatar"
172 title={!avatarUploadWorkspace ? "Avatar upload requires a workspace you can write to." : undefined}
173 >
174 {uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Camera className="h-3.5 w-3.5" />}
175 </button>
176 <input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} className="hidden" />
177 </div>
178 <div className="flex-1">
179 <p className="text-sm text-zinc-400">Profile photo</p>
180 <p className="text-xs text-zinc-400">JPG, PNG, GIF, or WebP. Max 2MB.</p>
181 {!avatarUploadWorkspace && (
182 <p className="mt-1 text-xs text-zinc-500">Avatar upload requires a workspace you can write to.</p>
183 )}
184 {avatarUrl && (
185 <button
186 onClick={() => setAvatarUrl("")}
187 disabled={!canMutateProfile}
188 className="mt-1 flex items-center gap-1 text-xs text-zinc-500 transition-colors hover:text-zinc-300"
189 >
190 <X className="h-3 w-3" />
191 Remove
192 </button>
193 )}
194 </div>
195 </div>
196
197 <div>
198 <label htmlFor="profile-name" className="mb-1 block text-sm font-medium text-zinc-400">
199 Name
200 </label>
201 <input
202 id="profile-name"
203 type="text"
204 value={name}
205 onChange={(e) => setName(e.target.value)}
206 className="w-full rounded-lg border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-500 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
207 placeholder="Your name"
208 />
209 </div>
210
211 <div>
212 <label className="mb-1 block text-sm font-medium text-zinc-400">Email</label>
213 <p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-500">{user.email}</p>
214 </div>
215
216 {error && <p className="text-sm text-red-400">{error}</p>}
217 {refreshState === "refreshing" && (
218 <p className="text-sm text-zinc-400">Restoring your session before profile changes.</p>
219 )}
220 {refreshState !== "refreshing" && sessionMode !== SESSION_MODES.AUTHENTICATED && (
221 <p className="text-sm text-zinc-500">Sign in again to edit your profile.</p>
222 )}
223
224 <div className="flex items-center gap-3">
225 <Button
226 variant="primary"
227 size="sm"
228 onClick={handleSave}
229 disabled={saving || !name.trim() || !hasChanges || !canMutateProfile}
230 icon={saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
231 >
232 {saving ? "Saving..." : "Save"}
233 </Button>
234 {success && <span className="text-sm text-green-400">Saved</span>}
235 </div>
236 </div>
237 </div>
238 );
239}