You can now update your name or email from profiles page
This commit is contained in:
2
bun.lock
2
bun.lock
@@ -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=="],
|
||||
|
||||
|
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
@@ -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;
|
||||
},
|
||||
});
|
||||
|
@@ -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",
|
||||
|
@@ -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