diff --git a/bun.lockb b/bun.lockb index c069b26..4585b13 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 5723eeb..de31850 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..5ab4c75 Binary files /dev/null and b/public/favicon.png differ diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..b3fe17f --- /dev/null +++ b/src/app/global-error.tsx @@ -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 ( + + + + +
+ + {reset !== undefined && ( + + )} + +
+
+
+ + + ); +}; +export default GlobalError; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 116eb1b..39e20f5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 > +
{children} @@ -245,4 +247,4 @@ export default function RootLayout({ ); -} +}; diff --git a/src/app/page.tsx b/src/app/page.tsx index a7dc76a..860807f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
-
-

- Create T3 App -

- - - -
+
+
); } diff --git a/src/components/default/auth/forms/client/profile/avatar-upload.tsx b/src/components/default/auth/forms/client/profile/avatar-upload.tsx index 7a0be69..6e7812b 100644 --- a/src/components/default/auth/forms/client/profile/avatar-upload.tsx +++ b/src/components/default/auth/forms/client/profile/avatar-upload.tsx @@ -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; + cardProps?: ComponentProps; + cardContentProps?: ComponentProps; + containerProps?: ComponentProps<'div'>; + basedAvatarProps?: ComponentProps; + iconProps?: ComponentProps; }; 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 ( - - -
+ + +
+ +
+ +
+
+ +
+ + {isUploading && ( +
+ + Uploading... +
+ )} + {!isAuthenticated && ( +

+ Sign in to upload an avatar. +

+ )}
); - }; diff --git a/src/components/default/layout/header/avatar-dropdown.tsx b/src/components/default/layout/header/avatar-dropdown.tsx new file mode 100644 index 0000000..b36d493 --- /dev/null +++ b/src/components/default/layout/header/avatar-dropdown.tsx @@ -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 ( + + + + + + {(profile?.full_name ?? profile?.email) && ( + <> + + {profile.full_name?.trim() ?? profile.email?.trim()} + + + + )} + + + Edit Profile + + + + + + + + + ); +}; diff --git a/src/components/default/layout/header/index.tsx b/src/components/default/layout/header/index.tsx new file mode 100644 index 0000000..02d51fa --- /dev/null +++ b/src/components/default/layout/header/index.tsx @@ -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 = () => ( +
+ + {isAuthenticated && ( )} +
+ ); + + return ( +
+
+
+ + {/* Left spacer for perfect centering */} +
+
+
+ + {/* Centered logo and title */} +
+ + Tech Tracker Logo +

+ Next Template +

+ +
+ + {/* Right-aligned controls */} +
+ +
+ +
+
+
+ ); + +}; +export default Header; diff --git a/src/components/ui/based-avatar.tsx b/src/components/ui/based-avatar.tsx index b5aced3..581489e 100644 --- a/src/components/ui/based-avatar.tsx +++ b/src/components/ui/based-avatar.tsx @@ -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 & { +type BasedAvatarProps = ComponentProps & { src?: string | null; fullName?: string | null; - imageClassName?: string; - fallbackClassName?: string; - userIconSize?: number; + imageProps?: Omit, 'data-slot'>; + fallbackProps?: ComponentProps; + userIconProps?: ComponentProps; }; 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 ? ( - + ) : ( {fullName ? ( @@ -48,7 +51,7 @@ const BasedAvatar = ({ .join('') .toUpperCase() ) : ( - + )} )} diff --git a/src/lib/hooks/context/use-auth.tsx b/src/lib/hooks/context/use-auth.tsx old mode 100644 new mode 100755 index 6c76b24..0fd58cc --- a/src/lib/hooks/context/use-auth.tsx +++ b/src/lib/hooks/context/use-auth.tsx @@ -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; }; @@ -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) => { - 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) => { + //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) => { + 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 }; } }; diff --git a/src/lib/hooks/context/use-query.tsx b/src/lib/hooks/context/use-query.tsx index 3b959ef..8226e47 100644 --- a/src/lib/hooks/context/use-query.tsx +++ b/src/lib/hooks/context/use-query.tsx @@ -65,6 +65,7 @@ const QueryClientProvider = ({ children }: { children: React.ReactNode }) => { }), defaultOptions: { queries: { + refetchOnWindowFocus: true, staleTime: 60 * 1000, }, }, diff --git a/src/lib/hooks/context/use-theme.tsx b/src/lib/hooks/context/use-theme.tsx index bca1983..1c32f58 100644 --- a/src/lib/hooks/context/use-theme.tsx +++ b/src/lib/hooks/context/use-theme.tsx @@ -25,17 +25,12 @@ const ThemeProvider = ({ type ThemeToggleProps = { size?: number; - buttonClassName?: ComponentProps['className']; - buttonProps?: Omit, 'className' | 'onClick'>; + buttonProps?: Omit, '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 ( - ); @@ -58,9 +53,11 @@ const ThemeToggle = ({ return (