Refactor & clean up code.

This commit is contained in:
2025-07-17 15:20:59 -05:00
parent dabc248010
commit fefe7e8717
31 changed files with 473 additions and 295 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -48,12 +48,13 @@
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@sentry/nextjs": "^9.36.0",
"@sentry/nextjs": "^9.40.0",
"@supabase-cache-helpers/postgrest-react-query": "^1.13.4",
"@supabase-cache-helpers/storage-react-query": "^1.3.5",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.4",
"@supabase/supabase-js": "^2.51.0",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.82.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -64,7 +65,7 @@
"import-in-the-middle": "^1.14.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.522.0",
"next": "^15.3.5",
"next": "^15.4.1",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6",
"postgres": "^3.4.7",
@@ -85,12 +86,12 @@
"@tailwindcss/postcss": "^4.1.11",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^20.19.6",
"@types/node": "^20.19.8",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"drizzle-kit": "^0.30.6",
"eslint": "^9.30.1",
"eslint-config-next": "^15.3.5",
"eslint": "^9.31.0",
"eslint-config-next": "^15.4.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-drizzle": "^0.2.3",
"eslint-plugin-prettier": "^5.5.1",
@@ -100,7 +101,7 @@
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0"
"typescript-eslint": "^8.37.0"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
@@ -108,6 +109,9 @@
"trustedDependencies": [
"@sentry/cli",
"@tailwindcss/oxide",
"core-js-pure",
"esbuild",
"sharp",
"unrs-resolver"
]
}

View File

@@ -11,21 +11,14 @@ const AuthSuccessPage = () => {
useEffect(() => {
const handleAuthSuccess = async () => {
// Refresh the auth context to pick up the new session
await refreshUser();
// Small delay to ensure state is updated
setTimeout(() => {
router.push('/');
}, 100);
setTimeout(() => router.push('/'), 100);
};
handleAuthSuccess().catch((error) => {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
});
handleAuthSuccess()
.catch(error => console.error(`Error handling auth success: ${error}`));
}, [refreshUser, router]);
// Show loading while processing
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='flex flex-col items-center space-y-4'>

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Forgot Password',
};
};
const ForgotPasswordLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default ForgotPasswordLayout;

View File

@@ -0,0 +1,11 @@
'use client';
import { ForgotPasswordCard } from '@/components/default/auth/cards/client';
const ForgotPasswordPage = () => {
return (
<div className='flex flex-col items-center min-h-[50vh]'>
<ForgotPasswordCard cardProps={{className: 'my-auto'}}/>
</div>
);
};
export default ForgotPasswordPage;

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Profile',
};
};
const ProfileLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default ProfileLayout;

View File

@@ -0,0 +1,9 @@
'use client';
const ProfilePage = () => {
return (
<div className='flex flex-col items-center min-h-[90vh]'>
</div>
);
};
export default ProfilePage;

View File

