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

@@ -755,7 +755,7 @@
"@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="],
"@types/node": ["@types/node@20.19.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-lSOjyS6vdO2G2g2CWrETTV3Jz2zlCXHpu1rcubLKpz9oj+z/1CceHlj+yq53W+9zgb98nSov/wjEKYDNauD+Hw=="],
"@types/node": ["@types/node@20.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g=="],
"@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="],

View File

@@ -25,6 +25,34 @@ export const getUser = query(async (ctx) => {
};
});
export const updateUserName = mutation({
args: {
name: v.string(),
},
handler: async (ctx, { name }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
await ctx.db.patch(userId, { name });
return { success: true };
},
});
export const updateUserEmail = mutation({
args: {
email: v.string(),
},
handler: async (ctx, { email }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
await ctx.db.patch(userId, { email });
return { success: true };
}
});
export const updateUserImage = mutation({
args: {
storageId: v.id('_storage'),
@@ -32,7 +60,12 @@ export const updateUserImage = mutation({
handler: async (ctx, { storageId }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
const oldImage = user.image as Id<'_storage'> | undefined;
await ctx.db.patch(userId, { image: storageId });
if (oldImage && oldImage !== storageId)
await ctx.storage.delete(oldImage);
return { success: true };
},
});

View File

@@ -1,13 +1,17 @@
import { mutation, query } from './_generated/server';
import { v } from 'convex/values';
import { ConvexError, v } from 'convex/values';
import { getAuthUserId } from '@convex-dev/auth/server';
export const generateUploadUrl = mutation(async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return await ctx.storage.generateUploadUrl();
});
export const getImageUrl = query({
args: { storageId: v.id('_storage') },
handler: async (ctx, { storageId }) => {
return await ctx.storage.getUrl(storageId);
const url = await ctx.storage.getUrl(storageId);
return url ?? null;
},
});

View File

@@ -48,7 +48,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.12",
"@types/node": "^20.19.12",
"@types/node": "^20.19.13",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"dotenv": "^16.6.1",

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>
);
};