diff --git a/bun.lockb b/bun.lockb index e8fba36..f87b360 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 9826284..9e76725 100644 --- a/package.json +++ b/package.json @@ -48,11 +48,12 @@ "@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.30.0", + "@sentry/nextjs": "^9.31.0", + "@supabase-cache-helpers/postgrest-react-query": "^1.13.4", "@supabase/ssr": "^0.6.1", - "@supabase/supabase-js": "^2.50.0", + "@supabase/supabase-js": "^2.50.1", "@t3-oss/env-nextjs": "^0.12.0", - "@tanstack/react-query": "^5.80.10", + "@tanstack/react-query": "^5.81.2", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -93,12 +94,12 @@ "eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-prettier": "^5.5.0", "postcss": "^8.5.6", - "prettier": "^3.5.3", + "prettier": "^3.6.0", "prettier-plugin-tailwindcss": "^0.6.13", "tailwindcss": "^4.1.10", "tw-animate-css": "^1.3.4", "typescript": "^5.8.3", - "typescript-eslint": "^8.34.1" + "typescript-eslint": "^8.35.0" }, "ct3aMetadata": { "initVersion": "7.39.3" diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2c36ac1..3542f7b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,15 +1,207 @@ import '@/styles/globals.css'; - import { type Metadata } from 'next'; import { Geist } from 'next/font/google'; import { - ReactQueryClientProvider, + AuthContextProvider, + ThemeProvider, + TVModeProvider, + QueryClientProvider, } from '@/lib/hooks/context'; +import PlausibleProvider from 'next-plausible'; +import { Toaster } from '@/components/ui'; +import * as Sentry from '@sentry/nextjs'; -export const metadata: Metadata = { - title: 'Create T3 App', - description: 'Generated by create-t3-app', - icons: [{ rel: 'icon', url: '/favicon.ico' }], +export const generateMetadata = (): Metadata => { + return { + title: { + template: '%s | Next Template', + default: 'Next Template', + }, + description: 'Gib\'s Next Template', + applicationName: 'Next Template', + keywords: 'Next.js, Supabase, Tailwind, Tanstack, React, Query, T3, Gib', + authors: [{ name: 'Gib', url: 'https://gbrown.org' }], + creator: 'Gib Brown', + publisher: 'Gib Brown', + other: { + ...Sentry.getTraceData(), + }, + formatDetection: { + email: false, + address: false, + telephone: false, + }, + robots: { + index: true, + follow: true, + nocache: false, + googleBot: { + index: true, + follow: true, + noimageindex: false, + 'max-video-preview': -1, + 'max-image-preview': 'large', + 'max-snippet': -1, + }, + }, + icons: { + icon: [ + { url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' }, + { url: '/favicon-16.png', type: 'image/png', sizes: '16x16' }, + { url: '/favicon-32.png', type: 'image/png', sizes: '32x32' }, + { url: '/favicon.png', type: 'image/png', sizes: '96x96' }, + { url: '/appicon/icon-36.png', type: 'image/png', sizes: '36x36' }, + { url: '/appicon/icon-48.png', type: 'image/png', sizes: '48x48' }, + { url: '/appicon/icon-72.png', type: 'image/png', sizes: '72x72' }, + { url: '/appicon/icon-96.png', type: 'image/png', sizes: '96x96' }, + { url: '/appicon/icon-144.png', type: 'image/png', sizes: '144x144' }, + { url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' }, + /* + { + url: '/favicon.ico', type: 'image/x-icon', + sizes: 'any', media: '(prefers-color-scheme: dark)' + }, + { + url: '/favicon-16.png', type: 'image/png', + sizes: '16x16', media: '(prefers-color-scheme: dark)' + }, + { + url: '/favicon-32.png', type: 'image/png', + sizes: '32x32', media: '(prefers-color-scheme: dark)' + }, + { + url: '/favicon.png', type: 'image/png', + sizes: '96x96', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-36.png', type: 'image/png', + sizes: '36x36', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-48.png', type: 'image/png', + sizes: '48x48', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-72.png', type: 'image/png', + sizes: '72x72', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-96.png', type: 'image/png', + sizes: '96x96', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-144.png', type: 'image/png', + sizes: '144x144', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon.png', type: 'image/png', + sizes: '192x192', media: '(prefers-color-scheme: dark)' + }, + */ + ], + apple: [ + { url: '/appicon/icon-36.png', type: 'image/png', sizes: '36x36' }, + { url: '/appicon/icon-48.png', type: 'image/png', sizes: '48x48' }, + { url: '/appicon/icon-72.png', type: 'image/png', sizes: '72x72' }, + { url: '/appicon/icon-96.png', type: 'image/png', sizes: '96x96' }, + { url: '/appicon/icon-144.png', type: 'image/png', sizes: '144x144' }, + { url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' }, + /* + { + url: '/appicon/icon-36.png', type: 'image/png', + sizes: '36x36', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-48.png', type: 'image/png', + sizes: '48x48', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-72.png', type: 'image/png', + sizes: '72x72', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-96.png', type: 'image/png', + sizes: '96x96', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-144.png', type: 'image/png', + sizes: '144x144', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon.png', type: 'image/png', + sizes: '192x192', media: '(prefers-color-scheme: dark)' + }, + */ + ], + shortcut: [ + { url: '/appicon/icon-36.png', type: 'image/png', sizes: '36x36' }, + { url: '/appicon/icon-48.png', type: 'image/png', sizes: '48x48' }, + { url: '/appicon/icon-72.png', type: 'image/png', sizes: '72x72' }, + { url: '/appicon/icon-96.png', type: 'image/png', sizes: '96x96' }, + { url: '/appicon/icon-144.png', type: 'image/png', sizes: '144x144' }, + { url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' }, + /* + { + url: '/appicon/icon-36.png', type: 'image/png', + sizes: '36x36', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-48.png', type: 'image/png', + sizes: '48x48', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-72.png', type: 'image/png', + sizes: '72x72', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-96.png', type: 'image/png', + sizes: '96x96', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon-144.png', type: 'image/png', + sizes: '144x144', media: '(prefers-color-scheme: dark)' + }, + { + url: '/appicon/icon.png', type: 'image/png', + sizes: '192x192', media: '(prefers-color-scheme: dark)' + }, + */ + ], + }, + /* + appleWebApp: { + title: 'Tech Tracker', + statusBarStyle: 'black-translucent', + startupImage: [ + '/appicon/apple/splash-768x1024.png', + { + url: '/appicon/apple/splash-1536x2008.png', + media: '(device-width: 768px) and (device-height: 1024px)' + }, + ], + }, + verification: { + google: 'google', + yandex: 'yandex', + yahoo: 'yahoo', + }, + category: 'technology', + appLinks: { + ios: { + url: 'https://git.gbrown.org/next-template', + app_store_id: 'org.gbrown.next-template', + }, + android: { + package: 'https://git.gbrown.org/next-template', + app_name: 'app_t3_template', + }, + web: { + url: 'https://git.gbrown.org/next-template', + should_fallback: true, + }, + }, + */ + }; }; const geist = Geist({ @@ -21,10 +213,31 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - - - {children} - - + + + + + + + + {children} + + + + + + + + ); } diff --git a/src/lib/hooks/context/index.tsx b/src/lib/hooks/context/index.tsx index 080964a..474d9dc 100644 --- a/src/lib/hooks/context/index.tsx +++ b/src/lib/hooks/context/index.tsx @@ -1,4 +1,5 @@ +export { AuthContextProvider, useAuth } from './use-auth'; export { useIsMobile } from './use-mobile'; -export { ReactQueryClientProvider } from './use-query'; +export { QueryClientProvider, QueryErrorCodes } from './use-query'; export { ThemeProvider, ThemeToggle } 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 32e7362..6c76b24 100644 --- a/src/lib/hooks/context/use-auth.tsx +++ b/src/lib/hooks/context/use-auth.tsx @@ -1,11 +1,146 @@ 'use client'; - import React, { type ReactNode, createContext, - useCallback, useContext, useEffect, - useRef, - useState, } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery as useSupabaseQuery } from '@supabase-cache-helpers/postgrest-react-query'; +import { QueryErrorCodes } from '@/lib/hooks/context'; +import { type User, type Profile, useSupabaseClient } 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; + }) => Promise<{ data?: Profile; error?: unknown }>; + refreshUser: () => Promise; +}; + +const AuthContext = createContext(undefined); + +const AuthContextProvider = ({ children }: { children: ReactNode }) => { + const queryClient = useQueryClient(); + const supabase = useSupabaseClient(); + + if (!supabase) throw new Error('Supabase client not found!'); + + // User query + 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; + }, + retry: false, + meta: { errCode: QueryErrorCodes.FETCH_USER_FAILED }, + }); + + // Profile query + const { + data: profileData, + isLoading: profileLoading, + } = useSupabaseQuery( + getProfile(supabase, userData?.id ?? ''), + { + 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 = 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 }, + }); + + 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'] }); + } + }); + return () => subscription.unsubscribe(); + }, [supabase.auth, queryClient]); + + const handleUpdateProfile = async (data: Partial) => { + try { + const result = await updateProfileMutation.mutateAsync(data); + return { data: result }; + } catch (error) { + return { error }; + } + }; + + const refreshUser = async () => { + await queryClient.invalidateQueries({ queryKey: ['auth'] }); + }; + + const value: AuthContextType = { + user: userData ?? null, + profile: profileData ?? null, + avatar: avatarData ?? null, + loading: userLoading || profileLoading, + isAuthenticated: !!userData && !userError, + updateProfile: handleUpdateProfile, + refreshUser, + }; + + return {children}; +}; + +const useAuth = () => { + const context = useContext(AuthContext); + if (!context || context === undefined) { + 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 94659a4..ae75796 100644 --- a/src/lib/hooks/context/use-query.tsx +++ b/src/lib/hooks/context/use-query.tsx @@ -1,22 +1,78 @@ -'use client' +'use client'; +import { + QueryClient, + QueryClientProvider as ReactQueryClientProvider, + QueryCache, + MutationCache, +} from '@tanstack/react-query'; +import { useState } from 'react'; +import { toast } from 'sonner'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useState } from 'react' +const enum QueryErrorCodes { + FETCH_USER_FAILED = 'FETCH_USER_FAILED', + FETCH_PROFILE_FAILED = 'FETCH_PROFILE_FAILED', + FETCH_AVATAR_FAILED = 'FETCH_AVATAR_FAILED', + UPDATE_PROFILE_FAILED = 'UPDATE_PROFILE_FAILED', + UPLOAD_PHOTO_FAILED = 'UPLOAD_PHOTO_FAILED', +}; -const ReactQueryClientProvider = ({ children }: { children: React.ReactNode }) => { +const queryCacheOnError = (error: unknown, query: any) => { + const errorMessage = error instanceof Error ? error.message : error as string; + switch (query.meta?.errCode) { + case QueryErrorCodes.FETCH_USER_FAILED: + toast.error('Failed to fetch user!'); + break; + case QueryErrorCodes.FETCH_PROFILE_FAILED: + toast.error('Failed to fetch profile!'); + break; + case QueryErrorCodes.FETCH_AVATAR_FAILED: + console.warn('Failed to fetch avatar. User may not have one!') + break; + default: + console.error('Query error:', error); + break; + } +}; + +const mutationCacheOnError = ( + error: unknown, + variables: unknown, + context: unknown, + mutation: any, +) => { + const errorMessage = error instanceof Error ? error.message : error as string; + switch (mutation.meta?.errCode) { + case QueryErrorCodes.UPDATE_PROFILE_FAILED: + toast.error(`Failed to update user profile: ${errorMessage}`) + break; + case QueryErrorCodes.UPLOAD_PHOTO_FAILED: + toast.error(`Failed to upload photo: ${errorMessage}`) + break; + default: + console.error('Mutation error:', error); + break; + } +}; + + +const QueryClientProvider = ({ children }: { children: React.ReactNode }) => { const [queryClient] = useState( () => new QueryClient({ + queryCache: new QueryCache({ + onError: queryCacheOnError, + }), + mutationCache: new MutationCache({ + onError: mutationCacheOnError, + }), defaultOptions: { queries: { - // With SSR, we usually want to set some default staleTime - // above 0 to avoid refetching immediately on the client staleTime: 60 * 1000, }, }, }) ) - return {children} + return {children} }; -export { ReactQueryClientProvider }; +export { QueryClientProvider, QueryErrorCodes }; diff --git a/src/lib/hooks/index.ts b/src/lib/hooks/index.ts new file mode 100644 index 0000000..0ce7e35 --- /dev/null +++ b/src/lib/hooks/index.ts @@ -0,0 +1 @@ +export { useFileUpload } from './use-file-upload'; diff --git a/src/lib/hooks/use-file-upload.ts b/src/lib/hooks/use-file-upload.ts new file mode 100644 index 0000000..69a6aa0 --- /dev/null +++ b/src/lib/hooks/use-file-upload.ts @@ -0,0 +1,82 @@ +'use client'; +import { useState, useRef } from 'react'; +import { uploadFile, resizeImage } from '@/lib/queries'; +import { toast } from 'sonner'; +import { useAuth } from '@/lib/hooks/context'; +import { type SupabaseClient } from '@/utils/supabase'; + +type UploadToStorageProps = { + client: SupabaseClient; + file: File; + bucket: string; + resize?: false | { + maxWidth?: number; + maxHeight?: number; + quality?: number; + }, + replace?: false | string, +}; + +const useFileUpload = () => { + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + const { profile, isAuthenticated } = useAuth(); + + const uploadToStorage = async ({ + client, + file, + bucket, + resize = false, + replace = false, + }: UploadToStorageProps) => { + try { + if (!isAuthenticated) + throw new Error('User is not authenticated!'); + setIsUploading(true); + let fileToUpload = file; + if (resize && file.type.startsWith('image/')) + fileToUpload = await resizeImage({file, options: resize}); + if (replace) { + const { data, error} = await uploadFile({ + client, + bucket, + path: replace, + file: fileToUpload, + options: { + contentType: file.type, + upsert: true, + }, + }); + if (error) throw error; + return data + } else { + const fileExt = file.name.split('.').pop(); + const fileName = `${Date.now()}-${profile?.id}.${fileExt}`; + const { data, error } = await uploadFile({ + client, + bucket, + path: fileName, + file: fileToUpload, + options: { + contentType: file.type, + }, + }); + if (error) throw error; + return data; + } + } catch (error) { + toast.error(`Error uploading file: ${error as string}`); + return error; + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + return { + isUploading, + fileInputRef, + uploadToStorage, + }; +}; + +export { useFileUpload }; diff --git a/src/lib/queries/auth.ts b/src/lib/queries/auth.ts new file mode 100644 index 0000000..f714c83 --- /dev/null +++ b/src/lib/queries/auth.ts @@ -0,0 +1,118 @@ +import { type SupabaseClient, type Profile } from '@/utils/supabase'; +import { getSignedUrl } from '@/lib/queries'; + +const signUp = (client: SupabaseClient, formData: FormData) => { + const full_name = formData.get('name') as string; + const email = formData.get('email') as string; + const password = formData.get('password') as string; + const origin = process.env.NEXT_PUBLIC_SITE_URL!; + return client.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${origin}/auth/callback`, + data: { + full_name, + email, + provider: 'email', + } + } + }); +}; + +const signIn = (client: SupabaseClient, 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 origin = process.env.NEXT_PUBLIC_SITE_URL!; + return client.auth.signInWithOAuth({ + provider: 'azure', + options: { + scopes: 'openid profile email offline_access', + redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`, + }, + }); +}; + +const signInWithApple = (client: SupabaseClient) => { + const origin = process.env.NEXT_PUBLIC_SITE_URL!; + return client.auth.signInWithOAuth({ + provider: 'apple', + options: { + scopes: 'openid profile email offline_access', + redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`, + }, + }); +}; + +const forgotPassword = (client: SupabaseClient, formData: FormData) => { + const email = formData.get('email') as string; + const origin = process.env.NEXT_PUBLIC_SITE_URL!; + return client.auth.resetPasswordForEmail(email, { + redirectTo: `${origin}/auth/callback?redirect_to=/profile`, + }); +}; + +const resetPassword = (client: SupabaseClient, formData: FormData) => { + const password = formData.get('password') as string; + return client.auth.updateUser({ password }); +}; + +const signOut = (client: SupabaseClient) => { + return client.auth.signOut(); +} + +const getCurrentUser = (client: SupabaseClient) => { + return client.auth.getUser(); +}; + +const getProfile = (client: SupabaseClient, userId: string) => { + return client + .from(`profiles`) + .select(`*`) + .eq(`id`, userId) + .single(); +}; + +const getAvatar = (client: SupabaseClient, avatarUrl: string) => { + return getSignedUrl({ + client, + bucket: 'avatars', + path: avatarUrl, + seconds: 3600, + transform: { + width: 128, + height: 128, + }, + }); +}; + +const updateProfile = ( + client: SupabaseClient, + userId: string, + updates: Partial, +) => { + return client + .from(`profiles`) + .update(updates) + .eq(`id`, userId) + .select() + .single(); +}; + +export { + forgotPassword, + getCurrentUser, + getProfile, + getAvatar, + resetPassword, + signIn, + signInWithApple, + signInWithMicrosoft, + signOut, + signUp, + updateProfile +}; diff --git a/src/lib/queries/index.ts b/src/lib/queries/index.ts new file mode 100644 index 0000000..9d95d0f --- /dev/null +++ b/src/lib/queries/index.ts @@ -0,0 +1,22 @@ +export { + forgotPassword, + getCurrentUser, + getProfile, + getAvatar, + resetPassword, + signIn, + signInWithApple, + signInWithMicrosoft, + signOut, + signUp, + updateProfile +} from './auth'; +export { + deleteFiles, + getPublicUrl, + getSignedUrl, + listFiles, + resizeImage, + uploadFile, + updateFile +} from './storage'; diff --git a/src/lib/queries/storage.ts b/src/lib/queries/storage.ts new file mode 100644 index 0000000..96b36fc --- /dev/null +++ b/src/lib/queries/storage.ts @@ -0,0 +1,178 @@ +import { type SupabaseClient, type Profile } from '@/utils/supabase'; + +type GetStorageProps = { + client: SupabaseClient; + bucket: string; + path: string; + seconds?: number; + transform?: { + width?: number; + height?: number; + quality?: number; + format?: 'origin'; + resize?: 'cover' | 'contain' | 'fill'; + }; + download?: boolean | string; +}; + +type UploadStorageProps = { + client: SupabaseClient; + bucket: string; + path: string; + file: File; + options?: { + cacheControl?: string; + contentType?: string; + upsert?: boolean; + }; +}; + +type ResizeImageProps = { + file: File; + options?: { + maxWidth?: number; + maxHeight?: number; + quality?: number; + }; +}; + +const getPublicUrl = ({ + client, + bucket, + path, + transform = {}, + download = false, +}: GetStorageProps) => { + return client.storage + .from(bucket) + .getPublicUrl(path, { download, transform}); +}; + +const getSignedUrl = ({ + client, + bucket, + path, + seconds = 3600, + transform = {}, + download = false, +}: GetStorageProps) => { + return client.storage + .from(bucket) + .createSignedUrl(path, seconds, { download, transform}); +}; + +const uploadFile = ({ + client, + bucket, + path, + file, + options = {}, +}: UploadStorageProps) => { + return client.storage + .from(bucket) + .upload(path, file, options); +}; + +const updateFile = ({ + client, + bucket, + path, + file, + options = { + upsert: true, + }, +}: UploadStorageProps) => { + return client.storage + .from(bucket) + .update(path, file, options); +}; + +const deleteFiles = ({ + client, + bucket, + path, +}: { + client: SupabaseClient; + bucket: string; + path: string[]; + }) => { + return client.storage.from(bucket).remove(path); +}; + +const listFiles = ({ + client, + bucket, + path = '', + options = {}, +}: { + client: SupabaseClient; + bucket: string; + path?: string; + options?: { + limit?: number; + offset?: number; + sortBy?: { column: string, order: 'asc' | 'desc' }; + }; +}) => { + return client.storage.from(bucket).list(path, options); +}; + +const resizeImage = async ({ + file, + options = { + maxWidth: 800, + maxHeight: 800, + quality: 0.8, + }, +}: ResizeImageProps): Promise => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + const img = new Image(); + img.src = event.target?.result as string; + img.onload = () => { + let width = img.width; + let height = img.height; + if (width > height) { + if (width > (options.maxWidth ?? 800)) { + height = Math.round((height * (options.maxWidth ?? 800)) / width); + width = options.maxWidth ?? 800; + } + } else if (height > (options.maxHeight ?? 800)) { + width = Math.round((width * (options.maxHeight ?? 800)) / height); + height = options.maxHeight ?? 800; + } + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0, width, height); + canvas.toBlob( + (blob) => { + if (!blob) return; + const resizedFile = new File([blob], file.name, { + type: 'imgage/jpeg', + lastModified: Date.now(), + }); + resolve(resizedFile); + }, + 'image/jpeg', + (options.quality && options.quality < 1 && options.quality > 0) + ? options.quality + : 0.8, + ); + }; + }; + }); +}; + +export { + deleteFiles, + getPublicUrl, + getSignedUrl, + listFiles, + resizeImage, + uploadFile, + updateFile +}; diff --git a/src/utils/supabase/client.ts b/src/utils/supabase/client.ts index 6e38d28..09b1b47 100644 --- a/src/utils/supabase/client.ts +++ b/src/utils/supabase/client.ts @@ -1,13 +1,12 @@ 'use client'; import { createBrowserClient } from '@supabase/ssr'; -import type { Database } from '@/utils/supabase/database.types'; -import type { SupabaseClient } from '@/utils/supabase/types'; +import type { Database, SupabaseClient } from '@/utils/supabase'; import { useMemo } from 'react'; let client: SupabaseClient | undefined; -const getSupbaseClient = () => { +const getSupbaseClient = (): SupabaseClient | undefined => { if (client) return client; client = createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, diff --git a/src/utils/supabase/server.ts b/src/utils/supabase/server.ts index 6b9d69c..72fd439 100644 --- a/src/utils/supabase/server.ts +++ b/src/utils/supabase/server.ts @@ -2,10 +2,10 @@ import 'server-only'; import { createServerClient } from '@supabase/ssr'; -import type { Database } from '@/utils/supabase/database.types'; +import type { Database, SupabaseClient } from '@/utils/supabase'; import { cookies } from 'next/headers'; -export const useSupabaseServer = async () => { +export const useSupabaseServer = async (): Promise => { const cookieStore = await cookies(); return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!,