We now have an AuthContext!

This commit is contained in:
2025-05-20 19:28:31 -05:00
parent 0f92f7eb7f
commit 8169c719f6
12 changed files with 389 additions and 398 deletions

View File

@ -1,5 +1,7 @@
'use client';
import { useProfile } from '@/lib/hooks';
import { useAuth } from '@/components/context/auth';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { AvatarUpload, ProfileForm } from '@/components/default/profile';
import {
Card,
@ -12,10 +14,18 @@ import {
import { Loader2 } from 'lucide-react';
const ProfilePage = () => {
const { profile, isLoading, updateProfile } = useProfile();
const { profile, isLoading, isAuthenticated, updateProfile, refreshUserData } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/sign-in');
}
}, [isLoading, isAuthenticated, router])
const handleAvatarUploaded = async (path: string) => {
await updateProfile({ avatar_url: path });
await refreshUserData();
};
const handleProfileSubmit = async (values: {
@ -28,12 +38,23 @@ const ProfilePage = () => {
});
};
if (profile === undefined && !isLoading)
// Show loading state while checking authentication
if (isLoading) {
return (
<div className='flex p-5 items-center justify-center'>
<h1>Unauthorized</h1>
<div className='flex justify-center items-center min-h-[50vh]'>
<Loader2 className='h-8 w-8 animate-spin text-gray-500' />
</div>
);
}
// If not authenticated and not loading, this will show briefly before redirect
if (!isAuthenticated) {
return (
<div className='flex p-5 items-center justify-center'>
<h1>Unauthorized - Redirecting...</h1>
</div>
);
}
return (
<div className='max-w-3xl min-w-sm mx-auto p-4'>
@ -51,18 +72,9 @@ const ProfilePage = () => {
</div>
) : (
<div className='space-y-8'>
<AvatarUpload
profile={profile}
onAvatarUploaded={handleAvatarUploaded}
/>
<AvatarUpload onAvatarUploaded={handleAvatarUploaded} />
<Separator />
<ProfileForm
profile={profile}
isLoading={isLoading}
onSubmit={handleProfileSubmit}
/>
<ProfileForm onSubmit={handleProfileSubmit} />
</div>
)}
</CardContent>

View File

@ -3,6 +3,7 @@ import '@/styles/globals.css';
import { Geist } from 'next/font/google';
import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme';
import { AuthProvider } from '@/components/context/auth'
import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer';
import { Toaster } from '@/components/ui';
@ -45,16 +46,18 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
enableSystem
disableTransitionOnChange
>
<main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'>
<Navigation />
<div className='flex flex-col gap-20 max-w-5xl p-5 w-full'>
{children}
<AuthProvider>
<main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'>
<Navigation />
<div className='flex flex-col gap-20 max-w-5xl p-5 w-full'>
{children}
</div>
</div>
</div>
<Footer />
</main>
<Toaster />
<Footer />
</main>
<Toaster />
</AuthProvider>
</ThemeProvider>
</body>
</html>

View File

