File
Blob: src/client/components/profile-settings.tsx
| 1 | import { useState, useCallback, useMemo, useRef } from "react"; |
| 2 | import { Link } from "@tanstack/react-router"; |
| 3 | import { ArrowLeft, Save, Loader2, Camera, X, User as UserIcon } from "lucide-react"; |
| 4 | import { Button } from "@/client/components/ui/button"; |
| 5 | import { useAuthStore } from "@/client/stores/auth-store"; |
| 6 | import { useMemberWorkspaces } from "@/client/stores/workspace-directory"; |
| 7 | import { useWorkspaceReplica } from "@/client/stores/workspace-replica"; |
| 8 | import { useLastVisitedWorkspaceId } from "@/client/stores/workspace-navigation"; |
| 9 | import { api, toApiError } from "@/client/lib/api"; |
| 10 | import { SESSION_MODES } from "@/client/lib/constants"; |
| 11 | import { useDocumentTitle } from "@/client/hooks/use-document-title"; |
| 12 | import { Skeleton } from "@/client/components/ui/skeleton"; |
| 13 | import { isWorkspaceWriterRole } from "@/shared/entitlements"; |
| 14 | |
| 15 | function 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 | |
| 42 | export 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 | } |