You can now update your name or email from profiles page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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 }}
|
||||
/>
|
||||
|
@@ -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/*'
|
||||
|
@@ -1,2 +1,3 @@
|
||||
export { AvatarUpload } from './avatar-upload';
|
||||
export { ProfileHeader } from './header';
|
||||
export { UserInfoForm } from './user-info';
|
||||
|
125
src/components/layout/profile/user-info.tsx
Normal file
125
src/components/layout/profile/user-info.tsx
Normal 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>
|
||||
);
|
||||
};
|
Reference in New Issue
Block a user