Got the profile picture thing working!

This commit is contained in:
2025-09-03 12:51:07 -05:00
parent 6febfa05d4
commit be23ea1070
6 changed files with 102 additions and 43 deletions

View File

@@ -1,6 +1,7 @@
import { ConvexError, v } from 'convex/values'; import { ConvexError, v } from 'convex/values';
import { convexAuth, getAuthUserId } from '@convex-dev/auth/server'; import { convexAuth, getAuthUserId } from '@convex-dev/auth/server';
import { mutation, query } from './_generated/server'; import { mutation, query } from './_generated/server';
import { type Id } from './_generated/dataModel';
import Password from './CustomPassword'; import Password from './CustomPassword';
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
@@ -12,17 +13,21 @@ export const getUser = query(async (ctx) => {
if (!userId) return null; if (!userId) return null;
const user = await ctx.db.get(userId); const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
const image: Id<'_storage'> | null =
typeof user.image === 'string' && user.image.length > 0
? user.image as Id<'_storage'>
: null
return { return {
id: user._id, id: user._id,
email: user.email ?? null, email: user.email ?? null,
name: user.name ?? null, name: user.name ?? null,
image: user.image ?? null, image,
}; };
}); });
export const updateUserImage = mutation({ export const updateUserImage = mutation({
args: { args: {
storageId: v.id('_storage') storageId: v.id('_storage'),
}, },
handler: async (ctx, { storageId }) => { handler: async (ctx, { storageId }) => {
const userId = await getAuthUserId(ctx); const userId = await getAuthUserId(ctx);

View File

@@ -1,12 +1,12 @@
import { mutation, query } from "./_generated/server"; import { mutation, query } from './_generated/server';
import { v } from "convex/values"; import { v } from 'convex/values';
export const generateUploadUrl = mutation(async (ctx) => { export const generateUploadUrl = mutation(async (ctx) => {
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); return await ctx.storage.getUrl(storageId);
}, },

View File

@@ -1,8 +1,8 @@
'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 } from '@/components/layout/profile';
import { Card } from "@/components/ui"; import { Card } from '@/components/ui';
const Profile = async () => { const Profile = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser); const preloadedUser = await preloadQuery(api.auth.getUser);

View File

@@ -1,5 +1,4 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { import {
@@ -20,8 +19,12 @@ export const AvatarDropdown = () => {
const router = useRouter(); const router = useRouter();
const { isLoading, isAuthenticated } = useConvexAuth(); const { isLoading, isAuthenticated } = useConvexAuth();
const { signOut } = useAuthActions(); const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser);
const { tvMode, toggleTVMode } = useTVMode(); const { tvMode, toggleTVMode } = useTVMode();
const user = useQuery(api.auth.getUser);
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image } : 'skip',
);
if (isLoading) return <BasedAvatar className='animate-pulse' />; if (isLoading) return <BasedAvatar className='animate-pulse' />;
if (!isAuthenticated) return <div />; if (!isAuthenticated) return <div />;
@@ -29,7 +32,7 @@ export const AvatarDropdown = () => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<BasedAvatar <BasedAvatar
src={user?.image} src={currentImageUrl}
fullName={user?.name} fullName={user?.name}
className='lg:h-10 lg:w-10 my-auto' className='lg:h-10 lg:w-10 my-auto'
fallbackProps={{ className: 'text-xl font-semibold' }} fallbackProps={{ className: 'text-xl font-semibold' }}

View File

@@ -1,27 +1,82 @@
'use client'; 'use client';
import { type Preloaded, usePreloadedQuery } from 'convex/react'; import { useState } from 'react';
import { type api } from '~/convex/_generated/api';
import { import {
BasedAvatar, type Preloaded,
CardContent, usePreloadedQuery,
} from '@/components/ui'; useMutation,
useQuery,
} from 'convex/react';
import { api } from '~/convex/_generated/api';
import { BasedAvatar, CardContent } from '@/components/ui';
import { Loader2, Pencil, Upload } from 'lucide-react'; import { Loader2, Pencil, Upload } from 'lucide-react';
import { type Id } from '~/convex/_generated/dataModel';
type AvatarUploadProps = { 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 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',
);
const handleFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('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
await updateUserImage({ storageId: uploadResponse.storageId });
} catch (error) {
console.error('Upload failed:', error);
alert('Upload failed. Please try again.');
} finally {
setIsUploading(false);
}
};
return ( return (
<CardContent> <CardContent>
<div className='flex flex-col items-center'> <div className='flex flex-col items-center'>
<div <div
className='relative group cursor-pointer mb-4' className='relative group cursor-pointer mb-4'
onClick={() => {}} onClick={() => document.getElementById('avatar-upload')?.click()}
> >
<BasedAvatar <BasedAvatar
src={user?.image} src={currentImageUrl} // This will be the generated URL
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' }}
@@ -45,22 +100,22 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
/> />
</div> </div>
</div> </div>
{
//<input <input
//ref={fileInputRef} id='avatar-upload'
//type='file' type='file'
//accept='image/*' accept='image/*'
//className='hidden' className='hidden'
//onChange={handleFileChange} onChange={handleFileUpload}
//disabled={isUploading} disabled={isUploading}
///> />
//{isUploading && (
//<div className='flex items-center text-sm text-gray-500 mt-2'> {isUploading && (
//<Loader2 className='h-4 w-4 mr-2 animate-spin' /> <div className='flex items-center text-sm text-gray-500 mt-2'>
//Uploading... <Loader2 className='h-4 w-4 mr-2 animate-spin' />
//</div> Uploading...
//)} </div>
} )}
</div> </div>
</CardContent> </CardContent>
); );

View File

@@ -1,15 +1,11 @@
'use client'; 'use client';
import { type Preloaded, usePreloadedQuery } from 'convex/react'; import { type Preloaded, usePreloadedQuery } from 'convex/react';
import { type api } from '~/convex/_generated/api'; import { type api } from '~/convex/_generated/api';
import { import { CardHeader, CardTitle, CardDescription } from '@/components/ui';
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui';
type ProfileCardProps = { type ProfileCardProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>; preloadedUser: Preloaded<typeof api.auth.getUser>;
} };
const ProfileHeader = ({ preloadedUser }: ProfileCardProps) => { const ProfileHeader = ({ preloadedUser }: ProfileCardProps) => {
const user = usePreloadedQuery(preloadedUser); const user = usePreloadedQuery(preloadedUser);