Making progress on rewrite. Recreating queries and hooks now.
This commit is contained in:
11
package.json
11
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"
|
||||
|
@ -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 (
|
||||
<ReactQueryClientProvider>
|
||||
<html lang='en' className={`${geist.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
</ReactQueryClientProvider>
|
||||
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider>
|
||||
<AuthContextProvider>
|
||||
<PlausibleProvider
|
||||
domain='nexttemplate.gbrown.org'
|
||||
customDomain='https://plausible.gbrown.org'
|
||||
trackOutboundLinks
|
||||
selfHosted
|
||||
>
|
||||
<TVModeProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</TVModeProvider>
|
||||
</PlausibleProvider>
|
||||
</AuthContextProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(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<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 },
|
||||
});
|
||||
|
||||
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<Profile>) => {
|
||||
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
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 };
|
||||
|
@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
return <ReactQueryClientProvider client={queryClient}>{children}</ReactQueryClientProvider>
|
||||
};
|
||||
|
||||
export { ReactQueryClientProvider };
|
||||
export { QueryClientProvider, QueryErrorCodes };
|
||||
|
1
src/lib/hooks/index.ts
Normal file
1
src/lib/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useFileUpload } from './use-file-upload';
|
82
src/lib/hooks/use-file-upload.ts
Normal file
82
src/lib/hooks/use-file-upload.ts
Normal file
@ -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<HTMLInputElement | null>(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 };
|
118
src/lib/queries/auth.ts
Normal file
118
src/lib/queries/auth.ts
Normal file
@ -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<Profile>,
|
||||
) => {
|
||||
return client
|
||||
.from(`profiles`)
|
||||
.update(updates)
|
||||
.eq(`id`, userId)
|
||||
.select()
|
||||
.single();
|
||||
};
|
||||
|
||||
export {
|
||||
forgotPassword,
|
||||
getCurrentUser,
|
||||
getProfile,
|
||||
getAvatar,
|
||||
resetPassword,
|
||||
signIn,
|
||||
signInWithApple,
|
||||
signInWithMicrosoft,
|
||||
signOut,
|
||||
signUp,
|
||||
updateProfile
|
||||
};
|
22
src/lib/queries/index.ts
Normal file
22
src/lib/queries/index.ts
Normal file
@ -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';
|
178
src/lib/queries/storage.ts
Normal file
178
src/lib/queries/storage.ts
Normal file
@ -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<File> => {
|
||||
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
|
||||
};
|
@ -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<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
|
@ -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<SupabaseClient | undefined> => {
|
||||
const cookieStore = await cookies();
|
||||
return createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
|
Reference in New Issue
Block a user