From f120d179f733b891ca10185a6eefec1109017bee Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:17:31 +0200 Subject: [PATCH 1/3] feat(18-05): add profile hooks and profile edit UI in settings - Create usePublicProfile and useUpdateProfile hooks - Create ProfileSection component with avatar upload, display name, bio - Add Profile section to settings page (visible when authenticated) --- src/client/components/ProfileSection.tsx | 224 +++++++++++++++++++++++ src/client/hooks/useProfile.ts | 43 +++++ src/client/routes/settings.tsx | 9 +- 3 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 src/client/components/ProfileSection.tsx create mode 100644 src/client/hooks/useProfile.ts diff --git a/src/client/components/ProfileSection.tsx b/src/client/components/ProfileSection.tsx new file mode 100644 index 0000000..b466cbe --- /dev/null +++ b/src/client/components/ProfileSection.tsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from "react"; +import { useAuth } from "../hooks/useAuth"; +import { usePublicProfile, useUpdateProfile } from "../hooks/useProfile"; +import { apiUpload } from "../lib/api"; + +export function ProfileSection() { + const { data: auth } = useAuth(); + const userId = auth?.user?.id ?? null; + const { data: profile } = usePublicProfile(userId); + const updateProfile = useUpdateProfile(); + + const [displayName, setDisplayName] = useState(""); + const [bio, setBio] = useState(""); + const [avatarUrl, setAvatarUrl] = useState(null); + const [initialized, setInitialized] = useState(false); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + if (profile && !initialized) { + setDisplayName(profile.displayName ?? ""); + setBio(profile.bio ?? ""); + setAvatarUrl(profile.avatarUrl ?? null); + setInitialized(true); + } + }, [profile, initialized]); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + try { + await updateProfile.mutateAsync({ + displayName: displayName.trim() || undefined, + avatarUrl, + bio: bio.trim() || undefined, + }); + setMessage({ type: "success", text: "Profile updated" }); + } catch (err) { + setMessage({ type: "error", text: (err as Error).message }); + } + } + + async function handleAvatarUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + const maxSize = 5 * 1024 * 1024; + const accepted = ["image/jpeg", "image/png", "image/webp"]; + + if (!accepted.includes(file.type)) { + setMessage({ + type: "error", + text: "Please select a JPG, PNG, or WebP image.", + }); + return; + } + if (file.size > maxSize) { + setMessage({ type: "error", text: "Image must be under 5MB." }); + return; + } + + setUploading(true); + setMessage(null); + try { + const result = await apiUpload<{ filename: string }>("/api/images", file); + setAvatarUrl(result.filename); + } catch { + setMessage({ type: "error", text: "Avatar upload failed." }); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + } + + return ( +
+
+

Profile

+

+ Your public profile information +

+
+ + {/* Avatar */} +
+
fileInputRef.current?.click()} + className="relative w-16 h-16 rounded-full overflow-hidden cursor-pointer group shrink-0" + > + {avatarUrl ? ( + Avatar + ) : ( +
+ + + + +
+ )} + {uploading && ( +
+ + + + +
+ )} +
+
+ + {avatarUrl && ( + + )} +
+ +
+ + {/* Display Name */} +
+ + setDisplayName(e.target.value)} + maxLength={100} + placeholder="Your display name" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ + {/* Bio */} +
+ +