@ -0,0 +1,147 @@
'use client';
import React, { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { getUser, getProfile, updateProfile as updateProfileAction, getSignedUrl } from '@/lib/actions';
import type { User, Profile } from '@/utils/supabase';
import { toast } from 'sonner';
type AuthContextType = {
user: User | null;
profile: Profile | null;
avatarUrl: string | null;
isLoading: boolean;
isAuthenticated: boolean;
updateProfile: (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
refreshUserData: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchUserData = async () => {
try {
setIsLoading(true);
// Get user data
const userResponse = await getUser();
if (!userResponse.success) {
setUser(null);
setProfile(null);
setAvatarUrl(null);
return;
}
setUser(userResponse.data);
// Get profile data
const profileResponse = await getProfile();
if (!profileResponse.success) {
setProfile(null);
setAvatarUrl(null);
return;
}
setProfile(profileResponse.data);
// Get avatar URL if available
if (profileResponse.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: profileResponse.data.avatar_url,
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
}
}
} catch (error) {
console.error('Error fetching user data:', error);
toast.error('Failed to load user data');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchUserData().catch((error) => {
console.error('Error fetching user data:', error);
});
const intervalId = setInterval(() => {
void fetchUserData();
}, 30 * 60 * 1000);
return () => clearInterval(intervalId);
}, []);
const updateProfile = async (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => {
try {
setIsLoading(true);
const result = await updateProfileAction(data);
if (!result.success) {
throw new Error(result.error ?? 'Failed to update profile');
}
setProfile(result.data);
// If avatar was updated, refresh the avatar URL
if (data.avatar_url && result.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: result.data.avatar_url,
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
}
}
toast.success('Profile updated successfully!');
return { success: true, data: result.data };
} catch (error) {
console.error('Error updating profile:', error);
toast.error(error instanceof Error ? error.message : 'Failed to update profile');
return { success: false, error };
} finally {
setIsLoading(false);
}
};
const refreshUserData = async () => {
await fetchUserData();
};
const value = {
user,
profile,
avatarUrl,
isLoading,
isAuthenticated: !!user,
updateProfile,
refreshUserData,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@ -47,7 +47,13 @@ export const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => {
};
return (
<Button variant='outline' size='icon' onClick={toggleTheme} {...props}>
<Button
variant='outline'
size='icon'
className='cursor-pointer'
onClick={toggleTheme}
{...props}
>
<Sun
style={{ height: `${size}rem`, width: `${size}rem` }}
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'

View File

@ -13,28 +13,17 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui';
import { useProfile, useAvatar } from '@/lib/hooks';
import { useAuth } from '@/components/context/auth';
import { signOut } from '@/lib/actions';
import { User } from 'lucide-react';
const AvatarDropdown = () => {
const { profile } = useProfile();
const { avatarUrl, isLoading } = useAvatar(profile);
const { profile, avatarUrl, isLoading } = useAuth();
const handleSignOut = async () => {
await signOut();
};
if (isLoading) {
return (
<Avatar>
<AvatarFallback className='animate-pulse'>
<User size={32} />
</AvatarFallback>
</Avatar>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger>
@ -60,13 +49,13 @@ const AvatarDropdown = () => {
<DropdownMenuLabel>{profile?.full_name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href='/profile' className='w-full justify-center'>
<Link href='/profile' className='w-full justify-center cursor-pointer'>
Edit profile
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className='h-[2px]' />
<DropdownMenuItem asChild>
<button onClick={handleSignOut} className='w-full justify-center'>
<button onClick={handleSignOut} className='w-full justify-center cursor-pointer'>
Log out
</button>
</DropdownMenuItem>

View File

@ -1,19 +1,14 @@
import { useFileUpload } from '@/lib/hooks/useFileUpload';
import { useAvatar } from '@/lib/hooks/useAvatar';
import type { Profile } from '@/utils/supabase';
import { useAuth } from '@/components/context/auth';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui';
import { Pencil, User, Loader2 } from 'lucide-react';
import { Loader2, Pencil, Upload, User } from 'lucide-react';
type AvatarUploadProps = {
profile?: Profile;
onAvatarUploaded: (path: string) => Promise<void>;
};
export const AvatarUpload = ({
profile,
onAvatarUploaded,
}: AvatarUploadProps) => {
const { avatarUrl, isLoading } = useAvatar(profile);
export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
const { profile, avatarUrl } = useAuth();
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
const handleAvatarClick = () => {
@ -30,27 +25,13 @@ export const AvatarUpload = ({
}
};
if (isLoading) {
return (
<div className='flex flex-col items-center'>
<div className='mb-4'>
<Avatar className='h-32 w-32'>
<AvatarFallback className='text-2xl'>
{profile?.full_name ? (
profile.full_name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
) : (
<User size={32} />
)}
</AvatarFallback>
</Avatar>
</div>
</div>
);
}
const getInitials = (name: string | null | undefined): string => {
if (!name) return '';
return name.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase();
};
return (
<div className='flex flex-col items-center'>
@ -63,27 +44,29 @@ export const AvatarUpload = ({
<AvatarImage src={avatarUrl} alt={profile?.full_name ?? 'User'} />
) : (
<AvatarFallback className='text-2xl'>
{profile?.full_name ? (
profile.full_name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
) : (
<User size={32} />
)}
{profile?.full_name
? getInitials(profile.full_name)
: <User size={32} />}
</AvatarFallback>
)}
</Avatar>
<div
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
transition-all flex items-center justify-center'
>
<Pencil
className='text-white opacity-0 group-hover:opacity-100
transition-opacity'
size={24}
/>
<div
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
transition-all flex items-center justify-center'
>
<Upload
className='text-white opacity-0 group-hover:opacity-100
transition-opacity'
size={24}
/>
</div>
<div className='absolute inset-1 transition-all flex items-end justify-end'>
<Pencil
className='text-white opacity-100 group-hover:opacity-0
transition-opacity'
size={24}
/>
</div>
</div>
<input
ref={fileInputRef}
@ -93,16 +76,12 @@ export const AvatarUpload = ({
onChange={handleFileChange}
disabled={isUploading}
/>
</div>
{isUploading && (
<div className='flex items-center text-sm text-gray-500 mt-2'>
<Loader2 className='h-4 w-4 mr-2 animate-spin' />
Uploading...
</div>
)}
<p className='text-sm text-gray-500 mt-2'>
Click on the avatar to upload a new image
</p>
</div>
);
};

View File

@ -1,7 +1,6 @@
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { Profile } from '@/utils/supabase';
import {
Button,
Form,
@ -15,6 +14,7 @@ import {
} from '@/components/ui';
import { Loader2 } from 'lucide-react';
import { useEffect } from 'react';
import { useAuth } from '@/components/context/auth';
const formSchema = z.object({
full_name: z.string().min(5, {
@ -24,16 +24,12 @@ const formSchema = z.object({
});
type ProfileFormProps = {
profile?: Profile;
isLoading: boolean;
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
};
export function ProfileForm({
profile,
isLoading,
onSubmit,
}: ProfileFormProps) {
export const ProfileForm = ({onSubmit}: ProfileFormProps) => {
const { profile, isLoading } = useAuth();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {

View File

@ -1,3 +0,0 @@
export * from './useAvatar';
export * from './useFileUpload';
export * from './useProfile';

View File

@ -1,49 +0,0 @@
import { useState, useEffect } from 'react';
import { getSignedUrl } from '@/lib/actions';
import type { Profile } from '@/utils/supabase';
import { toast } from 'sonner';
export const useAvatar = (profile?: Profile) => {
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const getAvatarUrl = async () => {
if (profile?.avatar_url) {
try {
setIsLoading(true);
const response = await getSignedUrl({
bucket: 'avatars',
url: profile.avatar_url,
transform: {
quality: 20,
},
});
if (response.success) {
setAvatarUrl(response.data);
}
} catch (error) {
console.error('Error getting signed URL:', error);
} finally {
setIsLoading(false);
}
} else {
setAvatarUrl(undefined);
}
};
getAvatarUrl().catch((error) => {
toast.error(
error instanceof Error
? error.message
: 'Failed to get signed avatar url.',
);
});
}, [profile]);
return {
avatarUrl,
isLoading,
};
};

View File

@ -1,61 +0,0 @@
import { useState, useEffect } from 'react';
import { getProfile, updateProfile } from '@/lib/actions';
import type { Profile } from '@/utils/supabase';
import { toast } from 'sonner';
export const useProfile = () => {
const [profile, setProfile] = useState<Profile | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchProfile = async () => {
try {
setIsLoading(true);
const profileResponse = await getProfile();
if (!profileResponse.success)
throw new Error('Profile response unsuccessful');
setProfile(profileResponse.data);
} catch {
setProfile(undefined);
} finally {
setIsLoading(false);
}
};
fetchProfile().catch((error) => {
toast.error(
error instanceof Error ? error.message : 'Failed to get profile',
);
});
}, []);
const updateUserProfile = async (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => {
try {
setIsLoading(true);
const result = await updateProfile(data);
if (!result.success) {
throw new Error(result.error ?? 'Failed to update profile');
}
setProfile(result.data);
toast.success('Profile updated successfully!');
return { success: true, data: result.data };
} catch (error) {
console.error('Error updating profile: ', error);
toast.error(
error instanceof Error ? error.message : 'Failed to update profile',
);
return { success: false, error };
} finally {
setIsLoading(false);
}
};
return {
profile,
isLoading,
updateProfile: updateUserProfile,
};
};