Files
GearBox/src/client/components/ProfileSection.tsx
Jean-Luc Makiola b647e23f91
All checks were successful
CI / ci (push) Successful in 1m11s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
fix: use presigned S3 URLs for avatar images instead of /uploads/ paths
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>
2026-04-12 23:02:45 +02:00

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>
);
}