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": "^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

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

View File

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

View File

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

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

View File

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

View File

@@ -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` }}

View File

@@ -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 = {

View File

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

View File

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