Making progress.

This commit is contained in:
2025-06-11 14:15:43 -05:00
parent 6c06dbc535
commit ee6bede841
86 changed files with 4594 additions and 4185 deletions

View File

@ -2,6 +2,5 @@
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "all",
"useTabs": true,
"tabWidth": 2
}

View File

@ -17,7 +17,7 @@ export default tseslint.config(
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
eslintPluginPrettierRecommended,
//eslintPluginPrettierRecommended,
],
rules: {
'@typescript-eslint/array-type': 'off',

View File

@ -3,9 +3,12 @@
*/
import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
import { withPlausibleProxy } from 'next-plausible';
/** @type {import("next").NextConfig} */
const config = {
const config = withPlausibleProxy({
customDomain: 'https://plausible.gbrown.org',
})({
output: 'standalone',
images: {
remotePatterns: [
@ -36,7 +39,7 @@ const config = {
},
},
},
};
});
const sentryConfig = {
// For all available options, see:

View File

@ -32,6 +32,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.510.0",
"next": "^15.3.3",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",

16
pnpm-lock.yaml generated
View File

@ -53,6 +53,9 @@ importers:
next:
specifier: ^15.3.3
version: 15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-plausible:
specifier: ^3.12.4
version: 3.12.4(next@15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -2758,6 +2761,13 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
next-plausible@3.12.4:
resolution: {integrity: sha512-cD3+ixJxf8yBYvsideTxqli3fvrB7R4BXcvsNJz8Sm2X1QN039WfiXjCyNWkub4h5++rRs6fHhchUMnOuJokcg==}
peerDependencies:
next: '^11.1.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 '
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
@ -6327,6 +6337,12 @@ snapshots:
neo-async@2.6.2: {}
next-plausible@3.12.4(next@15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
next: 15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0

1
public/icons/gitea.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M414.4 376.5 200 379.6l-1.4-256.7 103.5-15.2 108.8-1.5z" style="fill:#fff"/><path d="M502.6 103.7c-3.3-3.3-7.8-3.3-7.8-3.3s-95.5 5.4-144.9 6.5c-10.8.2-21.6.5-32.3.6V203c-4.5-2.1-9-4.3-13.5-6.4 0-29.6-.1-88.9-.1-88.9-23.6.3-72.7-1.8-72.7-1.8s-115.2-5.8-127.7-6.9c-8-.5-18.3-1.7-31.8 1.2-7.1 1.5-27.3 6-43.8 21.9C-8.7 154.8.7 206.7 1.9 214.5c1.4 9.5 5.6 36 25.8 59 37.3 45.7 117.6 44.6 117.6 44.6s9.9 23.5 24.9 45.2c20.4 27 41.3 48 61.7 50.5 51.3 0 153.9-.1 153.9-.1s9.8.1 23-8.4c11.4-6.9 21.6-19.1 21.6-19.1s10.5-11.2 25.2-36.9c4.5-7.9 8.2-15.6 11.5-22.8 0 0 45-95.4 45-188.2-1-28-7.9-33-9.5-34.6M97.7 269.9c-21.1-6.9-30.1-15.2-30.1-15.2S52 243.8 44.2 222.3c-13.4-36-1.1-58-1.1-58s6.8-18.3 31.4-24.4c11.2-3 25.2-2.5 25.2-2.5s5.8 48.4 12.8 76.7c5.9 23.8 20.2 63.3 20.2 63.3s-21.3-2.6-35-7.5m289.4-4.5c-5.2 12.6-44.8 92.1-44.8 92.1s-5 11.8-16 12.5c-4.7.3-8.4-1-8.4-1s-.2-.1-4.3-1.7l-92-44.8s-8.9-4.6-10.4-12.7c-1.8-6.6 2.2-14.7 2.2-14.7l44.2-91.1s3.9-7.9 9.9-10.6c.5-.2 1.9-.8 3.7-1.2 6.6-1.7 14.7 2.3 14.7 2.3l18.4 8.9c-3.7 7.6-7.5 15.2-11.2 22.9-5.5-.1-10.5 2.9-13.1 7.7-2.8 5.1-2.2 11.5 1.5 16.1-6.6 13.8-13.3 27.5-19.9 41.1-6.7.1-12.5 4.7-14.1 11.2-1.5 6.5 1.6 13.3 7.4 16.3 6.3 3.3 14.3 1.5 18.5-4.4 4.2-5.8 3.5-13.8-1.5-18.8l19.5-40c1.2.1 3 .2 5-.4 3.3-.7 5.8-2.9 5.8-2.9 3.4 1.5 7 3.1 10.8 5 3.9 2 7.6 4 10.9 5.9.7.4 1.5.9 2.3 1.5 1.3 1.1 2.8 2.5 3.8 4.5 1.5 4.5-1.5 12.1-1.5 12.1-1.9 6.2-15 33.1-15 33.1-6.6-.2-12.5 4.1-14.4 10.2-2.1 6.6.9 14.1 7.2 17.3 6.4 3.3 14.2 1.4 18.3-4.3 4.1-5.5 3.7-13.3-.9-18.4l4.6-9.2c4.1-8.5 11-24.8 11-24.8.7-1.4 4.6-8.4 2.2-17.3-2-9.3-10.3-13.6-10.3-13.6-9.9-6.4-23.8-12.4-23.8-12.4s0-3.3-.9-5.8-2.3-4.2-3.2-5.1c3.6-7.6 7.4-15.1 11-22.6l61.8 29.9s10.3 4.6 12.5 13.2c1.5 6-.4 11.4-1.5 14" style="fill:#609926"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -72,7 +72,10 @@ const ForgotPassword = () => {
};
return (
<Card className='min-w-xs md:min-w-sm'>
<div className='w-full mx-auto text-center pt-2 md:pt-10'>
<div className='mx-auto flex flex-col items-center justify-center'>
<Card className='min-w-xs sm:min-w-sm sm:max-w-xs max-w-lg'>
<CardHeader>
<CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
<CardDescription className='text-sm text-foreground'>
@ -124,6 +127,8 @@ const ForgotPassword = () => {
</Form>
</CardContent>
</Card>
</div>
</div>
);
};
export default ForgotPassword;

View File

@ -24,8 +24,7 @@ import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
import { Separator } from '@/components/ui';
import { SignInWithMicrosoft } from '@/components/default/auth/SignInWithMicrosoft';
import { SignInWithApple } from '@/components/default/auth/SignInWithApple';
import { SignInWithApple, SignInWithMicrosoft } from '@/components/default/auth';
const formSchema = z.object({
email: z.string().email({
@ -78,7 +77,10 @@ const Login = () => {
};
return (
<Card className='min-w-xs md:min-w-sm'>
<div className='w-full mx-auto text-center pt-2 md:pt-10'>
<div className='mx-auto flex flex-col items-center justify-center'>
<Card className='min-w-xs md:min-w-sm sm:max-w-xs max-w-lg'>
<CardHeader>
<CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
<CardDescription className='text-foreground'>
@ -165,6 +167,8 @@ const Login = () => {
<SignInWithApple />
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -99,7 +99,9 @@ const SignUp = () => {
};
return (
<Card className='min-w-xs md:min-w-sm'>
<div className='w-full mx-auto text-center pt-2 md:pt-10'>
<div className='mx-auto flex flex-col items-center justify-center'>
<Card className='min-w-xs md:min-w-sm sm:max-w-xs lg:max-w-lg'>
<CardHeader>
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
<CardDescription className='text-foreground'>
@ -201,10 +203,12 @@ const SignUp = () => {
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft />
<SignInWithApple />
<SignInWithMicrosoft type='signUp' />
<SignInWithApple type='signUp' />
</CardContent>
</Card>
</div>
</div>
);
};
export default SignUp;

View File

@ -1,16 +0,0 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
class SentryExampleAPIError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = 'SentryExampleAPIError';
}
}
// A faulty API route to test Sentry's error monitoring
export function GET() {
throw new SentryExampleAPIError(
'This error is raised on the backend called by the example page.',
);
return NextResponse.json({ data: 'Testing Sentry Error...' });
}

View File

@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
class SentryExampleAPIError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = 'SentryExampleAPIError';
}
}
// A faulty API route to test Sentry's error monitoring
export function GET() {
throw new SentryExampleAPIError(
'This error is raised on the backend called by the example page.',
);
return NextResponse.json({ data: 'Testing Sentry Error...' });
}

View File

@ -3,7 +3,7 @@
import '@/styles/globals.css';
import { cn } from '@/lib/utils';
import { AuthProvider, ThemeProvider } from '@/components/context';
import Navigation from '@/components/default/navigation';
import Header from '@/components/default/header';
import Footer from '@/components/default/footer';
import { Button, Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs';
@ -40,7 +40,7 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
<AuthProvider>
<main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'>
<Navigation />
<Header />
<div
className='flex flex-col gap-20 max-w-5xl
p-5 w-full items-center'

View File

@ -7,7 +7,8 @@ import {
ThemeProvider,
TVModeProvider,
} from '@/components/context';
import Navigation from '@/components/default/navigation';
import PlausibleProvider from 'next-plausible';
import Header from '@/components/default/header';
import Footer from '@/components/default/footer';
import { Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs';
@ -18,7 +19,9 @@ export const generateMetadata = (): Metadata => {
template: '%s | Tech Tracker',
default: 'Tech Tracker',
},
description: 'Created by Gib with Next.js & Supabase!',
description:
'App used by COG IT employees to \
update their status throughout the day.',
applicationName: 'Tech Tracker',
keywords:
'Tech Tracker, City of Gulfport, Information Technology, T3 Template, ' +
@ -342,72 +345,39 @@ export const generateMetadata = (): Metadata => {
other: {
...Sentry.getTraceData(),
},
//twitter: {
//card: 'app',
//title: 'Tech Tracker',
//description: 'Created by Gib with Next.js & Supabase!',
//siteId: '',
//creator: '@cs_gib',
//creatorId: '',
//images: {
//url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
//alt: 'Tech Tracker',
//},
//app: {
//name: 'Tech Tracker',
//id: {
//iphone: '',
//ipad: '',
//googleplay: '',
//},
//url: {
//iphone: '',
//ipad: '',
//googleplay: '',
//},
//},
//},
//verification: {
//google: 'google',
//yandex: 'yandex',
//yahoo: 'yahoo',
//},
//itunes: {
//appId: '',
//appArgument: '',
//},
//appleWebApp: {
//title: 'Tech Tracker',
//statusBarStyle: 'black-translucent',
//startupImage: [
//'/icons/apple/splash-768x1004.png',
//{
//url: '/icons/apple/splash-1536x2008.png',
//media: '(device-width: 768px) and (device-height: 1024px)',
//},
//],
//},
//appLinks: {
//ios: {
//url: 'https://techtracker.gbrown.org/ios',
//app_store_id: 'com.gbrown.techtracker',
//},
//android: {
//package: 'https://techtracker.gbrown.org/android',
//app_name: 'app_t3_template',
//},
//web: {
//url: 'https://techtracker.gbrown.org',
//should_fallback: true,
//},
//},
//facebook: {
//appId: '',
//},
//pinterest: {
//richPin: true,
//},
appleWebApp: {
title: 'Tech Tracker',
statusBarStyle: 'black-translucent',
startupImage: [
'/icons/apple/splash-768x1004.png',
{
url: '/icons/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://techtracker.gbrown.org/ios',
app_store_id: 'com.gbrown.techtracker',
},
android: {
package: 'https://techtracker.gbrown.org/android',
app_name: 'app_t3_template',
},
web: {
url: 'https://techtracker.gbrown.org',
should_fallback: true,
},
},
*/
};
};
@ -429,21 +399,21 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
disableTransitionOnChange
>
<AuthProvider>
<TVModeProvider>
<main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'>
<Navigation />
<div
className='flex flex-col gap-20 max-w-5xl
p-5 w-full items-center'
<PlausibleProvider
domain='techtracker.gbrown.org'
customDomain='https://plausible.gbrown.org'
trackOutboundLinks={true}
selfHosted={true}
>
<TVModeProvider>
<div className='min-h-screen'>
<Header />
{children}
</div>
<Toaster />
</div>
<Footer />
</main>
<Toaster />
</TVModeProvider>
</PlausibleProvider>
</AuthProvider>
</ThemeProvider>
</body>

View File

@ -1,96 +1,15 @@
'use server';
import { FetchDataSteps } from '@/components/default/tutorial';
import { InfoIcon } from 'lucide-react';
import { getUser } from '@/lib/actions';
import type { User } from '@/utils/supabase';
import { TestSentryCard } from '@/components/default/sentry';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from '@/components/ui';
import {
SignInSignUp,
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
import { redirect } from 'next/navigation';
const HomePage = async () => {
const response = await getUser();
if (!response.success || !response.data) {
return (
<main className='w-full items-center justify-center'>
<div className='flex flex-col p-5 items-center justify-center space-y-6'>
<Card className='md:min-w-2xl'>
<CardHeader className='flex flex-col items-center'>
<CardTitle className='text-3xl'>
Welcome to the T3 Supabase Template!
</CardTitle>
<CardDescription className='text-[1.0rem] mb-2'>
A great place to start is by creating a new user account &
ensuring you can sign up! If you already have an account, go
ahead and sign in!
</CardDescription>
<SignInSignUp
className='flex gap-4 w-full justify-center'
signInSize='xl'
signUpSize='xl'
/>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<div className='flex gap-4'>
<SignInWithMicrosoft buttonSize='lg' />
<SignInWithApple buttonSize='lg' />
</div>
</CardHeader>
<Separator className='bg-accent' />
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'>
<CardTitle className='text-lg mb-6 w-2/3 text-center'>
You can also test out your connection to Sentry if you want to
start there!
</CardTitle>
<TestSentryCard />
</CardContent>
</Card>
</div>
</main>
);
redirect('/sign-in');
}
const user: User = response.data;
return (
<div className='flex-1 w-full flex flex-col gap-12'>
<div className='w-full'>
<div
className='bg-accent text-sm p-3 px-5
rounded-md text-foreground flex gap-3 items-center'
>
<InfoIcon size='16' strokeWidth={2} />
This is a protected component that you can only see as an
authenticated user
</div>
</div>
<div className='flex flex-col gap-2 items-start'>
<h2 className='font-bold text-3xl mb-4'>Your user details</h2>
<pre
className='text-sm font-mono p-3 rounded
border max-h-50 overflow-auto'
>
{JSON.stringify(user, null, 2)}
</pre>
</div>
<TestSentryCard />
<div>
<h2 className='font-bold text-2xl mb-4'>Next steps</h2>
<FetchDataSteps />
</div>
</div>
);
return <NoSession />;
};
export default HomePage;

View File

@ -0,0 +1,11 @@
import { SignUpCard } from './cards/SignUp';
export default function NoSession() {
return (
<div className='w-full mx-auto text-center pt-2 md:pt-10'>
<div className='mx-auto flex flex-col items-center justify-center'>
<SignUpCard />
</div>
</div>
);
}

View File

@ -1,33 +0,0 @@
'use client';
import Link from 'next/link';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInSignUpProps = {
className?: ComponentProps<'div'>['className'];
signInSize?: VariantProps<typeof buttonVariants>['size'];
signUpSize?: VariantProps<typeof buttonVariants>['size'];
signInVariant?: VariantProps<typeof buttonVariants>['variant'];
signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInSignUp = ({
className = 'flex gap-2',
signInSize = 'default',
signUpSize = 'sm',
signInVariant = 'outline',
signUpVariant = 'default',
}: SignInSignUpProps) => {
return (
<div className={className}>
<Button asChild size={signInSize} variant={signInVariant}>
<Link href='/sign-in'>Sign In</Link>
</Button>
<Button asChild size={signUpSize} variant={signUpVariant}>
<Link href='/sign-up'>Sign Up</Link>
</Button>
</div>
);
};

View File

@ -1,77 +0,0 @@
'use client';
import { signInWithApple } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithAppleProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithApple = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithAppleProps) => {
const router = useRouter();
const { isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithApple = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithApple();
if (result?.success && result.data) {
// Redirect to Apple OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error signing in with Apple!`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
} finally {
setIsSigningIn(false);
await refreshUserData();
router.push('');
}
};
return (
<form onSubmit={handleSignInWithApple} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/apple.svg'
alt='Apple logo'
className='invert-75 dark:invert-25'
width={22}
height={22}
/>
<p className='text-[1.0rem]'>Sign In with Apple</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -1,70 +0,0 @@
'use client';
import { signInWithMicrosoft } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithMicrosoftProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithMicrosoft = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithMicrosoftProps) => {
const { isLoading } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithMicrosoft = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithMicrosoft();
if (result?.success && result.data) {
// Redirect to Microsoft OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error: Could not sign in with Microsoft!`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<form onSubmit={handleSignInWithMicrosoft} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/microsoft.svg'
alt='Microsoft logo'
width={20}
height={20}
/>
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -0,0 +1,33 @@
'use client';
import Link from 'next/link';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInSignUpProps = {
className?: ComponentProps<'div'>['className'];
signInSize?: VariantProps<typeof buttonVariants>['size'];
signUpSize?: VariantProps<typeof buttonVariants>['size'];
signInVariant?: VariantProps<typeof buttonVariants>['variant'];
signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInSignUp = ({
className = 'flex gap-2',
signInSize = 'default',
signUpSize = 'sm',
signInVariant = 'outline',
signUpVariant = 'default',
}: SignInSignUpProps) => {
return (
<div className={className}>
<Button asChild size={signInSize} variant={signInVariant}>
<Link href='/sign-in'>Sign In</Link>
</Button>
<Button asChild size={signUpSize} variant={signUpVariant}>
<Link href='/sign-up'>Sign Up</Link>
</Button>
</div>
);
};

View File

@ -0,0 +1,77 @@
'use client';
import { signInWithApple } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithAppleProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithApple = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithAppleProps) => {
const router = useRouter();
const { isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithApple = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithApple();
if (result?.success && result.data) {
// Redirect to Apple OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error signing in with Apple!`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
} finally {
setIsSigningIn(false);
await refreshUserData();
router.push('');
}
};
return (
<form onSubmit={handleSignInWithApple} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/apple.svg'
alt='Apple logo'
className='invert-75 dark:invert-25'
width={22}
height={22}
/>
<p className='text-[1.0rem]'>Sign In with Apple</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -0,0 +1,70 @@
'use client';
import { signInWithMicrosoft } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithMicrosoftProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithMicrosoft = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithMicrosoftProps) => {
const { isLoading } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithMicrosoft = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithMicrosoft();
if (result?.success && result.data) {
// Redirect to Microsoft OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error: Could not sign in with Microsoft!`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<form onSubmit={handleSignInWithMicrosoft} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/microsoft.svg'
alt='Microsoft logo'
width={20}
height={20}
/>
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -0,0 +1,128 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@/components/ui';
import Link from 'next/link';
import { forgotPassword } from '@/lib/actions';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
const formSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
});
export const ForgotPasswordCard = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
},
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleForgotPassword = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('email', values.email);
const result = await forgotPassword(formData);
if (result?.success) {
await refreshUserData();
setStatusMessage(
result?.data ?? 'Check your email for a link to reset your password.',
);
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<Card className='min-w-xs md:min-w-sm'>
<CardHeader>
<CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
<CardDescription className='text-sm text-foreground'>
Don&apos;t have an account?{' '}
<Link className='font-medium underline' href='/sign-up'>
Sign up
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleForgotPassword)}
className='flex flex-col min-w-64 space-y-6'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SubmitButton
disabled={isLoading}
pendingText='Resetting Password...'
>
Reset Password
</SubmitButton>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ success: statusMessage }} />
))}
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,121 @@
'use client';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import {
AvatarUpload,
ProfileForm,
ResetPasswordForm,
SignOut,
} from '@/components/default/profile';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
Separator,
} from '@/components/ui';
import { Loader2 } from 'lucide-react';
import { resetPassword } from '@/lib/actions';
import { toast } from 'sonner';
import { type Result } from '@/lib/actions';
export const ProfileCard = () => {
const {
profile,
isLoading,
isAuthenticated,
updateProfile,
refreshUserData,
} = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/sign-in');
}
}, [isLoading, isAuthenticated, router]);
const handleAvatarUploaded = async (path: string) => {
await updateProfile({ avatar_url: path });
await refreshUserData();
};
const handleProfileSubmit = async (values: {
full_name: string;
email: string;
}) => {
try {
await updateProfile({
full_name: values.full_name,
email: values.email,
});
} catch {
toast.error('Error updating profile!: ');
}
};
const handleResetPasswordSubmit = async (
formData: FormData,
): Promise<Result<null>> => {
try {
const result = await resetPassword(formData);
if (!result.success) {
toast.error(`Error resetting password: ${result.error}`);
return { success: false, error: result.error };
}
return { success: true, data: null };
} catch (error) {
toast.error(
`Error resetting password!: ${(error as string) ?? 'Unknown error'}`,
);
return { success: false, error: 'Unknown error' };
}
};
// Show loading state while checking authentication
if (isLoading) {
return (
<div className='flex justify-center items-center min-h-[50vh]'>
<Loader2 className='h-8 w-8 animate-spin text-gray-500' />
</div>
);
}
// If not authenticated and not loading, this will show briefly before redirect
if (!isAuthenticated) {
return (
<div className='flex p-5 items-center justify-center'>
<h1>Unauthorized - Redirecting...</h1>
</div>
);
}
return (
<div className='max-w-2xl min-w-sm mx-auto p-4'>
<Card className='mb-8'>
<CardHeader className='pb-2'>
<CardTitle className='text-2xl'>Your Profile</CardTitle>
<CardDescription>
Manage your personal information and how it appears to others
</CardDescription>
</CardHeader>
{isLoading && !profile ? (
<div className='flex justify-center py-8'>
<Loader2 className='h-8 w-8 animate-spin text-gray-500' />
</div>
) : (
<div className='space-y-8'>
<AvatarUpload onAvatarUploaded={handleAvatarUploaded} />
<Separator />
<ProfileForm onSubmit={handleProfileSubmit} />
<Separator />
<ResetPasswordForm onSubmit={handleResetPasswordSubmit} />
<Separator />
<SignOut />
</div>
)}
</Card>
</div>
);
};

View File

@ -0,0 +1,171 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@/components/ui';
import Link from 'next/link';
import { signIn } from '@/lib/actions';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
import { Separator } from '@/components/ui';
import {
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const formSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
});
export const SignInCard = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleSignIn = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
const result = await signIn(formData);
if (result?.success) {
await refreshUserData();
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<Card className='min-w-xs max-w-lg md:min-w-sm'>
<CardHeader>
<CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
<CardDescription className='text-foreground'>
Don&apos;t have an account?{' '}
<Link className='font-medium underline' href='/sign-up'>
Sign up
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSignIn)}
className='flex flex-col min-w-64 space-y-6 pb-4'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<div className='flex justify-between'>
<FormLabel className='text-lg'>Password</FormLabel>
<Link
className='text-xs text-foreground underline text-right'
href='/forgot-password'
>
Forgot Password?
</Link>
</div>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ message: statusMessage }} />
))}
<SubmitButton
disabled={isLoading}
pendingText='Signing In...'
className='text-[1.0rem] cursor-pointer'
>
Sign in
</SubmitButton>
</form>
</Form>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft />
<SignInWithApple />
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,209 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import Link from 'next/link';
import { signUp } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
} from '@/components/ui';
import { useEffect, useState } from 'react';
import {
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const formSchema = z
.object({
name: z.string().min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
confirmPassword: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
export const SignUpCard = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
mode: 'onChange',
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleSignUp = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('name', values.name);
formData.append('email', values.email);
formData.append('password', values.password);
const result = await signUp(formData);
if (result?.success) {
await refreshUserData();
setStatusMessage(
result.data ??
'Thanks for signing up! Please check your email for a verification link.',
);
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<Card className='min-w-xs md:min-w-sm'>
<CardHeader>
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
<CardDescription className='text-foreground'>
Already have an account?{' '}
<Link className='text-primary font-medium underline' href='/sign-in'>
Sign in
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSignUp)}
className='flex flex-col mx-auto space-y-4 mb-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Name</FormLabel>
<FormControl>
<Input type='text' placeholder='Full Name' {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Confirm Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ success: statusMessage }} />
))}
<SubmitButton
className='text-[1.0rem] cursor-pointer'
disabled={isLoading}
pendingText='Signing Up...'
>
Sign Up
</SubmitButton>
</form>
</Form>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft />
<SignInWithApple />
</CardContent>
</Card>
);
};

