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": "^1.1.9",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@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/ssr": "^0.6.1",
|
||||||
"@supabase/supabase-js": "^2.50.0",
|
"@supabase/supabase-js": "^2.50.1",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@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",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -93,12 +94,12 @@
|
|||||||
"eslint-plugin-drizzle": "^0.2.3",
|
"eslint-plugin-drizzle": "^0.2.3",
|
||||||
"eslint-plugin-prettier": "^5.5.0",
|
"eslint-plugin-prettier": "^5.5.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.6.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"tw-animate-css": "^1.3.4",
|
"tw-animate-css": "^1.3.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.34.1"
|
"typescript-eslint": "^8.35.0"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
|
@ -1,15 +1,207 @@
|
|||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
import { type Metadata } from 'next';
|
import { type Metadata } from 'next';
|
||||||
import { Geist } from 'next/font/google';
|
import { Geist } from 'next/font/google';
|
||||||
import {
|
import {
|
||||||
ReactQueryClientProvider,
|
AuthContextProvider,
|
||||||
|
ThemeProvider,
|
||||||
|
TVModeProvider,
|
||||||
|
QueryClientProvider,
|
||||||
} from '@/lib/hooks/context';
|
} from '@/lib/hooks/context';
|
||||||
|
import PlausibleProvider from 'next-plausible';
|
||||||
|
import { Toaster } from '@/components/ui';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const generateMetadata = (): Metadata => {
|
||||||
title: 'Create T3 App',
|
return {
|
||||||
description: 'Generated by create-t3-app',
|
title: {
|
||||||
icons: [{ rel: 'icon', url: '/favicon.ico' }],
|
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({
|
const geist = Geist({
|
||||||
@ -21,10 +213,31 @@ export default function RootLayout({
|
|||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<ReactQueryClientProvider>
|
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
|
||||||
<html lang='en' className={`${geist.variable}`}>
|
<body>
|
||||||
<body>{children}</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>
|
</html>
|
||||||
</ReactQueryClientProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
|
export { AuthContextProvider, useAuth } from './use-auth';
|
||||||
export { useIsMobile } from './use-mobile';
|
export { useIsMobile } from './use-mobile';
|
||||||
export { ReactQueryClientProvider } from './use-query';
|
export { QueryClientProvider, QueryErrorCodes } from './use-query';
|
||||||
export { ThemeProvider, ThemeToggle } from './use-theme';
|
export { ThemeProvider, ThemeToggle } from './use-theme';
|
||||||
export { TVModeProvider, useTVMode, TVToggle } from './use-tv-mode';
|
export { TVModeProvider, useTVMode, TVToggle } from './use-tv-mode';
|
||||||
|
@ -1,11 +1,146 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
} 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'
|
const enum QueryErrorCodes {
|
||||||
import { useState } from 'react'
|
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(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: queryCacheOnError,
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: mutationCacheOnError,
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
// With SSR, we usually want to set some default staleTime
|
|
||||||
// above 0 to avoid refetching immediately on the client
|
|
||||||
staleTime: 60 * 1000,
|
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';
|
'use client';
|
||||||
|
|
||||||
import { createBrowserClient } from '@supabase/ssr';
|
import { createBrowserClient } from '@supabase/ssr';
|
||||||
import type { Database } from '@/utils/supabase/database.types';
|
import type { Database, SupabaseClient } from '@/utils/supabase';
|
||||||
import type { SupabaseClient } from '@/utils/supabase/types';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
let client: SupabaseClient | undefined;
|
let client: SupabaseClient | undefined;
|
||||||
|
|
||||||
const getSupbaseClient = () => {
|
const getSupbaseClient = (): SupabaseClient | undefined => {
|
||||||
if (client) return client;
|
if (client) return client;
|
||||||
client = createBrowserClient<Database>(
|
client = createBrowserClient<Database>(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import 'server-only';
|
import 'server-only';
|
||||||
import { createServerClient } from '@supabase/ssr';
|
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';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
export const useSupabaseServer = async () => {
|
export const useSupabaseServer = async (): Promise<SupabaseClient | undefined> => {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
return createServerClient<Database>(
|
return createServerClient<Database>(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
Reference in New Issue
Block a user