diff --git a/bun.lockb b/bun.lockb index f712402..fdccb78 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 6745fb8..4738558 100644 --- a/package.json +++ b/package.json @@ -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" ] } diff --git a/src/app/(auth)/auth/success/page.tsx b/src/app/(auth)/auth/success/page.tsx index c98dc2d..a18dad1 100644 --- a/src/app/(auth)/auth/success/page.tsx +++ b/src/app/(auth)/auth/success/page.tsx @@ -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 (
diff --git a/src/app/(auth)/forgot-password/layout.tsx b/src/app/(auth)/forgot-password/layout.tsx new file mode 100644 index 0000000..55cd61c --- /dev/null +++ b/src/app/(auth)/forgot-password/layout.tsx @@ -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; diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..35d1646 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,11 @@ +'use client'; +import { ForgotPasswordCard } from '@/components/default/auth/cards/client'; + +const ForgotPasswordPage = () => { + return ( +
+ +
+ ); +}; +export default ForgotPasswordPage; diff --git a/src/app/(auth)/profile/layout.tsx b/src/app/(auth)/profile/layout.tsx new file mode 100644 index 0000000..09e67d4 --- /dev/null +++ b/src/app/(auth)/profile/layout.tsx @@ -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; diff --git a/src/app/(auth)/profile/page.tsx b/src/app/(auth)/profile/page.tsx new file mode 100644 index 0000000..c80e708 --- /dev/null +++ b/src/app/(auth)/profile/page.tsx @@ -0,0 +1,9 @@ +'use client'; + +const ProfilePage = () => { + return ( +
+
+ ); +}; +export default ProfilePage; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 39e20f5..7d0164f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - +
- {children} +
+ {children} +
@@ -248,3 +254,4 @@ export default function RootLayout({ ); }; +export default RootLayout; diff --git a/src/app/page.tsx b/src/app/page.tsx index 860807f..b354cf8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
+
-
+
); -} +}; +export default HomePage; diff --git a/src/components/default/auth/buttons/client/sign-in-with-apple.tsx b/src/components/default/auth/buttons/client/sign-in-with-apple.tsx index c43b96f..b99dff7 100644 --- a/src/components/default/auth/buttons/client/sign-in-with-apple.tsx +++ b/src/components/default/auth/buttons/client/sign-in-with-apple.tsx @@ -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(); diff --git a/src/components/default/auth/buttons/client/sign-in-with-microsoft.tsx b/src/components/default/auth/buttons/client/sign-in-with-microsoft.tsx index 2aafc43..7fab135 100644 --- a/src/components/default/auth/buttons/client/sign-in-with-microsoft.tsx +++ b/src/components/default/auth/buttons/client/sign-in-with-microsoft.tsx @@ -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(); diff --git a/src/components/default/auth/buttons/client/sign-out.tsx b/src/components/default/auth/buttons/client/sign-out.tsx index 04dd2aa..802b525 100644 --- a/src/components/default/auth/buttons/client/sign-out.tsx +++ b/src/components/default/auth/buttons/client/sign-out.tsx @@ -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 @@ -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); } diff --git a/src/components/default/auth/buttons/server/sign-out.tsx b/src/components/default/auth/buttons/server/sign-out.tsx index e69de29..0802e1e 100644 --- a/src/components/default/auth/buttons/server/sign-out.tsx +++ b/src/components/default/auth/buttons/server/sign-out.tsx @@ -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 + +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 ( +
+ + Sign Out + +
+ ); +}; diff --git a/src/components/default/auth/cards/client/forgot-password.tsx b/src/components/default/auth/cards/client/forgot-password.tsx index 0f6f697..3c8e4df 100644 --- a/src/components/default/auth/cards/client/forgot-password.tsx +++ b/src/components/default/auth/cards/client/forgot-password.tsx @@ -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>({ 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(); diff --git a/src/components/default/auth/cards/client/index.tsx b/src/components/default/auth/cards/client/index.tsx new file mode 100644 index 0000000..534acb4 --- /dev/null +++ b/src/components/default/auth/cards/client/index.tsx @@ -0,0 +1,2 @@ +export { ForgotPasswordCard } from './forgot-password'; +export { SignInCard } from './sign-in'; diff --git a/src/components/default/auth/cards/client/sign-in.tsx b/src/components/default/auth/cards/client/sign-in.tsx index c2c24bd..8a60317 100755 --- a/src/components/default/auth/cards/client/sign-in.tsx +++ b/src/components/default/auth/cards/client/sign-in.tsx @@ -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>({ 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, + ), + }} /> @@ -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, + ), + }} /> diff --git a/src/components/default/auth/forms/client/profile/avatar-upload.tsx b/src/components/default/auth/forms/client/profile/avatar-upload.tsx old mode 100644 new mode 100755 index 6e7812b..11d535b --- a/src/components/default/auth/forms/client/profile/avatar-upload.tsx +++ b/src/components/default/auth/forms/client/profile/avatar-upload.tsx @@ -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; @@ -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 = ({ >
{ - 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 = () => { { +type Props = { + headerProps?: ComponentProps<'header'>; + themeToggleProps?: ThemeToggleProps; +}; + +const Header = ({ + headerProps, + themeToggleProps, +}: Props) => { const { isAuthenticated } = useAuth(); const Controls = () => ( @@ -13,9 +22,10 @@ const Header = () => { {isAuthenticated && ( )} @@ -23,46 +33,50 @@ const Header = () => { ); return ( -
-
-
- - {/* Left spacer for perfect centering */} -
-
-
- - {/* Centered logo and title */} -
- - Tech Tracker Logo -

- Next Template -

- -
- - {/* Right-aligned controls */} -
- -
+
+
+ {/* Left spacer for perfect centering */} +
+
+ + {/* Centered logo and title */} +
+ + Tech Tracker Logo +

+ Next Template +

+ +
+ + {/* Right-aligned controls */} +
+ +
+
); diff --git a/src/lib/hooks/context/index.tsx b/src/lib/hooks/context/index.tsx index 474d9dc..3f951a2 100644 --- a/src/lib/hooks/context/index.tsx +++ b/src/lib/hooks/context/index.tsx @@ -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'; diff --git a/src/lib/hooks/context/use-auth.tsx b/src/lib/hooks/context/use-auth.tsx index 0fd58cc..86a3dd2 100755 --- a/src/lib/hooks/context/use-auth.tsx +++ b/src/lib/hooks/context/use-auth.tsx @@ -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) => Promise; refreshUser: () => Promise; }; const AuthContext = createContext(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(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) => { - //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) => { 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 {children}; + return ( + + {children} + + ); }; -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 }; diff --git a/src/lib/hooks/context/use-query.tsx b/src/lib/hooks/context/use-query.tsx index 8226e47..e25fc1e 100644 --- a/src/lib/hooks/context/use-query.tsx +++ b/src/lib/hooks/context/use-query.tsx @@ -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, }, }, }) diff --git a/src/lib/hooks/context/use-theme.tsx b/src/lib/hooks/context/use-theme.tsx index 1c32f58..ea37d15 100644 --- a/src/lib/hooks/context/use-theme.tsx +++ b/src/lib/hooks/context/use-theme.tsx @@ -71,4 +71,4 @@ const ThemeToggle = ({ ); }; -export { ThemeProvider, ThemeToggle }; +export { ThemeProvider, ThemeToggle, type ThemeToggleProps }; diff --git a/src/lib/hooks/use-file-upload.ts b/src/lib/hooks/use-file-upload.ts index 8019cb1..9543f03 100644 --- a/src/lib/hooks/use-file-upload.ts +++ b/src/lib/hooks/use-file-upload.ts @@ -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(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) => { 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) => { 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, diff --git a/src/lib/queries/auth.ts b/src/lib/queries/auth.ts index 96c5413..b56d9a4 100644 --- a/src/lib/queries/auth.ts +++ b/src/lib/queries/auth.ts @@ -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, ) => { @@ -108,7 +115,7 @@ export { forgotPassword, getCurrentUser, getProfile, - getAvatar, + getUserWithStatus, resetPassword, signIn, signInWithApple, diff --git a/src/lib/queries/index.ts b/src/lib/queries/index.ts index 9d95d0f..077a9f6 100644 --- a/src/lib/queries/index.ts +++ b/src/lib/queries/index.ts @@ -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, diff --git a/src/lib/queries/storage.ts b/src/lib/queries/storage.ts old mode 100644 new mode 100755 index 96b36fc..658f575 --- a/src/lib/queries/storage.ts +++ b/src/lib/queries/storage.ts @@ -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, diff --git a/src/utils/supabase/client.ts b/src/utils/supabase/client.ts index 09b1b47..f01dc5e 100644 --- a/src/utils/supabase/client.ts +++ b/src/utils/supabase/client.ts @@ -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( 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 }; diff --git a/src/utils/supabase/index.ts b/src/utils/supabase/index.ts index 189b3a3..12de9d4 100644 --- a/src/utils/supabase/index.ts +++ b/src/utils/supabase/index.ts @@ -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'; diff --git a/src/utils/supabase/server.ts b/src/utils/supabase/server.ts index 24fd6f2..c713782 100644 --- a/src/utils/supabase/server.ts +++ b/src/utils/supabase/server.ts @@ -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( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -26,5 +25,7 @@ export const SupabaseServer = async () => { }, }, }, - ); + ) as SBClientWithDatabase; }; + +export { SupabaseServer }; diff --git a/src/utils/supabase/types.ts b/src/utils/supabase/types.ts index ab07c50..2dc08cc 100644 --- a/src/utils/supabase/types.ts +++ b/src/utils/supabase/types.ts @@ -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; +export type SBClientWithDatabase = SBClient; export type { User } from '@supabase/supabase-js'; @@ -10,6 +10,20 @@ export type Result = { 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 Promise> = T extends (...args: any) => Promise ? R : never;