212 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|