Organize/components/profile/profile-form.tsx

212 lines
6.9 KiB
TypeScript

"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<string | null>(initial.avatar);
const [savingNames, setSavingNames] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<Card className="max-w-xl">
<CardContent className="space-y-5">
{/* Photo */}
<div className="flex items-center gap-4">
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatar, no image-optimization pipeline in this self-hosted app
<img
src={avatar}
alt="Profile"
className="size-16 shrink-0 rounded-full object-cover"
/>
) : (
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xl font-bold text-primary">
{initials}
</span>
)}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_AVATAR_TYPES.join(",")}
className="hidden"
onChange={handleFileChosen}
disabled={uploading}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Camera className="size-3.5" />
)}
{uploading ? "Updating…" : avatar ? "Change photo" : "Add photo"}
</Button>
{avatar && (
<Button
variant="ghost"
size="sm"
onClick={handleRemoveAvatar}
disabled={uploading}
>
<Trash2 className="size-3.5" />
Remove
</Button>
)}
</div>
</div>
{/* Name */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-first-name">First name</Label>
<Input
id="profile-first-name"
value={firstName}
maxLength={60}
autoComplete="given-name"
placeholder="Alex"
onChange={(e) => setFirstName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-last-name">Last name</Label>
<Input
id="profile-last-name"
value={lastName}
maxLength={60}
autoComplete="family-name"
placeholder="Fertig"
onChange={(e) => setLastName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
/>
</div>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleSaveNames} disabled={!namesDirty || savingNames}>
{savingNames ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Saving
</>
) : (
"Save name"
)}
</Button>
</CardFooter>
</Card>
);
}