@@ -12,6 +12,8 @@ import PlausibleProvider from 'next-plausible';
import { Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs';
import Header from '@/components/default/layout/header';
import { SupabaseServer } from '@/utils/supabase';
import { getCurrentUser } from '@/lib/queries';
export const generateMetadata = (): Metadata => {
return {
@@ -211,9 +213,11 @@ const fontSans = Inter({
variable: '--font-sans',
});
export default function RootLayout({
const RootLayout = async ({
children,
}: Readonly<{ children: React.ReactNode }>) {
}: Readonly<{ children: React.ReactNode }>) => {
const client = await SupabaseServer();
const { data: { user } } = await getCurrentUser(client);
return (
<html
lang='en'
@@ -228,7 +232,7 @@ export default function RootLayout({
disableTransitionOnChange
>
<QueryClientProvider>
<AuthContextProvider>
<AuthContextProvider initialUser={user}>
<PlausibleProvider
domain='nexttemplate.gbrown.org'
customDomain='https://plausible.gbrown.org'
@@ -237,7 +241,9 @@ export default function RootLayout({
>
<TVModeProvider>
<Header />
{children}
<main className='min-h-[90vh]'>
{children}
</main>
<Toaster />
</TVModeProvider>
</PlausibleProvider>
@@ -248,3 +254,4 @@ export default function RootLayout({
</html>
);
};
export default RootLayout;

View File

@@ -1,9 +1,10 @@
import { SignInCard } from '@/components/default/auth/cards/client/sign-in';
import { SignInCard } from '@/components/default/auth/cards/client';
export default function HomePage() {
const HomePage = () => {
return (
<main className='flex flex-col items-center min-h-[90vh]'>
<div className='flex flex-col items-center min-h-[90vh]'>
<SignInCard containerProps={{className: 'my-auto'}}/>
</main>
</div>
);
}
};
export default HomePage;

View File

@@ -8,7 +8,7 @@ import {
import { useAuth } from '@/lib/hooks/context';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useSupabaseClient } from '@/utils/supabase';
import { SupabaseClient } from '@/utils/supabase';
import { FaApple } from 'react-icons/fa';
import { type ComponentProps } from 'react';
import { cn } from '@/lib/utils';
@@ -30,7 +30,7 @@ export const SignInWithApple = ({
const { loading, refreshUser } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [ isLoading, setIsLoading ] = useState(false);
const supabase = useSupabaseClient();
const supabase = SupabaseClient()!;
const handleSignInWithApple = async (e: React.FormEvent) => {
e.preventDefault();

View File

@@ -8,7 +8,7 @@ import {
import { useAuth } from '@/lib/hooks/context';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useSupabaseClient } from '@/utils/supabase';
import { SupabaseClient } from '@/utils/supabase';
import { FaMicrosoft } from 'react-icons/fa';
import { type ComponentProps } from 'react';
import { cn } from '@/lib/utils';
@@ -30,7 +30,7 @@ export const SignInWithMicrosoft = ({
const { loading, refreshUser } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [ isLoading, setIsLoading ] = useState(false);
const supabase = useSupabaseClient();
const supabase = SupabaseClient()!;
const handleSignInWithMicrosoft = async (e: React.FormEvent) => {
e.preventDefault();

View File

@@ -3,7 +3,7 @@ import { SubmitButton, type SubmitButtonProps } from '@/components/default/forms
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/context';
import { signOut } from '@/lib/queries';
import { useSupabaseClient } from '@/utils/supabase';
import { SupabaseClient } from '@/utils/supabase';
import { cn } from '@/lib/utils';
type SignOutProps = Omit<SubmitButtonProps, 'disabled' | 'onClick'>
@@ -13,8 +13,7 @@ export const SignOut = ({
pendingText = 'Signing out...',
...props
}: SignOutProps) => {
const supabase = useSupabaseClient();
const supabase = SupabaseClient()!;
const { loading, refreshUser } = useAuth();
const router = useRouter();
@@ -24,7 +23,7 @@ export const SignOut = ({
const result = await signOut(supabase);
if (result.error) throw new Error(result.error.message);
await refreshUser();
router.push('/sign-in');
router.push('/');
} catch (error) {
console.error(error);
}

View File

@@ -0,0 +1,45 @@
'use server';
import 'server-only';
import { redirect } from 'next/navigation';
import { SubmitButton, type SubmitButtonProps } from '@/components/default/forms';
import { signOut } from '@/lib/queries';
import { SupabaseServer } from '@/utils/supabase';
import { cn } from '@/lib/utils';
type SignOutProps = Omit<SubmitButtonProps, 'disabled' | 'onClick' | 'formAction'>
export const SignOut = async ({
className,
pendingText = 'Signing out...',
...props
}: SignOutProps) => {
const handleSignOut = async () => {
try {
const supabase = await SupabaseServer();
if (!supabase) throw new Error('Supabase client not found');
const result = await signOut(supabase);
if (result.error) throw new Error(result.error.message);
} catch (error) {
console.error(error);
//redirect('/global-error');
}
redirect('/');
};
return (
<form action={handleSignOut}>
<SubmitButton
formAction={handleSignOut}
{...props}
pendingText={pendingText}
className={cn(
'text-[1.0rem] font-semibold \
hover:bg-red-700/60 dark:hover:bg-red-300/80',
className
)}
>
Sign Out
</SubmitButton>
</form>
);
};

View File

@@ -21,7 +21,7 @@ import { forgotPassword } from '@/lib/queries';
import { useAuth } from '@/lib/hooks/context';
import { useEffect, useState, type ComponentProps } from 'react';
import { useRouter } from 'next/navigation';
import { useSupabaseClient } from '@/utils/supabase';
import { SupabaseClient } from '@/utils/supabase';
import { StatusMessage, SubmitButton } from '@/components/default/forms';
import { cn } from '@/lib/utils';
@@ -55,7 +55,7 @@ export const ForgotPasswordCard = ({
const router = useRouter();
const { isAuthenticated, loading, refreshUser } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const supabase = useSupabaseClient();
const supabase = SupabaseClient()!;
const form = useForm<z.infer<typeof forgotPasswordFormSchema>>({
resolver: zodResolver(forgotPasswordFormSchema),
@@ -73,7 +73,6 @@ export const ForgotPasswordCard = ({
setStatusMessage('');
const formData = new FormData();
formData.append('email', values.email);
if (!supabase) throw new Error('Supabase client not found');
const result = await forgotPassword(supabase, formData);
if (result.error) throw new Error(result.error.message);
await refreshUser();

View File

@@ -0,0 +1,2 @@
export { ForgotPasswordCard } from './forgot-password';
export { SignInCard } from './sign-in';

View File

@@ -7,7 +7,7 @@ import { signIn, signUp } from '@/lib/queries';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useAuth } from '@/lib/hooks/context';
import { useSupabaseClient } from '@/utils/supabase';
import { SupabaseClient } from '@/utils/supabase';
import { StatusMessage, SubmitButton } from '@/components/default/forms';
import {
Card,
@@ -93,7 +93,7 @@ export const SignInCard = ({
const router = useRouter();
const { isAuthenticated, loading, refreshUser } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const supabase = useSupabaseClient();
const supabase = SupabaseClient()!;
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
resolver: zodResolver(signInFormSchema),
@@ -283,14 +283,20 @@ export const SignInCard = ({
'flex w-5/6 m-auto',
signInWithMicrosoftProps?.submitButtonProps?.className),
}}
textClassName={cn(
'text-lg',
signInWithMicrosoftProps?.textClassName,
)}
iconClassName={cn(
'size-6',
signInWithMicrosoftProps?.iconClassName,
)}
textProps={{
...signInWithMicrosoftProps?.textProps,
className: cn(
'text-lg',
signInWithMicrosoftProps?.textProps?.className,
),
}}
iconProps={{
...signInWithMicrosoftProps?.iconProps,
className: cn(
'size-6',
signInWithMicrosoftProps?.iconProps?.className,
),
}}
/>
<SignInWithApple
{...signInWithAppleProps}
@@ -299,14 +305,20 @@ export const SignInCard = ({
'flex w-5/6 m-auto',
signInWithAppleProps?.submitButtonProps?.className),
}}
textClassName={cn(
'text-lg',
signInWithAppleProps?.textClassName,
)}
iconClassName={cn(
'size-6',
signInWithAppleProps?.iconClassName,
)}
textProps={{
...signInWithAppleProps?.textProps,
className: cn(
'text-lg',
signInWithAppleProps?.textProps?.className,
),
}}
iconProps={{
...signInWithAppleProps?.iconProps,
className: cn(
'size-6',
signInWithAppleProps?.iconProps?.className,
),
}}
/>
</CardContent>
</Card>
@@ -444,14 +456,18 @@ export const SignInCard = ({
'flex w-5/6 m-auto',
signInWithMicrosoftProps?.submitButtonProps?.className),
}}
textClassName={cn(
'text-lg',
signInWithMicrosoftProps?.textClassName,
)}
iconClassName={cn(
'size-6',
signInWithMicrosoftProps?.iconClassName,
)}
textProps={{
className: cn(
'text-lg',
signInWithMicrosoftProps?.textProps?.className,
),
}}
iconProps={{
className: cn(
'size-6',
signInWithMicrosoftProps?.iconProps?.className,
),
}}
/>
<SignInWithApple
{...signInWithAppleProps}
@@ -460,14 +476,18 @@ export const SignInCard = ({
'flex w-5/6 m-auto',
signInWithAppleProps?.submitButtonProps?.className),
}}
textClassName={cn(
'text-lg',
signInWithAppleProps?.textClassName,
)}
iconClassName={cn(
'size-6',
signInWithAppleProps?.iconClassName,
)}
textProps={{
className: cn(
'text-lg',
signInWithAppleProps?.textProps?.className,
),
}}
iconProps={{
className: cn(
'size-6',
signInWithAppleProps?.iconProps?.className,
),
}}
/>
</CardContent>
</Card>

View File

@@ -1,7 +1,7 @@
'use client';
import { useFileUpload } from '@/lib/hooks';
import { useAuth } from '@/lib/hooks/context';
import { useSupabaseClient } from '@/utils/supabase';
import { SupabaseClient } from '@/utils/supabase';
import {
BasedAvatar,
Card,
@@ -11,6 +11,7 @@ import { Loader2, Pencil, Upload } from 'lucide-react';
import type { ComponentProps, ChangeEvent } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { getAvatarUrl } from '@/lib/queries';
type AvatarUploadProps = {
onAvatarUploaded: (path: string) => Promise<void>;
@@ -32,8 +33,12 @@ export const AvatarUpload = ({
},
}: AvatarUploadProps) => {
const { profile, isAuthenticated } = useAuth();
const { isUploading, fileInputRef, uploadAvatarMutation } = useFileUpload();
const client = useSupabaseClient();
const client = SupabaseClient()!;
const {
isUploading,
fileInputRef,
uploadAvatarMutation
} = useFileUpload(client, 'avatars');
const handleAvatarClick = () => {
if (!isAuthenticated) {
@@ -56,9 +61,7 @@ export const AvatarUpload = ({
`${profile?.id}.${file.name.split('.').pop()}`;
const avatarUrl = await uploadAvatarMutation.mutateAsync({
client,
file,
bucket: 'avatars',
resize: {
maxWidth: 500,
maxHeight: 500,
@@ -91,7 +94,7 @@ export const AvatarUpload = ({
>
<BasedAvatar
{...basedAvatarProps}
src={profile?.avatar_url}
src={getAvatarUrl(client, profile?.avatar_url ?? '')}
fullName={profile?.full_name}
className={cn('h-32, w-32', basedAvatarProps?.className)}
fallbackProps={{ className: 'text-4xl font-semibold' }}
@@ -99,7 +102,7 @@ export const AvatarUpload = ({
/>
<div
className={cn(
'absoloute inset-0 rounded-full bg-black/0\
'absolute inset-0 rounded-full bg-black/0\
group-hover:bg-black/50 transition-all flex\
items-center justify-center'
)}

View File

@@ -13,16 +13,16 @@ import {
import { useAuth } from '@/lib/hooks/context';
import { useRouter } from 'next/navigation';
import { signOut } from '@/lib/queries';
import { useSupabaseClient } from '@/utils/supabase';
import { SupabaseClient } from '@/utils/supabase';
import { getAvatarUrl } from '@/lib/queries';
export const AvatarDropdown = () => {
const { profile, avatar, refreshUser } = useAuth();
const { profile, refreshUser } = useAuth();
const router = useRouter();
const client = useSupabaseClient();
const client = SupabaseClient()!;
const handleSignOut = async () => {
try {
if (!client) throw new Error('Supabase client not found!');
const { error } = await signOut(client);
if (error) throw new Error(error.message);
await refreshUser();
@@ -36,7 +36,7 @@ export const AvatarDropdown = () => {
<DropdownMenu>
<DropdownMenuTrigger>
<BasedAvatar
src={avatar}
src={getAvatarUrl(client, profile?.avatar_url ?? '')}
fullName={profile?.full_name}
className='lg:h-12 lg:w-12 my-auto'
fallbackProps={{ className: 'text-xl font-semibold' }}

View File

@@ -1,11 +1,20 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { ThemeToggle, useAuth } from '@/lib/hooks/context';
import { ThemeToggle, type ThemeToggleProps, useAuth } from '@/lib/hooks/context';
import { cn } from '@/lib/utils';
import { AvatarDropdown } from './avatar-dropdown';
import { type ComponentProps } from 'react';
const Header = () => {
type Props = {
headerProps?: ComponentProps<'header'>;
themeToggleProps?: ThemeToggleProps;
};
const Header = ({
headerProps,
themeToggleProps,
}: Props) => {
const { isAuthenticated } = useAuth();
const Controls = () => (
@@ -13,9 +22,10 @@ const Header = () => {
<ThemeToggle
size={1.2}
buttonProps={{
className: 'mr-4 py-5',
variant: 'secondary',
size: 'sm'
size: 'sm',
className: 'mr-4 py-5',
...themeToggleProps?.buttonProps,
}}
/>
{isAuthenticated && ( <AvatarDropdown /> )}
@@ -23,46 +33,50 @@ const Header = () => {
);
return (
<header className='w-full min-h-[10vh]'>
<div className='container mx-auto px-4 md:px-6 lg:px-20'>
<div className='flex items-center justify-between'>
{/* Left spacer for perfect centering */}
<div className='flex flex-1 justify-start'>
<div className='sm:w-[120px] md:w-[160px]' />
</div>
{/* Centered logo and title */}
<div className='flex-shrink-0'>
<Link
href='/'
scroll={false}
className='flex flex-row items-center justify-center px-4'
>
<Image
src='/favicon.png'
alt='Tech Tracker Logo'
width={100}
height={100}
className='max-w-[40px] md:max-w-[120px]'
/>
<h1
className='title-text text-sm md:text-4xl lg:text-8xl
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground
dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
>
Next Template
</h1>
</Link>
</div>
{/* Right-aligned controls */}
<div className='flex-1 flex justify-end'>
<Controls />
</div>
<header
{...headerProps}
className={cn(
'w-full min-h-[10vh] px-4 md:px-6 lg:px-20',
headerProps?.className,
)}
>
<div className='flex items-center justify-between'>
{/* Left spacer for perfect centering */}
<div className='flex flex-1 justify-start'>
<div className='sm:w-[120px] md:w-[160px]' />
</div>
{/* Centered logo and title */}
<div className='flex-shrink-0'>
<Link
href='/'
scroll={false}
className='flex flex-row items-center justify-center px-4'
>
<Image
src='/favicon.png'
alt='Tech Tracker Logo'
width={100}
height={100}
className='max-w-[40px] md:max-w-[120px]'
/>
<h1
className='title-text text-sm md:text-4xl lg:text-8xl
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground
dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
>
Next Template
</h1>
</Link>
</div>
{/* Right-aligned controls */}
<div className='flex-1 flex justify-end'>
<Controls />
</div>
</div>
</header>
);

View File

@@ -1,5 +1,5 @@
export { AuthContextProvider, useAuth } from './use-auth';
export { useIsMobile } from './use-mobile';
export { QueryClientProvider, QueryErrorCodes } from './use-query';
export { ThemeProvider, ThemeToggle } from './use-theme';
export { ThemeProvider, ThemeToggle, type ThemeToggleProps } from './use-theme';
export { TVModeProvider, useTVMode, TVToggle } from './use-tv-mode';

View File

@@ -1,92 +1,73 @@
'use client';
import React, {
type ReactNode,
createContext,
useContext,
useEffect,
useState
} from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
useQuery as useSupabaseQuery,
useUpdateMutation,
} from '@supabase-cache-helpers/postgrest-react-query';
import { QueryErrorCodes } from '@/lib/hooks/context';
import { type User, type Profile, useSupabaseClient } from '@/utils/supabase';
import { type User, type Profile } from '@/utils/supabase';
import { SupabaseClient } from '@/utils/supabase';
import { toast } from 'sonner';
import {
getAvatar,
getCurrentUser,
getProfile,
updateProfile as updateProfileQuery
} from '@/lib/queries';
type AuthContextType = {
user: User | null;
profile: Profile | null;
avatar: string | null;
loading: boolean;
isAuthenticated: boolean;
updateProfile: (data: {
full_name?: string;
email?: string;
avatar_url?: string;
provider?: string;
}) => Promise<{ data?: Profile | null; error?: { message: string } | null }>;
updateProfile: (data: Partial<Profile>) => Promise<void>;
refreshUser: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AuthContextProvider = ({ children }: { children: ReactNode }) => {
export const AuthContextProvider = ({
children,
initialUser,
}: {
children: React.ReactNode;
initialUser?: User | null;
}) => {
const queryClient = useQueryClient();
const supabase = useSupabaseClient();
const supabase = SupabaseClient();
if (!supabase) throw new Error('Supabase client not found!');
// User query
// Initialize with server-side user data
const [user, setUser] = useState<User | null>(initialUser ?? null);
// User query with initial data
const {
data: userData,
isLoading: userLoading,
error: userError,
} = useQuery({
queryKey: ['auth', 'user'],
queryFn: async () => {
const result = await getCurrentUser(supabase);
if (result.error) throw result.error;
return result.data.user as User | null;
const { data: { user } } = await supabase.auth.getUser();
return user;
},
retry: false,
meta: { errCode: QueryErrorCodes.FETCH_USER_FAILED },
initialData: initialUser,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Profile query
// Profile query using Supabase Cache Helpers
const {
data: profileData,
isLoading: profileLoading,
} = useSupabaseQuery(
getProfile(supabase, userData?.id ?? ''),
supabase
.from('profiles')
.select('*')
.eq('id', userData?.id ?? '')
.single(),
{
enabled: !!userData?.id,
meta: { errCode: QueryErrorCodes.FETCH_PROFILE_FAILED },
}
);
// Avatar query
const {
data: avatarData,
} = useQuery({
queryKey: ['auth', 'avatar', profileData?.avatar_url],
queryFn: async () => {
if (!profileData?.avatar_url) return null;
const result = await getAvatar(supabase, profileData.avatar_url);
if (result.error) throw result.error;
return result.data.signedUrl as string | null;
},
enabled: !!profileData?.avatar_url,
meta: { errCode: QueryErrorCodes.FETCH_AVATAR_FAILED },
});
// Update profile mutation
const updateProfileMutation = useUpdateMutation(
supabase.from('profiles'),
@@ -95,47 +76,31 @@ const AuthContextProvider = ({ children }: { children: ReactNode }) => {
{
onSuccess: () => toast.success('Profile updated successfully!'),
onError: (error) => toast.error(`Failed to update profile: ${error.message}`),
meta: { errCode: QueryErrorCodes.UPDATE_PROFILE_FAILED },
},
}
);
//const updateProfileMutation = useMutation({
//mutationFn: async (updates: Partial<Profile>) => {
//if (!userData?.id) throw new Error('User ID is required!');
//const result = await updateProfileQuery(supabase, userData.id, updates);
//if (result.error) throw result.error;
//return result.data;
//},
//onSuccess: () => {
//queryClient.invalidateQueries({ queryKey: ['auth'] })
//.catch((error) => console.error('Error invalidating auth queries:', error));
//toast.success('Profile updated successfully!');
//},
//meta: { errCode: QueryErrorCodes.UPDATE_PROFILE_FAILED },
//});
// Auth state listener
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, _session) => {
if (event === 'SIGNED_IN' || event === 'SIGNED_OUT' || event === 'TOKEN_REFRESHED') {
await queryClient.invalidateQueries({ queryKey: ['auth'] });
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
setUser(session?.user ?? null);
if (event === 'SIGNED_IN' || event === 'SIGNED_OUT' || event === 'TOKEN_REFRESHED') {
await queryClient.invalidateQueries({ queryKey: ['auth'] });
}
}
});
);
return () => subscription.unsubscribe();
}, [supabase.auth, queryClient]);
const handleUpdateProfile = async (data: Partial<Profile>) => {
if (!userData?.id) throw new Error('User ID is required!');
try {
const result = await updateProfileMutation.mutateAsync({
...data,
id: userData.id,
});
return { data: result, error: null };
} catch (error) {
return { data: null, error };
}
await updateProfileMutation.mutateAsync({
...data,
id: userData.id,
});
};
const refreshUser = async () => {
@@ -145,22 +110,23 @@ const AuthContextProvider = ({ children }: { children: ReactNode }) => {
const value: AuthContextType = {
user: userData ?? null,
profile: profileData ?? null,
avatar: avatarData ?? null,
loading: userLoading || profileLoading,
isAuthenticated: !!userData && !userError,
isAuthenticated: !!userData,
updateProfile: handleUpdateProfile,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
const useAuth = () => {
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context || context === undefined) {
if (!context) {
throw new Error('useAuth must be used within an AuthContextProvider');
}
return context;
};
export { AuthContextProvider, useAuth };

View File

@@ -66,7 +66,10 @@ const QueryClientProvider = ({ children }: { children: React.ReactNode }) => {
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
staleTime: 60 * 1000,
// Supabase cache helpers recommends Infinity.
// React Query Recommends 1 minute.
staleTime: 10 * (60 * 1000), // We'll be in between with 10 minutes
gcTime: Infinity,
},
},
})

View File

@@ -71,4 +71,4 @@ const ThemeToggle = ({
);
};
export { ThemeProvider, ThemeToggle };
export { ThemeProvider, ThemeToggle, type ThemeToggleProps };

View File

@@ -1,77 +1,98 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getSignedUrl, resizeImage, uploadFile } from '@/lib/queries';
import { useUpload } from '@supabase-cache-helpers/storage-react-query';
import { getSignedUrl, resizeImage } from '@/lib/queries';
import { useAuth, QueryErrorCodes } from '@/lib/hooks/context';
import type { SupabaseClient, User, Profile } from '@/utils/supabase';
import type { SBClientWithDatabase, User, Profile } from '@/utils/supabase';
import { toast } from 'sonner';
type UploadToStorageProps = {
client: SupabaseClient;
client: SBClientWithDatabase;
file: File;
bucket: string;
resize?: false | {
maxWidth?: number;
maxHeight?: number;
quality?: number;
},
replace?: false | string,
};
replace?: false | string;
};
const useFileUpload = () => {
const useFileUpload = (client: SBClientWithDatabase, bucket: string) => {
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const { profile, isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const uploadToStorage = async ({
client,
// Initialize the upload hook at the top level
const { mutateAsync: upload } = useUpload(
client.storage.from(bucket),
{
buildFileName: ({ fileName, path }) => path ?? fileName,
}
);
const uploadToStorage = useCallback(async ({
file,
bucket,
resize = false,
replace = false,
}: UploadToStorageProps) => {
}: Omit<UploadToStorageProps, 'client' | 'bucket'>) => {
try {
if (!isAuthenticated)
throw new Error('Error: User is not authenticated!');
setIsUploading(true);
let fileToUpload = file;
if (resize && file.type.startsWith('image/'))
fileToUpload = await resizeImage({file, options: resize});
const path = replace || `${Date.now()}-${profile?.id}.${file.name.split('.').pop()}`;
const { data, error} = await uploadFile({
client,
bucket,
path,
file: fileToUpload,
options: {
contentType: file.type,
...(replace && {upsert: true})
},
fileToUpload = await resizeImage({ file, options: resize });
const fileName = replace || `${Date.now()}-${profile?.id}.${file.name.split('.').pop()}`;
// Create a file object with the custom path
const fileWithPath = Object.assign(fileToUpload, {
webkitRelativePath: fileName,
});
if (error) throw new Error(`Error uploading file: ${error.message}`);
const uploadResult = await upload({ files: [fileWithPath]});
if (!uploadResult || uploadResult.length === 0) {
throw new Error('Upload failed: No result returned');
}
const uploadedFile = uploadResult[0];
if (!uploadedFile || uploadedFile.error) {
throw new Error(`Error uploading file: ${uploadedFile?.error.message ?? 'No uploaded file'}`);
}
// Get signed URL for the uploaded file
const { data: urlData, error: urlError } = await getSignedUrl({
client,
bucket,
path: data.path,
path: uploadedFile.data.path,
});
if (urlError) throw new Error(`Error getting signed URL: ${urlError.message}`);
return {urlData, error: null};
if (urlError) {
throw new Error(`Error getting signed URL: ${urlError.message}`);
}
return { urlData, error: null };
} catch (error) {
return { data: null, error };
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
}, [client, bucket, upload, isAuthenticated, profile?.id]);
const uploadMutation = useMutation({
mutationFn: uploadToStorage,
onSuccess: (result) => {
if (result.error) {
toast.error(`Upload failed: ${result.error as string}`)
toast.error(`Upload failed: ${result.error as string}`);
} else {
toast.success(`File uploaded successfully!`);
toast.success('File uploaded successfully!');
}
},
onError: (error) => {
@@ -81,15 +102,15 @@ const useFileUpload = () => {
});
const uploadAvatarMutation = useMutation({
mutationFn: async (props: UploadToStorageProps) => {
mutationFn: async (props: Omit<UploadToStorageProps, 'client' | 'bucket'>) => {
const { data, error } = await uploadToStorage(props);
if (error) throw new Error(`Error uploading avatar: ${error as string}`);
return data;
},
onSuccess: (avatarUrl) => {
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['auth'] })
.catch((error) => console.error('Error invalidating auth query:', error));
queryClient.setQueryData(['auth, user'], (oldUser: User) => oldUser);
if (profile?.id) {
queryClient.setQueryData(['profiles', profile.id], (oldProfile: Profile) => ({
...oldProfile,
@@ -97,14 +118,13 @@ const useFileUpload = () => {
updated_at: new Date().toISOString(),
}));
}
toast.success('Avatar uploaded sucessfully!');
toast.success('Avatar uploaded successfully!');
},
onError: (error) => {
toast.error(`Avatar upload failed: ${error instanceof Error ? error.message : error}`);
},
meta: { errCode: QueryErrorCodes.UPLOAD_PHOTO_FAILED },
})
});
return {
isUploading: isUploading || uploadMutation.isPending || uploadAvatarMutation.isPending,

View File

@@ -1,7 +1,6 @@
import { type SupabaseClient, type Profile } from '@/utils/supabase';
import { getSignedUrl } from '@/lib/queries';
import { type Profile, type SBClientWithDatabase, type UserRecord } from '@/utils/supabase';
const signUp = (client: SupabaseClient, formData: FormData) => {
const signUp = (client: SBClientWithDatabase, formData: FormData) => {
const full_name = formData.get('name') as string;
const email = formData.get('email') as string;
const password = formData.get('password') as string;
@@ -20,13 +19,13 @@ const signUp = (client: SupabaseClient, formData: FormData) => {
});
};
const signIn = (client: SupabaseClient, formData: FormData) => {
const signIn = (client: SBClientWithDatabase, formData: FormData) => {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
return client.auth.signInWithPassword({ email, password });
};
const signInWithMicrosoft = (client: SupabaseClient) => {
const signInWithMicrosoft = (client: SBClientWithDatabase) => {
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
return client.auth.signInWithOAuth({
provider: 'azure',
@@ -37,7 +36,7 @@ const signInWithMicrosoft = (client: SupabaseClient) => {
});
};
const signInWithApple = (client: SupabaseClient) => {
const signInWithApple = (client: SBClientWithDatabase) => {
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
return client.auth.signInWithOAuth({
provider: 'apple',
@@ -48,7 +47,7 @@ const signInWithApple = (client: SupabaseClient) => {
});
};
const forgotPassword = (client: SupabaseClient, formData: FormData) => {
const forgotPassword = (client: SBClientWithDatabase, formData: FormData) => {
const email = formData.get('email') as string;
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
return client.auth.resetPasswordForEmail(email, {
@@ -56,20 +55,20 @@ const forgotPassword = (client: SupabaseClient, formData: FormData) => {
});
};
const resetPassword = (client: SupabaseClient, formData: FormData) => {
const resetPassword = (client: SBClientWithDatabase, formData: FormData) => {
const password = formData.get('password') as string;
return client.auth.updateUser({ password });
};
const signOut = (client: SupabaseClient) => {
const signOut = (client: SBClientWithDatabase) => {
return client.auth.signOut();
}
const getCurrentUser = (client: SupabaseClient) => {
const getCurrentUser = (client: SBClientWithDatabase) => {
return client.auth.getUser();
};
const getProfile = (client: SupabaseClient, userId: string) => {
const getProfile = (client: SBClientWithDatabase, userId: string) => {
return client
.from(`profiles`)
.select(`*`)
@@ -77,21 +76,29 @@ const getProfile = (client: SupabaseClient, userId: string) => {
.single();
};
const getAvatar = (client: SupabaseClient, avatarUrl: string) => {
return getSignedUrl({
client,
bucket: 'avatars',
path: avatarUrl,
seconds: 3600,
transform: {
width: 128,
height: 128,
},
});
const getUserWithStatus = (client: SBClientWithDatabase, userId: string) => {
return client
.from(`profiles`)
.select(`
id,
updated_at,
email,
full_name,
avatar_url,
provider,
status:statuses!current_status_id(
text:status,
created_at,
updated_by:profiles!updated_by_id(*)
)
`)
.eq(`id`, userId)
.throwOnError()
.single();
};
const updateProfile = (
client: SupabaseClient,
client: SBClientWithDatabase,
userId: string,
updates: Partial<Profile>,
) => {
@@ -108,7 +115,7 @@ export {
forgotPassword,
getCurrentUser,
getProfile,
getAvatar,
getUserWithStatus,
resetPassword,
signIn,
signInWithApple,

View File

@@ -2,7 +2,7 @@ export {
forgotPassword,
getCurrentUser,
getProfile,
getAvatar,
getUserWithStatus,
resetPassword,
signIn,
signInWithApple,
@@ -13,7 +13,9 @@ export {
} from './auth';
export {
deleteFiles,
getAvatarUrl,
getPublicUrl,
getSignedAvatarUrl,
getSignedUrl,
listFiles,
resizeImage,

44
src/lib/queries/storage.ts Normal file → Executable file
View File

@@ -1,7 +1,7 @@
import { type SupabaseClient, type Profile } from '@/utils/supabase';
import { type SBClientWithDatabase } from '@/utils/supabase';
type GetStorageProps = {
client: SupabaseClient;
client: SBClientWithDatabase;
bucket: string;
path: string;
seconds?: number;
@@ -16,7 +16,7 @@ type GetStorageProps = {
};
type UploadStorageProps = {
client: SupabaseClient;
client: SBClientWithDatabase;
bucket: string;
path: string;
file: File;
@@ -36,6 +36,22 @@ type ResizeImageProps = {
};
};
const getAvatarUrl = (
client: SBClientWithDatabase,
path: string,
) => {
return getPublicUrl({
client,
bucket: 'avatars',
path,
transform: {
width: 128,
height: 128,
quality: 0.8,
}
}).data.publicUrl;
};
const getPublicUrl = ({
client,
bucket,
@@ -48,6 +64,22 @@ const getPublicUrl = ({
.getPublicUrl(path, { download, transform});
};
const getSignedAvatarUrl = (
client: SBClientWithDatabase,
avatarUrl: string
) => {
return getSignedUrl({
client,
bucket: 'avatars',
path: avatarUrl,
seconds: 3600,
transform: {
width: 128,
height: 128,
},
});
};
const getSignedUrl = ({
client,
bucket,
@@ -92,7 +124,7 @@ const deleteFiles = ({
bucket,
path,
}: {
client: SupabaseClient;
client: SBClientWithDatabase;
bucket: string;
path: string[];
}) => {
@@ -105,7 +137,7 @@ const listFiles = ({
path = '',
options = {},
}: {
client: SupabaseClient;
client: SBClientWithDatabase;
bucket: string;
path?: string;
options?: {
@@ -169,7 +201,9 @@ const resizeImage = async ({
export {
deleteFiles,
getAvatarUrl,
getPublicUrl,
getSignedAvatarUrl,
getSignedUrl,
listFiles,
resizeImage,

View File

@@ -1,12 +1,10 @@
'use client';
import { createBrowserClient } from '@supabase/ssr';
import type { Database, SupabaseClient } from '@/utils/supabase';
import { useMemo } from 'react';
import type { Database, SBClientWithDatabase } from '@/utils/supabase';
let client: SupabaseClient | undefined;
let client: SBClientWithDatabase | undefined;
const getSupbaseClient = (): SupabaseClient | undefined => {
const getSupbaseClient = (): SBClientWithDatabase | undefined => {
if (client) return client;
client = createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
@@ -15,8 +13,6 @@ const getSupbaseClient = (): SupabaseClient | undefined => {
return client;
};
const useSupabaseClient = () => {
return useMemo(getSupbaseClient, []);
};
const SupabaseClient = () => getSupbaseClient();
export { useSupabaseClient };
export { SupabaseClient };

View File

@@ -1,5 +1,5 @@
export { useSupabaseClient } from './client';
export { updateSession } from './middleware';
export { SupabaseClient } from './client';
export { SupabaseServer } from './server';
export { updateSession } from './middleware';
export type { Database } from './database.types';
export type * from './types';

View File

@@ -1,11 +1,10 @@
'use server';
import 'server-only';
import { createServerClient } from '@supabase/ssr';
import type { Database } from '@/utils/supabase';
import type { Database, SBClientWithDatabase } from '@/utils/supabase';
import { cookies } from 'next/headers';
export const SupabaseServer = async () => {
const SupabaseServer = async () => {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
@@ -26,5 +25,7 @@ export const SupabaseServer = async () => {
},
},
},
);
) as SBClientWithDatabase;
};
export { SupabaseServer };

View File

@@ -1,7 +1,7 @@
import type { Database } from '@/utils/supabase/database.types';
import type { SupabaseClient as SBClient } from '@supabase/supabase-js'
export type SupabaseClient = SBClient<Database>;
export type SBClientWithDatabase = SBClient<Database>;
export type { User } from '@supabase/supabase-js';
@@ -10,6 +10,20 @@ export type Result<T> = {
error: { message: string } | null;
};
export type UserRecord = {
id: string,
updated_at: string | null,
email: string | null,
full_name: string | null,
avatar_url: string | null,
provider: string | null,
status: {
status: string,
created_at: string,
updated_by: Profile | null,
}
};
export type AsyncReturnType<T extends (...args: any) => Promise<any>> =
T extends (...args: any) => Promise<infer R> ? R : never;