feat: add image upload for avatar and banner

This commit is contained in:
2026-05-24 02:06:47 +02:00
parent 1874954ad7
commit 01932cf337
40 changed files with 1396 additions and 112 deletions

View File

@@ -31,7 +31,7 @@ export default async function EditProfilePage() {
This is how others will see you on the site.
</p>
</div>
<EditProfileForm currentUser={me} />
<EditProfileForm currentUser={me} token={token} />
</div>
);
}

View File

@@ -1,9 +1,11 @@
"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 } from "@/lib/api";
import { Me, UpdateProfileSchema, uploadAvatar, uploadBanner } from "@/lib/api";
import { updateProfile } from "@/app/actions/profile";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -18,20 +20,63 @@ import {
} 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 }: EditProfileFormProps) {
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,
avatarUrl: currentUser.avatarUrl ?? undefined,
headerUrl: currentUser.headerUrl ?? undefined,
customCss: currentUser.customCss ?? undefined,
},
});
@@ -51,6 +96,78 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
<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}
@@ -77,38 +194,6 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
</FormItem>
)}
/>
<FormField
name="avatarUrl"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/avatar.png"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="headerUrl"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Header URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/header.jpg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="customCss"
control={form.control}

View File

@@ -74,8 +74,6 @@ export const CreateThoughtSchema = z.object({
export const UpdateProfileSchema = z.object({
displayName: z.string().max(50).optional(),
bio: z.string().max(4000).optional(),
avatarUrl: z.string().optional(),
headerUrl: z.string().optional(),
customCss: z.string().optional(),
});
@@ -214,6 +212,25 @@ export const getMe = (token: string) =>
export const updateProfile = (data: z.infer<typeof UpdateProfileSchema>, token: string) =>
apiFetch("/users/me", { method: "PATCH", body: JSON.stringify(data) }, UserSchema, token);
async function uploadImage(endpoint: string, file: File, token: string): Promise<User> {
const base = process.env.NEXT_PUBLIC_API_URL;
const body = new FormData();
body.append("file", file);
const res = await fetch(`${base}${endpoint}`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}` },
body,
});
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return UserSchema.parse(await res.json());
}
export const uploadAvatar = (file: File, token: string) =>
uploadImage("/users/me/avatar", file, token);
export const uploadBanner = (file: File, token: string) =>
uploadImage("/users/me/banner", file, token);
export const getMeFollowingList = (token: string) =>
apiFetch("/users/me/following", { next: { tags: ['me'] } }, z.object({ total: z.number(), items: z.array(UserSchema) }), token);