"use client"; import { useRef, useState } from "react"; import { toast } from "sonner"; import { Camera, Loader2, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { removeAvatar as removeAvatarAction, updateProfileNames, uploadAvatar as uploadAvatarAction, } from "@/lib/actions/profile"; import type { ProfileDTO } from "@/types/profile"; // Kept in sync with lib/actions/profile.ts (the server re-checks both). const MAX_AVATAR_BYTES = 10 * 1024 * 1024; const ACCEPTED_AVATAR_TYPES = [ "image/jpeg", "image/png", "image/webp", "image/gif", ]; export function ProfileForm({ initial }: { initial: ProfileDTO }) { const [firstName, setFirstName] = useState(initial.firstName ?? ""); const [lastName, setLastName] = useState(initial.lastName ?? ""); // What's currently saved in the DB -- the "Save" button is only enabled // while the inputs differ from this. const [savedNames, setSavedNames] = useState({ firstName: initial.firstName ?? "", lastName: initial.lastName ?? "", }); const [avatar, setAvatar] = useState(initial.avatar); const [savingNames, setSavingNames] = useState(false); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const namesDirty = firstName.trim() !== savedNames.firstName || lastName.trim() !== savedNames.lastName; async function handleSaveNames() { setSavingNames(true); try { await updateProfileNames(firstName, lastName); setSavedNames({ firstName, lastName }); toast.success("Name saved."); } catch { toast.error("Couldn't save your name. Try again."); } finally { setSavingNames(false); } } async function handleFileChosen(event: React.ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; // allow re-picking the same file if (!file) return; // Same rules as the server action -- check early so a bad file never // leaves the browser. if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) { toast.error("Please choose a JPEG, PNG, WebP, or GIF image."); return; } if (file.size > MAX_AVATAR_BYTES) { toast.error("Image must be 10 MB or smaller."); return; } setUploading(true); try { const result = await uploadAvatarAction(file); if (result.error) { toast.error(result.error); return; } setAvatar(result.avatar ?? null); toast.success("Profile photo updated."); } catch (error) { // A thrown (rather than returned) error is a framework-level // rejection -- typically Next.js' server-action body limit 413ing // the file before it reaches the action. Say so instead of the // generic "try another image", which is misleading here. if (error instanceof Error && /body exceeded|413/i.test(error.message)) { toast.error( "Image is too large for the server to accept. Try one under 10 MB." ); } else { toast.error("Couldn't upload that photo. Try again."); } } finally { setUploading(false); } } async function handleRemoveAvatar() { setUploading(true); try { await removeAvatarAction(); setAvatar(null); toast.success("Profile photo removed."); } catch { toast.error("Couldn't remove the photo. Try again."); } finally { setUploading(false); } } const initials = (firstName.trim()[0] ?? lastName.trim()[0] ?? "?").toUpperCase(); return ( {/* Photo */}
{avatar ? ( // eslint-disable-next-line @next/next/no-img-element -- data-URL avatar, no image-optimization pipeline in this self-hosted app Profile ) : ( {initials} )}
{avatar && ( )}
{/* Name */}
setFirstName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()} />
setLastName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()} />
); }