From 500da1f8bee26a953eebea34f35397461f833470 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Thu, 4 Sep 2025 09:43:16 -0500 Subject: [PATCH] You can now update your name or email from profiles page --- bun.lock | 2 +- convex/auth.ts | 33 +++++ convex/files.ts | 8 +- package.json | 2 +- src/app/(auth)/profile/page.tsx | 9 +- .../layout/header/controls/AvatarDropdown.tsx | 10 +- .../layout/profile/avatar-upload.tsx | 35 +++-- src/components/layout/profile/index.tsx | 1 + src/components/layout/profile/user-info.tsx | 125 ++++++++++++++++++ 9 files changed, 196 insertions(+), 29 deletions(-) create mode 100644 src/components/layout/profile/user-info.tsx diff --git a/bun.lock b/bun.lock index a657547..b0ec268 100644 --- a/bun.lock +++ b/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=="], diff --git a/convex/auth.ts b/convex/auth.ts index 62abd30..e60f97c 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -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 }; }, }); diff --git a/convex/files.ts b/convex/files.ts index f5a41fc..2921ce9 100644 --- a/convex/files.ts +++ b/convex/files.ts @@ -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; }, }); diff --git a/package.json b/package.json index 96a4f36..53e38ff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(auth)/profile/page.tsx b/src/app/(auth)/profile/page.tsx index 4173039..89f98fa 100644 --- a/src/app/(auth)/profile/page.tsx +++ b/src/app/(auth)/profile/page.tsx @@ -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 ( - + + + + ); }; diff --git a/src/components/layout/header/controls/AvatarDropdown.tsx b/src/components/layout/header/controls/AvatarDropdown.tsx index 593469a..7e32f58 100644 --- a/src/components/layout/header/controls/AvatarDropdown.tsx +++ b/src/components/layout/header/controls/AvatarDropdown.tsx @@ -26,15 +26,21 @@ export const AvatarDropdown = () => { user?.image ? { storageId: user.image } : 'skip', ); - if (isLoading) return ; + if (isLoading) + return ( + + ); if (!isAuthenticated) return
; + return ( diff --git a/src/components/layout/profile/avatar-upload.tsx b/src/components/layout/profile/avatar-upload.tsx index fd569e1..00b19c9 100644 --- a/src/components/layout/profile/avatar-upload.tsx +++ b/src/components/layout/profile/avatar-upload.tsx @@ -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; }; -type UploadResponse = { - storageId: Id<'_storage'>; -}; - const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => { const user = usePreloadedQuery(preloadedUser); const [isUploading, setIsUploading] = useState(false); + const inputRef = useRef(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()} > {
; +}; + +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>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: user?.name ?? '', + email: user?.email ?? '', + } + }); + + const handleSubmit = async (values: z.infer) => { + const ops: Promise[] = []; + 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 ( + +
+ + ( + + Full Name + + + + Your public display name. + + + )} + /> + + ( + + Email + + + + + Your email address associated with your account. + + + + )} + /> + +
+ + Save Changes + +
+ + +
+ ); +};