More stuff

This commit is contained in:
2025-07-09 11:54:01 -05:00
parent 2fbb259e62
commit 04f2a48727
16 changed files with 358 additions and 66 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -48,10 +48,10 @@
"@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.35.0",
"@sentry/nextjs": "^9.36.0",
"@supabase-cache-helpers/postgrest-react-query": "^1.13.4",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.3",
"@supabase/supabase-js": "^2.50.4",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-table": "^8.21.3",
@@ -74,18 +74,18 @@
"react-hook-form": "^7.60.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.3",
"recharts": "^3.0.2",
"recharts": "^3.1.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.25.75"
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.11",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^20.19.4",
"@types/node": "^20.19.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"drizzle-kit": "^0.30.6",
@@ -100,7 +100,7 @@
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"typescript-eslint": "^8.35.1"
"typescript-eslint": "^8.36.0"
},
"ct3aMetadata": {
"initVersion": "7.39.3"

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

51
src/app/global-error.tsx Normal file
View 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;

View File

@@ -11,6 +11,7 @@ import {
import PlausibleProvider from 'next-plausible';
import { Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs';
import Header from '@/components/default/layout/header';
export const generateMetadata = (): Metadata => {
return {
@@ -208,7 +209,7 @@ export const generateMetadata = (): Metadata => {
const fontSans = Inter({
subsets: ['latin'],
variable: '--font-sans',
})
});
export default function RootLayout({
children,
@@ -235,6 +236,7 @@ export default function RootLayout({
selfHosted
>
<TVModeProvider>
<Header />
{children}
<Toaster />
</TVModeProvider>
@@ -245,4 +247,4 @@ export default function RootLayout({
</body>
</html>
);
}
};

View File

@@ -1,18 +1,9 @@
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() {
return (
<main className='flex min-h-screen flex-col items-center justify-center'>
<div className='container flex flex-col items-center justify-center gap-12 px-4 py-16'>
<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 className='flex flex-col items-center min-h-[90vh]'>
<SignInCard containerProps={{className: 'my-auto'}}/>
</main>
);
}

View File

@@ -10,13 +10,26 @@ import {
import { Loader2, Pencil, Upload } from 'lucide-react';
import type { ComponentProps, ChangeEvent } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type AvatarUploadProps = {
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 = ({
onAvatarUploaded,
cardProps,
cardContentProps,
containerProps,
basedAvatarProps,
iconProps = {
size: 32,
},
}: AvatarUploadProps) => {
const { profile, isAuthenticated } = useAuth();
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.size > 8 * 1024 * 1024) throw new Error('File is too large!');
const fileExt = file.name.split('.').pop();
const avatarPath = profile?.avatar_url ?? profile?.id;
const avatarPath = profile?.avatar_url ??
`${profile?.id}.${file.name.split('.').pop()}`;
const avatarUrl = await uploadAvatarMutation.mutateAsync({
client,
@@ -61,12 +74,78 @@ export const AvatarUpload = ({
};
return (
<Card>
<CardContent>
<div>
<Card
{...cardProps}
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>
</CardContent>
</Card>
);
};

View 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>
);
};

View 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;

View File

@@ -1,24 +1,26 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { User } from 'lucide-react';
import { cn } from '@/lib/utils';
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;
fullName?: string | null;
imageClassName?: string;
fallbackClassName?: string;
userIconSize?: number;
imageProps?: Omit<ComponentProps<typeof AvatarImage>, 'data-slot'>;
fallbackProps?: ComponentProps<typeof AvatarPrimitive.Fallback>;
userIconProps?: ComponentProps<typeof User>;
};
const BasedAvatar = ({
src = null,
fullName = null,
imageClassName = '',
fallbackClassName = '',
userIconSize = 32,
imageProps,
fallbackProps,
userIconProps = {
size: 32,
},
className,
...props
}: BasedAvatarProps) => {
@@ -32,13 +34,14 @@ const BasedAvatar = ({
{...props}
>
{src ? (
<AvatarImage src={src} className={imageClassName} />
<AvatarImage {...imageProps} src={src} className={imageProps?.className} />
) : (
<AvatarPrimitive.Fallback
{...fallbackProps}
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
fallbackClassName,
fallbackProps?.className,
)}
>
{fullName ? (
@@ -48,7 +51,7 @@ const BasedAvatar = ({
.join('')
.toUpperCase()
) : (
<User size={userIconSize} />
<User {...userIconProps} className={cn('', userIconProps?.className)} />
)}
</AvatarPrimitive.Fallback>
)}

56
src/lib/hooks/context/use-auth.tsx Normal file → Executable file
View File

@@ -6,7 +6,10 @@ import React, {
useEffect,
} from 'react';
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 { type User, type Profile, useSupabaseClient } from '@/utils/supabase';
import { toast } from 'sonner';
@@ -27,7 +30,8 @@ type AuthContextType = {
full_name?: string;
email?: string;
avatar_url?: string;
}) => Promise<{ data?: Profile; error?: unknown }>;
provider?: string;
}) => Promise<{ data?: Profile | null; error?: { message: string } | null }>;
refreshUser: () => Promise<void>;
};
@@ -67,6 +71,7 @@ const AuthContextProvider = ({ children }: { children: ReactNode }) => {
}
);
// Avatar query
const {
data: avatarData,
@@ -83,20 +88,31 @@ const AuthContextProvider = ({ children }: { children: ReactNode }) => {
});
// 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;
const updateProfileMutation = useUpdateMutation(
supabase.from('profiles'),
['id'],
'*',
{
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));
toast.success('Profile updated successfully!');
},
meta: { errCode: QueryErrorCodes.UPDATE_PROFILE_FAILED },
});
);
//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 {
@@ -110,11 +126,15 @@ const AuthContextProvider = ({ children }: { children: ReactNode }) => {
}, [supabase.auth, queryClient]);
const handleUpdateProfile = async (data: Partial<Profile>) => {
if (!userData?.id) throw new Error('User ID is required!');
try {
const result = await updateProfileMutation.mutateAsync(data);
return { data: result };
const result = await updateProfileMutation.mutateAsync({
...data,
id: userData.id,
});
return { data: result, error: null };
} catch (error) {
return { error };
return { data: null, error };
}
};

View File

@@ -65,6 +65,7 @@ const QueryClientProvider = ({ children }: { children: React.ReactNode }) => {
}),
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
staleTime: 60 * 1000,
},
},

