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/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=="],
|
"@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({
|
export const updateUserImage = mutation({
|
||||||
args: {
|
args: {
|
||||||
storageId: v.id('_storage'),
|
storageId: v.id('_storage'),
|
||||||
@@ -32,7 +60,12 @@ export const updateUserImage = mutation({
|
|||||||
handler: async (ctx, { storageId }) => {
|
handler: async (ctx, { storageId }) => {
|
||||||
const userId = await getAuthUserId(ctx);
|
const userId = await getAuthUserId(ctx);
|
||||||
if (!userId) throw new ConvexError('Not authenticated.');
|
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 });
|
await ctx.db.patch(userId, { image: storageId });
|
||||||
|
if (oldImage && oldImage !== storageId)
|
||||||
|
await ctx.storage.delete(oldImage);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@@ -1,13 +1,17 @@
|
|||||||
import { mutation, query } from './_generated/server';
|
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) => {
|
export const generateUploadUrl = mutation(async (ctx) => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) throw new ConvexError('Not authenticated.');
|
||||||
return await ctx.storage.generateUploadUrl();
|
return await ctx.storage.generateUploadUrl();
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getImageUrl = query({
|
export const getImageUrl = query({
|
||||||
args: { storageId: v.id('_storage') },
|
args: { storageId: v.id('_storage') },
|
||||||
handler: async (ctx, { storageId }) => {
|
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": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.1.12",
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
"@types/node": "^20.19.12",
|
"@types/node": "^20.19.13",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
|
@@ -1,15 +1,18 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import { preloadQuery } from 'convex/nextjs';
|
import { preloadQuery } from 'convex/nextjs';
|
||||||
import { api } from '~/convex/_generated/api';
|
import { api } from '~/convex/_generated/api';
|
||||||
import { AvatarUpload, ProfileHeader } from '@/components/layout/profile';
|
import { AvatarUpload, ProfileHeader, UserInfoForm } from '@/components/layout/profile';
|
||||||
import { Card } from '@/components/ui';
|
import { Card, Separator } from '@/components/ui';
|
||||||
|
|
||||||
const Profile = async () => {
|
const Profile = async () => {
|
||||||
const preloadedUser = await preloadQuery(api.auth.getUser);
|
const preloadedUser = await preloadQuery(api.auth.getUser);
|
||||||
return (
|
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} />
|
<ProfileHeader preloadedUser={preloadedUser} />
|
||||||
<AvatarUpload preloadedUser={preloadedUser} />
|
<AvatarUpload preloadedUser={preloadedUser} />
|
||||||
|
<Separator />
|
||||||
|
<UserInfoForm preloadedUser={preloadedUser} />
|
||||||
|
<Separator />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -26,15 +26,21 @@ export const AvatarDropdown = () => {
|
|||||||
user?.image ? { storageId: user.image } : 'skip',
|
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 />;
|
if (!isAuthenticated) return <div />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<BasedAvatar
|
<BasedAvatar
|
||||||
src={currentImageUrl}
|
src={currentImageUrl}
|
||||||
fullName={user?.name}
|
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' }}
|
fallbackProps={{ className: 'text-xl font-semibold' }}
|
||||||
userIconProps={{ size: 32 }}
|
userIconProps={{ size: 32 }}
|
||||||
/>
|
/>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
type Preloaded,
|
type Preloaded,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from 'convex/react';
|
} from 'convex/react';
|
||||||
import { api } from '~/convex/_generated/api';
|
import { api } from '~/convex/_generated/api';
|
||||||
import { BasedAvatar, CardContent } from '@/components/ui';
|
import { BasedAvatar, CardContent } from '@/components/ui';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Loader2, Pencil, Upload } from 'lucide-react';
|
import { Loader2, Pencil, Upload } from 'lucide-react';
|
||||||
import { type Id } from '~/convex/_generated/dataModel';
|
import { type Id } from '~/convex/_generated/dataModel';
|
||||||
|
|
||||||
@@ -15,18 +16,13 @@ type AvatarUploadProps = {
|
|||||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UploadResponse = {
|
|
||||||
storageId: Id<'_storage'>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||||
const user = usePreloadedQuery(preloadedUser);
|
const user = usePreloadedQuery(preloadedUser);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||||
const updateUserImage = useMutation(api.auth.updateUserImage);
|
const updateUserImage = useMutation(api.auth.updateUserImage);
|
||||||
|
|
||||||
// Get the current image URL from the storage ID
|
|
||||||
const currentImageUrl = useQuery(
|
const currentImageUrl = useQuery(
|
||||||
api.files.getImageUrl,
|
api.files.getImageUrl,
|
||||||
user?.image ? { storageId: user.image } : 'skip',
|
user?.image ? { storageId: user.image } : 'skip',
|
||||||
@@ -37,34 +33,32 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
) => {
|
) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (!file?.type.startsWith('image/')) {
|
||||||
if (!file.type.startsWith('image/')) {
|
toast.error('Please select an image file.');
|
||||||
alert('Please select an image file');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get upload URL
|
|
||||||
const postUrl = await generateUploadUrl();
|
const postUrl = await generateUploadUrl();
|
||||||
|
|
||||||
// Step 2: Upload file
|
|
||||||
const result = await fetch(postUrl, {
|
const result = await fetch(postUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': file.type },
|
headers: { 'Content-Type': file.type },
|
||||||
body: file,
|
body: file,
|
||||||
});
|
});
|
||||||
|
if (!result.ok) {
|
||||||
const uploadResponse = await result.json() as UploadResponse;
|
const msg = await result.text().catch(() => 'Upload failed.')
|
||||||
|
throw new Error(msg);
|
||||||
// Step 3: Update user's image field with storage ID
|
}
|
||||||
|
const uploadResponse = await result.json() as { storageId: Id<'_storage'> };
|
||||||
await updateUserImage({ storageId: uploadResponse.storageId });
|
await updateUserImage({ storageId: uploadResponse.storageId });
|
||||||
|
toast('Profile picture updated.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload failed:', error);
|
console.error('Upload failed:', error);
|
||||||
alert('Upload failed. Please try again.');
|
toast('Upload failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,7 +70,7 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
onClick={() => document.getElementById('avatar-upload')?.click()}
|
onClick={() => document.getElementById('avatar-upload')?.click()}
|
||||||
>
|
>
|
||||||
<BasedAvatar
|
<BasedAvatar
|
||||||
src={currentImageUrl} // This will be the generated URL
|
src={currentImageUrl ?? undefined}
|
||||||
fullName={user?.name}
|
fullName={user?.name}
|
||||||
className='h-32 w-32'
|
className='h-32 w-32'
|
||||||
fallbackProps={{ className: 'text-4xl font-semibold' }}
|
fallbackProps={{ className: 'text-4xl font-semibold' }}
|
||||||
@@ -102,6 +96,7 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
id='avatar-upload'
|
id='avatar-upload'
|
||||||
type='file'
|
type='file'
|
||||||
accept='image/*'
|
accept='image/*'
|
||||||
|
@@ -1,2 +1,3 @@
|
|||||||
export { AvatarUpload } from './avatar-upload';
|
export { AvatarUpload } from './avatar-upload';
|
||||||
export { ProfileHeader } from './header';
|
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