Files
thoughts/thoughts-frontend/components/edit-profile-form.tsx

225 lines
7.7 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Me, UpdateProfileSchema, uploadAvatar, uploadBanner } from "@/lib/api";
import { updateProfile } from "@/app/actions/profile";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { UserAvatar } from "@/components/user-avatar";
import { Camera, ImagePlus, Loader2 } from "lucide-react";
interface EditProfileFormProps {
currentUser: Me;
token: string;
}
export function EditProfileForm({ currentUser, token }: EditProfileFormProps) {
const router = useRouter();
const avatarInputRef = useRef<HTMLInputElement>(null);
const bannerInputRef = useRef<HTMLInputElement>(null);
const [avatarSrc, setAvatarSrc] = useState(currentUser.avatarUrl ?? null);
const [bannerSrc, setBannerSrc] = useState(currentUser.headerUrl ?? null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [uploadingBanner, setUploadingBanner] = useState(false);
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploadingAvatar(true);
try {
const updated = await uploadAvatar(file, token);
setAvatarSrc(updated.avatarUrl ?? null);
router.refresh();
toast.success("Avatar updated");
} catch {
toast.error("Failed to upload avatar");
} finally {
setUploadingAvatar(false);
e.target.value = "";
}
}
async function handleBannerChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploadingBanner(true);
try {
const updated = await uploadBanner(file, token);
setBannerSrc(updated.headerUrl ?? null);
router.refresh();
toast.success("Banner updated");
} catch {
toast.error("Failed to upload banner");
} finally {
setUploadingBanner(false);
e.target.value = "";
}
}
const form = useForm<z.infer<typeof UpdateProfileSchema>>({
resolver: zodResolver(UpdateProfileSchema),
defaultValues: {
displayName: currentUser.displayName ?? undefined,
bio: currentUser.bio ?? undefined,
customCss: currentUser.customCss ?? undefined,
},
});
async function onSubmit(values: z.infer<typeof UpdateProfileSchema>) {
toast.info("Updating your profile...");
try {
await updateProfile(currentUser.username, values);
toast.success("Profile updated successfully!");
} catch (err) {
toast.error(`Failed to update profile. ${err}`);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent className="space-y-6 pt-6">
{/* Banner */}
<div>
<p className="text-sm font-medium mb-2">Banner</p>
<div
className="relative h-32 rounded-md bg-muted overflow-hidden cursor-pointer group"
onClick={() => !uploadingBanner && bannerInputRef.current?.click()}
>
{bannerSrc ? (
<img
src={bannerSrc}
alt="Banner"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
No banner
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{uploadingBanner ? (
<Loader2 className="text-white h-6 w-6 animate-spin" />
) : (
<ImagePlus className="text-white h-6 w-6" />
)}
</div>
</div>
<input
ref={bannerInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleBannerChange}
disabled={uploadingBanner}
/>
</div>
{/* Avatar */}
<div>
<p className="text-sm font-medium mb-2">Avatar</p>
<div className="flex items-center gap-4">
<div
className="relative w-20 h-20 rounded-full cursor-pointer group shrink-0"
onClick={() => !uploadingAvatar && avatarInputRef.current?.click()}
>
<UserAvatar
src={avatarSrc}
alt={currentUser.displayName}
className="w-full h-full"
/>
<div className="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{uploadingAvatar ? (
<Loader2 className="text-white h-4 w-4 animate-spin" />
) : (
<Camera className="text-white h-4 w-4" />
)}
</div>
</div>
<p className="text-sm text-muted-foreground">
Click to upload · JPEG, PNG, GIF, WebP, AVIF · max 5 MB
</p>
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
disabled={uploadingAvatar}
/>
</div>
<FormField
name="displayName"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input placeholder="Your display name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="bio"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea placeholder="Tell us about yourself" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="customCss"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Custom CSS</FormLabel>
<FormControl>
<Textarea
placeholder="body { font-family: 'Comic Sans MS'; }"
rows={5}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter className="border-t px-6 py-4">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</CardFooter>
</Card>
</form>
</Form>
);
}