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)
This commit is contained in:
224
src/client/components/ProfileSection.tsx
Normal file
224
src/client/components/ProfileSection.tsx
Normal file
@@ -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<string | null>(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<HTMLInputElement>(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<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Profile</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Your public profile information
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="relative w-16 h-16 rounded-full overflow-hidden cursor-pointer group shrink-0"
|
||||||
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={`/uploads/${avatarUrl}`}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-gray-300 group-hover:text-gray-400 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uploading && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-500 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading..." : "Change avatar"}
|
||||||
|
</button>
|
||||||
|
{avatarUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAvatarUrl(null)}
|
||||||
|
className="block text-xs text-red-500 hover:text-red-700 mt-0.5"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Name */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="displayName"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="displayName"
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="bio"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Bio
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="bio"
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Tell others about yourself and your gear interests"
|
||||||
|
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 resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1 text-right">
|
||||||
|
{bio.length}/500
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p
|
||||||
|
className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateProfile.isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{updateProfile.isPending ? "Saving..." : "Save Profile"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/client/hooks/useProfile.ts
Normal file
43
src/client/hooks/useProfile.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiGet, apiPut } from "../lib/api";
|
||||||
|
|
||||||
|
interface PublicProfile {
|
||||||
|
id: number;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
setups: { id: number; name: string; createdAt: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateProfileData {
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
bio?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileResponse {
|
||||||
|
id: number;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublicProfile(userId: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["profiles", userId],
|
||||||
|
queryFn: () => apiGet<PublicProfile>(`/api/users/${userId}/profile`),
|
||||||
|
enabled: userId != null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateProfile() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: UpdateProfileData) =>
|
||||||
|
apiPut<ProfileResponse>("/api/auth/profile", data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { ProfileSection } from "../components/ProfileSection";
|
||||||
import {
|
import {
|
||||||
useApiKeys,
|
useApiKeys,
|
||||||
useAuth,
|
useAuth,
|
||||||
@@ -280,7 +281,13 @@ function SettingsPage() {
|
|||||||
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
|
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
|
{auth?.user && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
|
||||||
|
<ProfileSection />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>
|
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user