View File

@@ -25,17 +25,12 @@ const ThemeProvider = ({
type ThemeToggleProps = {
size?: number;
buttonClassName?: ComponentProps<typeof Button>['className'];
buttonProps?: Omit<ComponentProps<typeof Button>, 'className' | 'onClick'>;
buttonProps?: Omit<ComponentProps<typeof Button>, 'onClick'>;
};
const ThemeToggle = ({
size = 1,
buttonClassName,
buttonProps = {
variant: 'outline',
size: 'icon',
},
buttonProps,
}: ThemeToggleProps) => {
const { setTheme, resolvedTheme } = useTheme();
@@ -45,7 +40,7 @@ const ThemeToggle = ({
if (!mounted) {
return (
<Button className={buttonClassName} {...buttonProps}>
<Button {...buttonProps}>
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
</Button>
);
@@ -58,9 +53,11 @@ const ThemeToggle = ({
return (
<Button
className={cn('cursor-pointer', buttonClassName)}
onClick={toggleTheme}
variant='outline'
size='icon'
{...buttonProps}
onClick={toggleTheme}
className={cn('cursor-pointer', buttonProps?.className)}
>
<Sun
style={{ height: `${size}rem`, width: `${size}rem` }}

View File

@@ -3,7 +3,7 @@ import { useState, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getSignedUrl, resizeImage, uploadFile } from '@/lib/queries';
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';
type UploadToStorageProps = {

View File

@@ -100,6 +100,7 @@ const updateProfile = (
.update(updates)
.eq(`id`, userId)
.select()
.throwOnError()
.single();
};

View File

@@ -5,7 +5,7 @@ import { createServerClient } from '@supabase/ssr';
import type { Database } from '@/utils/supabase';
import { cookies } from 'next/headers';
export const SupabaseServer = async () => {
export const useSupabaseServer = async () => {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,