You can now update your name or email from profiles page

This commit is contained in:
2025-09-04 09:43:16 -05:00
parent be23ea1070
commit 500da1f8be
9 changed files with 196 additions and 29 deletions

View File

@@ -1,15 +1,18 @@
'use server';
import { preloadQuery } from 'convex/nextjs';
import { api } from '~/convex/_generated/api';
import { AvatarUpload, ProfileHeader } from '@/components/layout/profile';
import { Card } from '@/components/ui';
import { AvatarUpload, ProfileHeader, UserInfoForm } from '@/components/layout/profile';
import { Card, Separator } from '@/components/ui';
const Profile = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser);
return (
<Card className='max-w-2xl min-w-xs sm:min-w-md mx-auto mb-8'>
<Card className='max-w-xl min-w-xs sm:min-w-md mx-auto mb-8'>
<ProfileHeader preloadedUser={preloadedUser} />
<AvatarUpload preloadedUser={preloadedUser} />
<Separator />
<UserInfoForm preloadedUser={preloadedUser} />
<Separator />
</Card>
);
};

View File

@@ -26,15 +26,21 @@ export const AvatarDropdown = () => {
user?.image ? { storageId: user.image } : 'skip',
);
if (isLoading) return <BasedAvatar className='animate-pulse' />;
if (isLoading)
return (
<BasedAvatar
className='animate-pulse lg:h-10 lg:w-10'
/>
);
if (!isAuthenticated) return <div />;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<BasedAvatar
src={currentImageUrl}
fullName={user?.name}
className='lg:h-10 lg:w-10 my-auto'
className='lg:h-10 lg:w-10'
fallbackProps={{ className: 'text-xl font-semibold' }}
userIconProps={{ size: 32 }}
/>

View File

@@ -1,5 +1,5 @@
'use client';
import { useState } from 'react';
import { useRef, useState } from 'react';
import {
type Preloaded,
usePreloadedQuery,
@@ -8,6 +8,7 @@ import {
} from 'convex/react';
import { api } from '~/convex/_generated/api';
import { BasedAvatar, CardContent } from '@/components/ui';
import { toast } from 'sonner';
import { Loader2, Pencil, Upload } from 'lucide-react';
import { type Id } from '~/convex/_generated/dataModel';
@@ -15,18 +16,13 @@ type AvatarUploadProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
type UploadResponse = {
storageId: Id<'_storage'>;
};
const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const user = usePreloadedQuery(preloadedUser);
const [isUploading, setIsUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUserImage = useMutation(api.auth.updateUserImage);
// Get the current image URL from the storage ID
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image } : 'skip',
@@ -37,34 +33,32 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
if (!file?.type.startsWith('image/')) {
toast.error('Please select an image file.');
return;
}
setIsUploading(true);
try {
// Step 1: Get upload URL
const postUrl = await generateUploadUrl();
// Step 2: Upload file
const result = await fetch(postUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file,
});
const uploadResponse = await result.json() as UploadResponse;
// Step 3: Update user's image field with storage ID
if (!result.ok) {
const msg = await result.text().catch(() => 'Upload failed.')
throw new Error(msg);
}
const uploadResponse = await result.json() as { storageId: Id<'_storage'> };
await updateUserImage({ storageId: uploadResponse.storageId });
toast('Profile picture updated.');
} catch (error) {
console.error('Upload failed:', error);
alert('Upload failed. Please try again.');
toast('Upload failed. Please try again.');
} finally {
setIsUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
};
@@ -76,7 +70,7 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
onClick={() => document.getElementById('avatar-upload')?.click()}
>
<BasedAvatar
src={currentImageUrl} // This will be the generated URL
src={currentImageUrl ?? undefined}
fullName={user?.name}
className='h-32 w-32'
fallbackProps={{ className: 'text-4xl font-semibold' }}
@@ -102,6 +96,7 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
</div>
<input
ref={inputRef}
id='avatar-upload'
type='file'
accept='image/*'

View File

@@ -1,2 +1,3 @@
export { AvatarUpload } from './avatar-upload';
export { ProfileHeader } from './header';
export { UserInfoForm } from './user-info';

View File

@@ -0,0 +1,125 @@
'use client';
import { useState } from 'react';
import {
type Preloaded,
usePreloadedQuery,
useMutation,
} from 'convex/react';
import { api } from '~/convex/_generated/api';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
CardContent,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
SubmitButton,
} from '@/components/ui';
import { toast } from 'sonner';
const formSchema = z.object({
name: z.string()
.trim()
.min(5, {
message: 'Full name is required & must be at least 5 characters.'
})
.max(50, {
message: 'Full name must be less than 50 characters.'
}),
email: z.email({
message: 'Please enter a valid email address.'
}),
});
type UserInfoFormProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const [loading, setLoading] = useState(false);
const updateUserName = useMutation(api.auth.updateUserName);
const updateUserEmail = useMutation(api.auth.updateUserEmail);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
}
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const ops: Promise<unknown>[] = [];
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
if (name !== (user?.name ?? ''))
ops.push(updateUserName({name}));
if (email !== (user?.email ?? ''))
ops.push(updateUserEmail({email}));
if (ops.length === 0) return;
setLoading(true);
try {
await Promise.all(ops);
form.reset({ name, email});
} catch (error) {
console.error(error);
toast.error('Error updating profile.')
} finally {
setLoading(false);
}
};
return (
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your email address associated with your account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-center'>
<SubmitButton disabled={loading} pendingText='Saving...'>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
);
};