View File

@ -1,3 +1,8 @@
export * from './SignInSignUp';
export * from './SignInWithApple';
export * from './SignInWithMicrosoft';
export * from './NoSession';
export * from './buttons/SignInSignUp';
export * from './buttons/SignInWithApple';
export * from './buttons/SignInWithMicrosoft';
export * from './cards/ForgotPassword';
export * from './cards/Profile';
export * from './cards/SignIn';
export * from './cards/SignUp';

View File

@ -1,19 +1,42 @@
'use server';
import Link from 'next/link';
import Image from 'next/image';
const Footer = () => {
return (
<footer className='w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'>
<footer className='w-full h-16 flex items-center justify-between border-t bg-background px-8 mt-auto'>
<div className='flex items-center'>
<Link
href='https://git.gibbyb.com/gib/tech-tracker-next'
className='text-sm font-semibold px-3 py-2 rounded-lg
bg-gradient-to-tl from-[#35363F] to-[#24191A] hover:text-sky-200
hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]
flex items-center gap-2 transition-all duration-200'
>
<Image src='/icons/gitea.svg' alt='Gitea' width={20} height={20} />
<span>View Source Code on Gitea</span>
</Link>
</div>
<div className='flex-1 flex justify-center'>
<div className='text-xs text-center space-y-1'>
<p>
Powered by{' '}
<strong>Tech Tracker</strong> - Built for City of Gulfport IT Department
</p>
<p className='text-muted-foreground'>
Open Source MIT Licensed Self-Hosted
<a
href='https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs'
href='https://supabase.com'
target='_blank'
className='font-bold hover:underline'
className='hover:underline ml-1'
rel='noreferrer'
>
Supabase
Powered by Supabase
</a>
</p>
</div>
</div>
<div className='w-[160px]'></div> {/* Spacer to balance the layout */}
</footer>
);
};

