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!,