Avatar images were rendered via /uploads/ which doesn't exist since the S3 migration. Now the server enriches profile responses with avatarImageUrl (presigned S3 URL) and the frontend uses it directly. Also fixed the public profile page at /users/:id. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
241 lines
6.5 KiB
TypeScript
241 lines
6.5 KiB
TypeScript
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 [avatarFilename, setAvatarFilename] = useState<string | null>(null);
|
|
const [avatarDisplayUrl, setAvatarDisplayUrl] = useState<string | null>(null);
|
|
const [dirty, setDirty] = 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 && !dirty) {
|
|
setDisplayName(profile.displayName ?? "");
|
|
setBio(profile.bio ?? "");
|
|
setAvatarFilename(profile.avatarUrl ?? null);
|
|
setAvatarDisplayUrl(profile.avatarImageUrl ?? null);
|
|
}
|
|
}, [profile, dirty]);
|
|
|
|
async function handleSave(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setMessage(null);
|
|
try {
|
|
await updateProfile.mutateAsync({
|
|
displayName: displayName.trim() || undefined,
|
|
avatarUrl: avatarFilename,
|
|
bio: bio.trim() || undefined,
|
|
});
|
|
setDirty(false);
|
|
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;
|
|
}
|
|
|
|
const localPreview = URL.createObjectURL(file);
|
|
setAvatarDisplayUrl(localPreview);
|
|
setUploading(true);
|
|
setMessage(null);
|
|
try {
|
|
const result = await apiUpload<{ filename: string }>("/api/images", file);
|
|
setAvatarFilename(result.filename);
|
|
setDirty(true);
|
|
} catch {
|
|
setAvatarDisplayUrl(null);
|
|
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"
|
|
>
|
|
{avatarDisplayUrl ? (
|
|
<img
|
|
src={avatarDisplayUrl}
|
|
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>
|
|
{avatarFilename && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setAvatarFilename(null);
|
|
setAvatarDisplayUrl(null);
|
|
setDirty(true);
|
|
}}
|
|
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);
|
|
setDirty(true);
|
|
}}
|
|
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);
|
|
setDirty(true);
|
|
}}
|
|
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>
|
|
);
|
|
}
|