View File

@ -0,0 +1,87 @@
'use client';
import Link from 'next/link';
import {
Avatar,
AvatarFallback,
AvatarImage,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { signOut } from '@/lib/actions';
import { User } from 'lucide-react';
const AvatarDropdown = () => {
const { profile, avatarUrl, isLoading, refreshUserData } = useAuth();
const router = useRouter();
const handleSignOut = async () => {
const result = await signOut();
if (result?.success) {
await refreshUserData();
router.push('/sign-in');
}
};
const getInitials = (name: string | null | undefined): string => {
if (!name) return '';
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase();
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar className='cursor-pointer'>
{avatarUrl ? (
<AvatarImage
src={avatarUrl}
alt={getInitials(profile?.full_name)}
width={64}
height={64}
/>
) : (
<AvatarFallback className='text-sm'>
{profile?.full_name ? (
getInitials(profile.full_name)
) : (
<User size={32} />
)}
</AvatarFallback>
)}
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{profile?.full_name}</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'
>
Sign Out
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default AvatarDropdown;

View File

@ -0,0 +1,48 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import NavigationAuth from './auth';
import { ThemeToggle, TVToggle, useTVMode } from '@/components/context';
const Header = () => {
const { tvMode } = useTVMode();
return tvMode ? (
<div className='absolute top-4 right-2'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4'>
<TVToggle />
</div>
</div>
) : (
<header className='w-full py-2 pt-6 md:py-5'>
<div className='absolute top-4 right-6'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4 md:pr-8'>
<TVToggle />
<ThemeToggle className='mx-2' />
</div>
</div>
<Link
href='/'
scroll={false}
className='flex flex-row items-center text-center
justify-center sm:ml-0 p-4 mt-10 sm:mt-0'
>
<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-[#bec8e6] via-[#F0EEE4] to-[#FFF8E7]
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
>
Tech Tracker
</h1>
</Link>
</header>
);
};
export default Header;

View File

@ -1,87 +0,0 @@
'use client';
import Link from 'next/link';
import {
Avatar,
AvatarFallback,
AvatarImage,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { signOut } from '@/lib/actions';
import { User } from 'lucide-react';
const AvatarDropdown = () => {
const { profile, avatarUrl, isLoading, refreshUserData } = useAuth();
const router = useRouter();
const handleSignOut = async () => {
const result = await signOut();
if (result?.success) {
await refreshUserData();
router.push('/sign-in');
}
};
const getInitials = (name: string | null | undefined): string => {
if (!name) return '';
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase();
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar className='cursor-pointer'>
{avatarUrl ? (
<AvatarImage
src={avatarUrl}
alt={getInitials(profile?.full_name)}
width={64}
height={64}
/>
) : (
<AvatarFallback className='text-sm'>
{profile?.full_name ? (
getInitials(profile.full_name)
) : (
<User size={32} />
)}
</AvatarFallback>
)}
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{profile?.full_name}</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'
>
Sign Out
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default AvatarDropdown;

View File

@ -1,43 +0,0 @@
'use client'
import Image from 'next/image';
import Link from 'next/link';
import NavigationAuth from './auth';
import { ThemeToggle, TVToggle, useTVMode } from '@/components/context';
const Navigation = () => {
const { tvMode } = useTVMode();
if (tvMode) {
return (
<div className='absolute top-4 right-2'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4'>
<TVToggle />
</div>
</div>
);
}
return (
<nav
className='w-full flex justify-center
border-b border-b-foreground/10 h-16'
>
<div
className='w-full max-w-5xl flex justify-between
items-center p-3 px-5 text-sm'
>
<div className='flex gap-5 items-center font-semibold'>
<Link className='flex flex-row my-auto gap-2' href='/'>
<Image src='/favicon.png' alt='T3 Logo' width={50} height={50} />
<h1 className='my-auto text-2xl'>Tech Tracker</h1>
</Link>
</div>
<div className='flex items-center gap-2'>
<TVToggle />
<ThemeToggle />
<NavigationAuth />
</div>
</div>
</nav>
);
};
export default Navigation;

View File

@ -1,61 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui';
const CopyIcon = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<rect x='9' y='9' width='13' height='13' rx='2' ry='2'></rect>
<path d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'></path>
</svg>
);
const CheckIcon = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<polyline points='20 6 9 17 4 12'></polyline>
</svg>
);
export function CodeBlock({ code }: { code: string }) {
const [icon, setIcon] = useState(CopyIcon);
const copy = async () => {
await navigator?.clipboard?.writeText(code);
setIcon(CheckIcon);
setTimeout(() => setIcon(CopyIcon), 2000);
};
return (
<pre className='bg-muted rounded-md p-6 my-6 relative'>
<Button
size='icon'
onClick={copy}
variant={'outline'}
className='absolute right-2 top-2'
>
{icon}
</Button>
<code className='text-xs p-3'>{code}</code>
</pre>
);
}

View File

@ -1,95 +0,0 @@
import { TutorialStep, CodeBlock } from '@/components/default/tutorial';
const create = `create table notes (
id bigserial primary key,
title text
);
insert into notes(title)
values
('Today I created a Supabase project.'),
('I added some data and queried it from Next.js.'),
('It was awesome!');
`.trim();
const server = `import { createClient } from '@/utils/supabase/server'
export default async function Page() {
const supabase = await createClient()
const { data: notes } = await supabase.from('notes').select()
return <pre>{JSON.stringify(notes, null, 2)}</pre>
}
`.trim();
const client = `'use client'
import { createClient } from '@/utils/supabase/client'
import { useEffect, useState } from 'react'
export default function Page() {
const [notes, setNotes] = useState<any[] | null>(null)
const supabase = createClient()
useEffect(() => {
const getData = async () => {
const { data } = await supabase.from('notes').select()
setNotes(data)
}
getData()
}, [])
return <pre>{JSON.stringify(notes, null, 2)}</pre>
}
`.trim();
export const FetchDataSteps = () => {
return (
<ol className='flex flex-col gap-6'>
<TutorialStep title='Create some tables and insert some data'>
<p>
Head over to the{' '}
<a
href='https://supabase.com/dashboard/project/_/editor'
className='font-bold hover:underline text-foreground/80'
target='_blank'
rel='noreferrer'
>
Table Editor
</a>{' '}
for your Supabase project to create a table and insert some example
data. If you&apos;re stuck for creativity, you can copy and paste the
following into the{' '}
<a
href='https://supabase.com/dashboard/project/_/sql/new'
className='font-bold hover:underline text-foreground/80'
target='_blank'
rel='noreferrer'
>
SQL Editor
</a>{' '}
and click RUN!
</p>
<CodeBlock code={create} />
</TutorialStep>
<TutorialStep title='Query Supabase data from Next.js'>
<p>
To create a Supabase client and query data from an Async Server
Component, create a new page.tsx file at{' '}
<span className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs font-medium text-secondary-foreground border'>
/app/notes/page.tsx
</span>{' '}
and add the following.
</p>
<CodeBlock code={server} />
<p>Alternatively, you can use a Client Component.</p>
<CodeBlock code={client} />
</TutorialStep>
<TutorialStep title='Build in a weekend and scale to millions!'>
<p>You&apos;re ready to launch your product to the world! 🚀</p>
</TutorialStep>
</ol>
);
};

View File

@ -1,30 +0,0 @@
import { Checkbox } from '@/components/ui';
export const TutorialStep = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => {
return (
<li className='relative'>
<Checkbox
id={title}
name={title}
className={`absolute top-[3px] mr-2 peer`}
/>
<label
htmlFor={title}
className={`relative text-base text-foreground peer-checked:line-through font-medium`}
>
<span className='ml-8'>{title}</span>
<div
className={`ml-8 text-sm peer-checked:line-through font-normal text-muted-foreground`}
>
{children}
</div>
</label>
</li>
);
};

View File

@ -1,3 +0,0 @@
export { CodeBlock } from './CodeBlock';
export { FetchDataSteps } from './FetchDataSteps';
export { TutorialStep } from './TutorialStep';

View File

@ -97,7 +97,7 @@ export const forgotPassword = async (
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
redirectTo: `${origin}/auth/callback?redirect_to=/profile`,
});
if (error) {

View File

@ -56,10 +56,12 @@ export const signIn = async (formData: FormData): Promise<Result<null>> => {
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
const supabase = createClient();
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'azure',
options: {
scopes: 'openid, profile email offline_access',
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
},
});
if (error) return { success: false, error: error.message };
@ -68,10 +70,11 @@ export const signInWithMicrosoft = async (): Promise<Result<string>> => {
export const signInWithApple = async (): Promise<Result<string>> => {
const supabase = createClient();
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
scopes: 'openid, profile email offline_access',
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
},
});
if (error) return { success: false, error: error.message };
@ -90,7 +93,7 @@ export const forgotPassword = async (
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
redirectTo: `${origin}/auth/callback?redirect_to=/profile`,
});
if (error) {