More stuff
This commit is contained in:
12
package.json
12
package.json
@@ -48,10 +48,10 @@
|
|||||||
"@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.35.0",
|
"@sentry/nextjs": "^9.36.0",
|
||||||
"@supabase-cache-helpers/postgrest-react-query": "^1.13.4",
|
"@supabase-cache-helpers/postgrest-react-query": "^1.13.4",
|
||||||
"@supabase/ssr": "^0.6.1",
|
"@supabase/ssr": "^0.6.1",
|
||||||
"@supabase/supabase-js": "^2.50.3",
|
"@supabase/supabase-js": "^2.50.4",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"@tanstack/react-query": "^5.81.5",
|
"@tanstack/react-query": "^5.81.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
@@ -74,18 +74,18 @@
|
|||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
"recharts": "^3.0.2",
|
"recharts": "^3.1.0",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.75"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^20.19.4",
|
"@types/node": "^20.19.6",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"drizzle-kit": "^0.30.6",
|
"drizzle-kit": "^0.30.6",
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.35.1"
|
"typescript-eslint": "^8.36.0"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
|
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
51
src/app/global-error.tsx
Normal file
51
src/app/global-error.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import '@/styles/globals.css';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { AuthContextProvider, ThemeProvider } from '@/lib/hooks/context';
|
||||||
|
import { Button, Toaster } from '@/components/ui';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import NextError from 'next/error';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
const fontSans = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-sans',
|
||||||
|
});
|
||||||
|
|
||||||
|
type GlobalErrorProps = {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||||
|
useEffect(() => { Sentry.captureException(error) }, [error]);
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang='en'
|
||||||
|
className={cn('font-sans antialiased', fontSans.variable)}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<body>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute='class'
|
||||||
|
defaultTheme='system'
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<AuthContextProvider>
|
||||||
|
<main className='min-h-screen flex flex-col items-center'>
|
||||||
|
<NextError statusCode={0} />
|
||||||
|
{reset !== undefined && (
|
||||||
|
<Button onClick={() => reset()}>Try Again</Button>
|
||||||
|
)}
|
||||||
|
<Toaster />
|
||||||
|
</main>
|
||||||
|
</AuthContextProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default GlobalError;
|
@@ -11,6 +11,7 @@ import {
|
|||||||
import PlausibleProvider from 'next-plausible';
|
import PlausibleProvider from 'next-plausible';
|
||||||
import { Toaster } from '@/components/ui';
|
import { Toaster } from '@/components/ui';
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import Header from '@/components/default/layout/header';
|
||||||
|
|
||||||
export const generateMetadata = (): Metadata => {
|
export const generateMetadata = (): Metadata => {
|
||||||
return {
|
return {
|
||||||
@@ -208,7 +209,7 @@ export const generateMetadata = (): Metadata => {
|
|||||||
const fontSans = Inter({
|
const fontSans = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
variable: '--font-sans',
|
variable: '--font-sans',
|
||||||
})
|
});
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -235,6 +236,7 @@ export default function RootLayout({
|
|||||||
selfHosted
|
selfHosted
|
||||||
>
|
>
|
||||||
<TVModeProvider>
|
<TVModeProvider>
|
||||||
|
<Header />
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</TVModeProvider>
|
</TVModeProvider>
|
||||||
@@ -245,4 +247,4 @@ export default function RootLayout({
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
@@ -1,18 +1,9 @@
|
|||||||
import { SignInCard } from '@/components/default/auth/cards/client/sign-in';
|
import { SignInCard } from '@/components/default/auth/cards/client/sign-in';
|
||||||
import { ForgotPasswordCard } from '@/components/default/auth/cards/client/forgot-password';
|
|
||||||
import { ThemeToggle } from '@/lib/hooks/context';
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<main className='flex min-h-screen flex-col items-center justify-center'>
|
<main className='flex flex-col items-center min-h-[90vh]'>
|
||||||
<div className='container flex flex-col items-center justify-center gap-12 px-4 py-16'>
|
<SignInCard containerProps={{className: 'my-auto'}}/>
|
||||||
<h1 className='text-5xl font-extrabold tracking-tight text-white sm:text-[5rem]'>
|
|
||||||
Create <span className='text-[hsl(280,100%,70%)]'>T3</span> App
|
|
||||||
</h1>
|
|
||||||
<ThemeToggle />
|
|
||||||
<ForgotPasswordCard />
|
|
||||||
<SignInCard/>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -10,13 +10,26 @@ import {
|
|||||||
import { Loader2, Pencil, Upload } from 'lucide-react';
|
import { Loader2, Pencil, Upload } from 'lucide-react';
|
||||||
import type { ComponentProps, ChangeEvent } from 'react';
|
import type { ComponentProps, ChangeEvent } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type AvatarUploadProps = {
|
type AvatarUploadProps = {
|
||||||
onAvatarUploaded: (path: string) => Promise<void>;
|
onAvatarUploaded: (path: string) => Promise<void>;
|
||||||
|
cardProps?: ComponentProps<typeof Card>;
|
||||||
|
cardContentProps?: ComponentProps<typeof CardContent>;
|
||||||
|
containerProps?: ComponentProps<'div'>;
|
||||||
|
basedAvatarProps?: ComponentProps<typeof BasedAvatar>;
|
||||||
|
iconProps?: ComponentProps<typeof Upload>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvatarUpload = ({
|
export const AvatarUpload = ({
|
||||||
onAvatarUploaded,
|
onAvatarUploaded,
|
||||||
|
cardProps,
|
||||||
|
cardContentProps,
|
||||||
|
containerProps,
|
||||||
|
basedAvatarProps,
|
||||||
|
iconProps = {
|
||||||
|
size: 32,
|
||||||
|
},
|
||||||
}: AvatarUploadProps) => {
|
}: AvatarUploadProps) => {
|
||||||
const { profile, isAuthenticated } = useAuth();
|
const { profile, isAuthenticated } = useAuth();
|
||||||
const { isUploading, fileInputRef, uploadAvatarMutation } = useFileUpload();
|
const { isUploading, fileInputRef, uploadAvatarMutation } = useFileUpload();
|
||||||
@@ -39,8 +52,8 @@ export const AvatarUpload = ({
|
|||||||
if (!file.type.startsWith('image/')) throw new Error('File is not an image!');
|
if (!file.type.startsWith('image/')) throw new Error('File is not an image!');
|
||||||
if (file.size > 8 * 1024 * 1024) throw new Error('File is too large!');
|
if (file.size > 8 * 1024 * 1024) throw new Error('File is too large!');
|
||||||
|
|
||||||
const fileExt = file.name.split('.').pop();
|
const avatarPath = profile?.avatar_url ??
|
||||||
const avatarPath = profile?.avatar_url ?? profile?.id;
|
`${profile?.id}.${file.name.split('.').pop()}`;
|
||||||
|
|
||||||
const avatarUrl = await uploadAvatarMutation.mutateAsync({
|
const avatarUrl = await uploadAvatarMutation.mutateAsync({
|
||||||
client,
|
client,
|
||||||
@@ -61,12 +74,78 @@ export const AvatarUpload = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card
|
||||||
<CardContent>
|
{...cardProps}
|
||||||
<div>
|
className={cn('', cardProps?.className)}
|
||||||
|
>
|
||||||
|
<CardContent
|
||||||
|
{...cardContentProps}
|
||||||
|
className={cn('flex flex-col items-center', cardContentProps?.className)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...containerProps}
|
||||||
|
className={cn(
|
||||||
|
'relative group cursor-pointer mb-4',
|
||||||
|
containerProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BasedAvatar
|
||||||
|
{...basedAvatarProps}
|
||||||
|
src={profile?.avatar_url}
|
||||||
|
fullName={profile?.full_name}
|
||||||
|
className={cn('h-32, w-32', basedAvatarProps?.className)}
|
||||||
|
fallbackProps={{ className: 'text-4xl font-semibold' }}
|
||||||
|
userIconProps={{ size: 100 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absoloute inset-0 rounded-full bg-black/0\
|
||||||
|
group-hover:bg-black/50 transition-all flex\
|
||||||
|
items-center justify-center'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
{...iconProps}
|
||||||
|
className={cn('text-white opacity-0 group-hover:opacity-100\
|
||||||
|
transition-opacity', iconProps?.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-1 transition-all flex\
|
||||||
|
items-end justify-end',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Pencil
|
||||||
|
{...iconProps}
|
||||||
|
className={cn(
|
||||||
|
'text-white opacity-100 group-hover:opacity-0\
|
||||||
|
transition-opacity', iconProps?.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type='file'
|
||||||
|
accept='image/*'
|
||||||
|
className={cn('hidden')}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
{isUploading && (
|
||||||
|
<div className={cn('flex items-center text-sm text-gray-500 mt-2')}>
|
||||||
|
<Loader2 className={cn('h-4 w-4 mr-2 animate-spin')} />
|
||||||
|
Uploading...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<p className={cn('text-sm text-muted-foreground mt-2')}>
|
||||||
|
Sign in to upload an avatar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
76
src/components/default/layout/header/avatar-dropdown.tsx
Normal file
76
src/components/default/layout/header/avatar-dropdown.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
BasedAvatar,
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { useAuth } from '@/lib/hooks/context';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { signOut } from '@/lib/queries';
|
||||||
|
import { useSupabaseClient } from '@/utils/supabase';
|
||||||
|
|
||||||
|
export const AvatarDropdown = () => {
|
||||||
|
const { profile, avatar, refreshUser } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const client = useSupabaseClient();
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
try {
|
||||||
|
if (!client) throw new Error('Supabase client not found!');
|
||||||
|
const { error } = await signOut(client);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
await refreshUser();
|
||||||
|
router.push('/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<BasedAvatar
|
||||||
|
src={avatar}
|
||||||
|
fullName={profile?.full_name}
|
||||||
|
className='lg:h-12 lg:w-12 my-auto'
|
||||||
|
fallbackProps={{ className: 'text-xl font-semibold' }}
|
||||||
|
userIconProps={{ size: 32 }}
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
{(profile?.full_name ?? profile?.email) && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className='font-bold'>
|
||||||
|
{profile.full_name?.trim() ?? profile.email?.trim()}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href='/profile'
|
||||||
|
className='w-full justify-center cursor-pointer'
|
||||||
|
>
|
||||||
|
Edit Profile
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className='h-[2px]' />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className='w-full justify-center cursor-pointer'
|
||||||
|
variant='ghost'
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
71
src/components/default/layout/header/index.tsx
Normal file
71
src/components/default/layout/header/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ThemeToggle, useAuth } from '@/lib/hooks/context';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { AvatarDropdown } from './avatar-dropdown';
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const Controls = () => (
|
||||||
|
<div className='flex flex-row items-center'>
|
||||||
|
<ThemeToggle
|
||||||
|
size={1.2}
|
||||||
|
buttonProps={{
|
||||||
|
className: 'mr-4 py-5',
|
||||||
|
variant: 'secondary',
|
||||||
|
size: 'sm'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isAuthenticated && ( <AvatarDropdown /> )}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className='w-full min-h-[10vh]'>
|
||||||
|
<div className='container mx-auto px-4 md:px-6 lg:px-20'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
|
||||||
|
{/* Left spacer for perfect centering */}
|
||||||
|
<div className='flex flex-1 justify-start'>
|
||||||
|
<div className='sm:w-[120px] md:w-[160px]' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Centered logo and title */}
|
||||||
|
<div className='flex-shrink-0'>
|
||||||
|
<Link
|
||||||
|
href='/'
|
||||||
|
scroll={false}
|
||||||
|
className='flex flex-row items-center justify-center px-4'
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src='/favicon.png'
|
||||||
|
alt='Tech Tracker Logo'
|
||||||
|
width={100}
|
||||||
|
height={100}
|
||||||
|
className='max-w-[40px] md:max-w-[120px]'
|
||||||
|
/>
|
||||||
|
<h1
|
||||||
|
className='title-text text-sm md:text-4xl lg:text-8xl
|
||||||
|
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground
|
||||||
|
dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
|
||||||
|
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
|
||||||
|
>
|
||||||
|
Next Template
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right-aligned controls */}
|
||||||
|
<div className='flex-1 flex justify-end'>
|
||||||
|
<Controls />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
||||||
|
export default Header;
|
@@ -1,24 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import * as React from 'react';
|
|
||||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
import { User } from 'lucide-react';
|
import { User } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AvatarImage } from '@/components/ui/avatar';
|
import { AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { type ComponentProps } from 'react';
|
||||||
|
|
||||||
type BasedAvatarProps = React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
type BasedAvatarProps = ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
imageClassName?: string;
|
imageProps?: Omit<ComponentProps<typeof AvatarImage>, 'data-slot'>;
|
||||||
fallbackClassName?: string;
|
fallbackProps?: ComponentProps<typeof AvatarPrimitive.Fallback>;
|
||||||
userIconSize?: number;
|
userIconProps?: ComponentProps<typeof User>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BasedAvatar = ({
|
const BasedAvatar = ({
|
||||||
src = null,
|
src = null,
|
||||||
fullName = null,
|
fullName = null,
|
||||||
imageClassName = '',
|
imageProps,
|
||||||
fallbackClassName = '',
|
fallbackProps,
|
||||||
userIconSize = 32,
|
userIconProps = {
|
||||||
|
size: 32,
|
||||||
|
},
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: BasedAvatarProps) => {
|
}: BasedAvatarProps) => {
|
||||||
@@ -32,13 +34,14 @@ const BasedAvatar = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{src ? (
|
{src ? (
|
||||||
<AvatarImage src={src} className={imageClassName} />
|
<AvatarImage {...imageProps} src={src} className={imageProps?.className} />
|
||||||
) : (
|
) : (
|
||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
|
{...fallbackProps}
|
||||||
data-slot='avatar-fallback'
|
data-slot='avatar-fallback'
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||||
fallbackClassName,
|
fallbackProps?.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{fullName ? (
|
{fullName ? (
|
||||||
@@ -48,7 +51,7 @@ const BasedAvatar = ({
|
|||||||
.join('')
|
.join('')
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
) : (
|
) : (
|
||||||
<User size={userIconSize} />
|
<User {...userIconProps} className={cn('', userIconProps?.className)} />
|
||||||
)}
|
)}
|
||||||
</AvatarPrimitive.Fallback>
|
</AvatarPrimitive.Fallback>
|
||||||
)}
|
)}
|
||||||
|
56
src/lib/hooks/context/use-auth.tsx
Normal file → Executable file
56
src/lib/hooks/context/use-auth.tsx
Normal file → Executable file
@@ -6,7 +6,10 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useQuery as useSupabaseQuery } from '@supabase-cache-helpers/postgrest-react-query';
|
import {
|
||||||
|
useQuery as useSupabaseQuery,
|
||||||
|
useUpdateMutation,
|
||||||
|
} from '@supabase-cache-helpers/postgrest-react-query';
|
||||||
import { QueryErrorCodes } from '@/lib/hooks/context';
|
import { QueryErrorCodes } from '@/lib/hooks/context';
|
||||||
import { type User, type Profile, useSupabaseClient } from '@/utils/supabase';
|
import { type User, type Profile, useSupabaseClient } from '@/utils/supabase';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -27,7 +30,8 @@ type AuthContextType = {
|
|||||||
full_name?: string;
|
full_name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
}) => Promise<{ data?: Profile; error?: unknown }>;
|
provider?: string;
|
||||||
|
}) => Promise<{ data?: Profile | null; error?: { message: string } | null }>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +71,7 @@ const AuthContextProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Avatar query
|
// Avatar query
|
||||||
const {
|
const {
|
||||||
data: avatarData,
|
data: avatarData,
|
||||||
@@ -83,20 +88,31 @@ const AuthContextProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update profile mutation
|
// Update profile mutation
|
||||||
const updateProfileMutation = useMutation({
|
const updateProfileMutation = useUpdateMutation(
|
||||||
mutationFn: async (updates: Partial<Profile>) => {
|
supabase.from('profiles'),
|
||||||
if (!userData?.id) throw new Error('User ID is required!');
|
['id'],
|
||||||
const result = await updateProfileQuery(supabase, userData.id, updates);
|
'*',
|
||||||
if (result.error) throw result.error;
|
{
|
||||||
return result.data;
|
onSuccess: () => toast.success('Profile updated successfully!'),
|
||||||
|
onError: (error) => toast.error(`Failed to update profile: ${error.message}`),
|
||||||
|
meta: { errCode: QueryErrorCodes.UPDATE_PROFILE_FAILED },
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth'] })
|
|
||||||
.catch((error) => console.error('Error invalidating auth queries:', error));
|
//const updateProfileMutation = useMutation({
|
||||||
toast.success('Profile updated successfully!');
|
//mutationFn: async (updates: Partial<Profile>) => {
|
||||||
},
|
//if (!userData?.id) throw new Error('User ID is required!');
|
||||||
meta: { errCode: QueryErrorCodes.UPDATE_PROFILE_FAILED },
|
//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(() => {
|
useEffect(() => {
|
||||||
const {
|
const {
|
||||||
@@ -110,11 +126,15 @@ const AuthContextProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}, [supabase.auth, queryClient]);
|
}, [supabase.auth, queryClient]);
|
||||||
|
|
||||||
const handleUpdateProfile = async (data: Partial<Profile>) => {
|
const handleUpdateProfile = async (data: Partial<Profile>) => {
|
||||||
|
if (!userData?.id) throw new Error('User ID is required!');
|
||||||
try {
|
try {
|
||||||
const result = await updateProfileMutation.mutateAsync(data);
|
const result = await updateProfileMutation.mutateAsync({
|
||||||
return { data: result };
|
...data,
|
||||||
|
id: userData.id,
|
||||||
|
});
|
||||||
|
return { data: result, error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error };
|
return { data: null, error };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -65,6 +65,7 @@ const QueryClientProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
}),
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -25,17 +25,12 @@ const ThemeProvider = ({
|
|||||||
|
|
||||||
type ThemeToggleProps = {
|
type ThemeToggleProps = {
|
||||||
size?: number;
|
size?: number;
|
||||||
buttonClassName?: ComponentProps<typeof Button>['className'];
|
buttonProps?: Omit<ComponentProps<typeof Button>, 'onClick'>;
|
||||||
buttonProps?: Omit<ComponentProps<typeof Button>, 'className' | 'onClick'>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThemeToggle = ({
|
const ThemeToggle = ({
|
||||||
size = 1,
|
size = 1,
|
||||||
buttonClassName,
|
buttonProps,
|
||||||
buttonProps = {
|
|
||||||
variant: 'outline',
|
|
||||||
size: 'icon',
|
|
||||||
},
|
|
||||||
}: ThemeToggleProps) => {
|
}: ThemeToggleProps) => {
|
||||||
|
|
||||||
const { setTheme, resolvedTheme } = useTheme();
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
@@ -45,7 +40,7 @@ const ThemeToggle = ({
|
|||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
<Button className={buttonClassName} {...buttonProps}>
|
<Button {...buttonProps}>
|
||||||
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
|
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -58,9 +53,11 @@ const ThemeToggle = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={cn('cursor-pointer', buttonClassName)}
|
variant='outline'
|
||||||
onClick={toggleTheme}
|
size='icon'
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={cn('cursor-pointer', buttonProps?.className)}
|
||||||
>
|
>
|
||||||
<Sun
|
<Sun
|
||||||
style={{ height: `${size}rem`, width: `${size}rem` }}
|
style={{ height: `${size}rem`, width: `${size}rem` }}
|
||||||
|
@@ -3,7 +3,7 @@ import { useState, useRef } from 'react';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getSignedUrl, resizeImage, uploadFile } from '@/lib/queries';
|
import { getSignedUrl, resizeImage, uploadFile } from '@/lib/queries';
|
||||||
import { useAuth, QueryErrorCodes } from '@/lib/hooks/context';
|
import { useAuth, QueryErrorCodes } from '@/lib/hooks/context';
|
||||||
import type { SupabaseClient, Result, User, Profile } from '@/utils/supabase';
|
import type { SupabaseClient, User, Profile } from '@/utils/supabase';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
type UploadToStorageProps = {
|
type UploadToStorageProps = {
|
||||||
|
@@ -100,6 +100,7 @@ const updateProfile = (
|
|||||||
.update(updates)
|
.update(updates)
|
||||||
.eq(`id`, userId)
|
.eq(`id`, userId)
|
||||||
.select()
|
.select()
|
||||||
|
.throwOnError()
|
||||||
.single();
|
.single();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -5,7 +5,7 @@ import { createServerClient } from '@supabase/ssr';
|
|||||||
import type { Database } from '@/utils/supabase';
|
import type { Database } from '@/utils/supabase';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
export const SupabaseServer = async () => {
|
export const useSupabaseServer = async () => {
|
||||||
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