Almost done with template perhaps. Have not tested yet though.

This commit is contained in:
Gabriel Brown 2025-05-13 11:12:25 -05:00
parent a2f61ece94
commit eeffb93ab9
27 changed files with 1061 additions and 35 deletions

View File

@ -16,6 +16,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.4",

201
pnpm-lock.yaml generated
View File

@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@radix-ui/react-checkbox':
specifier: ^1.3.1
version: 1.3.1(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-label':
specifier: ^2.1.6
version: 2.1.6(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot':
specifier: ^1.2.2
version: 1.2.2(@types/react@19.1.4)(react@19.1.0)
@ -380,6 +386,22 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@radix-ui/primitive@1.1.2':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
'@radix-ui/react-checkbox@1.3.1':
resolution: {integrity: sha512-xTaLKAO+XXMPK/BpVTSaAAhlefmvMSACjIhK9mGsImvX2ljcTDm8VGR1CuS1uYcNdR5J+oiOhoJZc5un6bh3VQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
@ -389,6 +411,54 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-label@2.1.6':
resolution: {integrity: sha512-S/hv1mTlgcPX2gCTJrWuTjSXf7ER3Zf7zWGtOprxhIIY93Qin3n5VgNA0Ez9AgrK/lEtlYgzLd4f5x6AVar4Yw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.4':
resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.2':
resolution: {integrity: sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.2':
resolution: {integrity: sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==}
peerDependencies:
@ -398,6 +468,51 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.2':
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.2':
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.1':
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@ -2475,12 +2590,64 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@radix-ui/primitive@1.1.2': {}
'@radix-ui/react-checkbox@1.3.1(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.4)(react@19.1.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.4)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.4
'@types/react-dom': 19.1.4(@types/react@19.1.4)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.4)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.4
'@radix-ui/react-context@1.1.2(@types/react@19.1.4)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.4
'@radix-ui/react-label@2.1.6(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(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)
optionalDependencies:
'@types/react': 19.1.4
'@types/react-dom': 19.1.4(@types/react@19.1.4)
'@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.4
'@types/react-dom': 19.1.4(@types/react@19.1.4)
'@radix-ui/react-primitive@2.1.2(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-slot': 1.2.2(@types/react@19.1.4)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.4
'@types/react-dom': 19.1.4(@types/react@19.1.4)
'@radix-ui/react-slot@1.2.2(@types/react@19.1.4)(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.0)
@ -2488,6 +2655,40 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.4
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.4)(react@19.1.0)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.4)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.0)
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.4
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.4)(react@19.1.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.0)
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.4
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.4)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.4
'@radix-ui/react-use-previous@1.1.1(@types/react@19.1.4)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.4
'@radix-ui/react-use-size@1.1.1(@types/react@19.1.4)(react@19.1.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.0)
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.4
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.11.0': {}

138
src/actions/auth.ts Normal file
View File

@ -0,0 +1,138 @@
'use server';
import 'server-only';
import { encodedRedirect } from '@/utils/utils';
import { createClient } from '@/utils/supabase/server';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
export const signUp = async (formData: FormData) => {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = await createClient();
const origin = (await headers()).get('origin');
if (!email || !password) {
return encodedRedirect(
'error',
'/sign-up',
'Email & password are required',
);
}
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/callback`,
},
});
if (error) {
console.error(error.code + ': ' + error.message);
return encodedRedirect(
'error',
'/sign-up',
'Thanks for signing up! Please check your email for a verification link.',
);
} else {
return encodedRedirect(
'success',
'/sign-up',
'Thanks for signing up! Please check your email for a verification link.',
);
}
};
export const signIn = async (formData: FormData) => {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return encodedRedirect('error', '/sign-in', error.message);
}
return redirect('/protected');
};
export const forgotPassword = async (formData: FormData) => {
const email = formData.get('email') as string;
const supabase = await createClient();
const origin = (await headers()).get('origin');
const callbackUrl = formData.get('callbackUrl') as string;
if (!email) {
return encodedRedirect('error', '/forgot-password', 'Email is required');
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`,
});
if (error) {
console.error(error.message);
return encodedRedirect(
'error',
'/forgot-password',
'Could not reset password',
);
}
if (callbackUrl) {
return redirect(callbackUrl);
}
return encodedRedirect(
'success',
'/forgot-password',
'Check your email for a link to reset your password.',
);
};
export const resetPassword = async (formData: FormData) => {
const supabase = await createClient();
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (!password || !confirmPassword) {
encodedRedirect(
'error',
'/protected/reset-password',
'Password and confirm password are required',
);
}
if (password !== confirmPassword) {
encodedRedirect(
'error',
'/protected/reset-password',
'Passwords do not match',
);
}
const { error } = await supabase.auth.updateUser({
password: password,
});
if (error) {
encodedRedirect(
'error',
'/protected/reset-password',
'Password update failed',
);
}
encodedRedirect('success', '/protected/reset-password', 'Password updated');
};
export const signOut = async () => {
const supabase = await createClient();
await supabase.auth.signOut();
return redirect('/sign-in');
};

View File

@ -0,0 +1,37 @@
import Link from 'next/link';
import { forgotPassword } from '@/actions/auth';
import { FormMessage, type Message, SubmitButton } from '@/components/default';
import { Input, Label } from '@/components/ui';
import { SmtpMessage } from '@/app/(auth-pages)/smtp-message';
const ForgotPassword = async (props: { searchParams: Promise<Message> }) => {
const searchParams = await props.searchParams;
return (
<>
<form
className='flex-1 flex flex-col w-full gap-2 text-foreground
[&>input]:mb-6 min-w-64 max-w-64 mx-auto'
>
<div>
<h1 className='text-2xl font-medium'>Reset Password</h1>
<p className='text-sm text-secondary-foreground'>
Already have an account?{' '}
<Link className='text-primary underline' href='/sign-in'>
Sign in
</Link>
</p>
</div>
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'>
<Label htmlFor='email'>Email</Label>
<Input name='email' placeholder='you@example.com' required />
<SubmitButton formAction={forgotPassword}>
Reset Password
</SubmitButton>
<FormMessage message={searchParams} />
</div>
</form>
<SmtpMessage />
</>
);
};
export default ForgotPassword;

View File

@ -0,0 +1,6 @@
const Layout = async ({ children }: { children: React.ReactNode }) => {
return (
<div className='max-w-7xl flex flex-col gap-12 items-start'>{children}</div>
);
};
export default Layout;

View File

@ -0,0 +1,43 @@
import Link from 'next/link';
import { signIn } from '@/actions/auth';
import { FormMessage, type Message, SubmitButton } from '@/components/default';
import { Input, Label } from '@/components/ui';
const Login = async (props: { searchParams: Promise<Message> }) => {
const searchParams = await props.searchParams;
return (
<form className='flex-1 flex flex-col min-w-64'>
<h1 className='text-2xl font-medium'>Sign in</h1>
<p className='text-sm text-foreground'>
Don&apos;t have an account?{' '}
<Link className='text-foreground font-medium underline' href='/sign-up'>
Sign up
</Link>
</p>
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'>
<Label htmlFor='email'>Email</Label>
<Input name='email' placeholder='you@example.com' required />
<div className='flex justify-between items-center'>
<Label htmlFor='password'>Password</Label>
<Link
className='text-xs text-foreground underline'
href='/forgot-password'
>
Forgot Password?
</Link>
</div>
<Input
type='password'
name='password'
placeholder='Your password'
required
/>
<SubmitButton pendingText='Signing In...' formAction={signIn}>
Sign in
</SubmitButton>
<FormMessage message={searchParams} />
</div>
</form>
);
};
export default Login;

View File

@ -0,0 +1,54 @@
import Link from 'next/link';
import { signUp } from '@/actions/auth';
import { FormMessage, type Message, SubmitButton } from '@/components/default';
import { Input, Label } from '@/components/ui';
import { SmtpMessage } from '@/app/(auth-pages)/smtp-message';
const SignUp = async (props: { searchParams: Promise<Message> }) => {
const searchParams = await props.searchParams;
if ('message' in searchParams) {
return (
<div
className='w-full flex-1 flex items-center h-screen
sm:max-w-md justify-center gap-2 p-4'
>
<FormMessage message={searchParams} />
</div>
);
} else {
return (
<>
<form className='flex flex-col min-w-64 max-w-64 mx-auto'>
<h1 className='text-2xl font-medium'>Sign up</h1>
<p className='text-sm text text-foreground'>
Already have an account?{' '}
<Link
className='text-primary font-medium underline'
href='/sign-in'
>
Sign in
</Link>
</p>
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'>
<Label htmlFor='email'>Email</Label>
<Input name='email' placeholder='you@example.com' required />
<Label htmlFor='password'>Password</Label>
<Input
type='password'
name='password'
placeholder='Your password'
minLength={6}
required
/>
<SubmitButton formAction={signUp} pendingText='Signing up...'>
Sign up
</SubmitButton>
<FormMessage message={searchParams} />
</div>
</form>
<SmtpMessage />
</>
);
}
};
export default SignUp;

View File

@ -0,0 +1,25 @@
import { ArrowUpRight, InfoIcon } from 'lucide-react';
import Link from 'next/link';
export const SmtpMessage = () => {
return (
<div className='bg-muted/50 px-5 py-3 border rounded-md flex gap-4'>
<InfoIcon size={16} className='mt-0.5' />
<div className='flex flex-col gap-1'>
<small className='text-sm text-secondary-foreground'>
<strong> Note:</strong> Emails are rate limited. Enable Custom SMTP to
increase the rate limit.
</small>
<div>
<Link
href='https://supabase.com/docs/guides/auth/auth-smtp'
target='_blank'
className='text-primary/50 hover:text-primary flex items-center text-sm gap-1'
>
Learn more <ArrowUpRight size={14} />
</Link>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,24 @@
import { createClient } from '@/utils/supabase/server';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
// The `/auth/callback` route is required for the server-side auth flow implemented
// by the SSR package. It exchanges an auth code for the user's session.
// https://supabase.com/docs/guides/auth/server-side/nextjs
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get('code');
const origin = requestUrl.origin;
const redirectTo = requestUrl.searchParams.get('redirect_to')?.toString();
if (code) {
const supabase = await createClient();
await supabase.auth.exchangeCodeForSession(code);
}
if (redirectTo) {
return NextResponse.redirect(`${origin}${redirectTo}`);
}
// URL to redirect to after sign up process completes
return NextResponse.redirect(`${origin}/protected`);
}

View File

@ -3,6 +3,7 @@ import '@/styles/globals.css';
import { Geist } from 'next/font/google';
import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme';
import Navigation from '@/components/navigation';
export const metadata: Metadata = {
title: 'T3 Template with Supabase',
@ -44,7 +45,10 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
>
<main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'>
<nav className=''>{children}</nav>
<Navigation />
<div className='flex flex-col gap-20 max-w-5xl p-5'>
{children}
</div>
</div>
</main>
</ThemeProvider>

View File

@ -1,37 +1,14 @@
import Link from 'next/link';
export default function HomePage() {
const HomePage = () => {
return (
<main className='flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white'>
<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>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8'>
<Link
className='flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20'
href='https://create.t3.gg/en/usage/first-steps'
target='_blank'
>
<h3 className='text-2xl font-bold'>First Steps </h3>
<div className='text-lg'>
Just the basics - Everything you need to know to set up your
database and authentication.
</div>
</Link>
<Link
className='flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20'
href='https://create.t3.gg/en/introduction'
target='_blank'
>
<h3 className='text-2xl font-bold'>Documentation </h3>
<div className='text-lg'>
Learn more about Create T3 App, the libraries it uses, and how to
deploy it.
</div>
</Link>
</div>
</div>
<main
className='flex min-h-screen flex-col items-center justify-center
bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white'
>
<div
className='container flex flex-col items-center justify-center
gap-12 px-4 py-16'
></div>
</main>
);
}
};
export default HomePage;

View File

@ -0,0 +1,40 @@
'use server';
import { FetchDataSteps } from '@/components/tutorial';
import { createClient } from '@/utils/supabase/server';
import { InfoIcon } from 'lucide-react';
import { redirect } from 'next/navigation';
const ProtectedPage = async () => {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return redirect('sign-in');
}
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 page 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-2xl mb-4'>Your user details</h2>
<pre className='text-xs font-mono p-3 rounded border max-h-32 overflow-auto'>
{JSON.stringify(user, null, 2)}
</pre>
</div>
<div>
<h2 className='font-bold text-2xl mb-4'>Next steps</h2>
<FetchDataSteps />
</div>
</div>
);
};
export default ProtectedPage;

View File

@ -0,0 +1,36 @@
import { resetPassword } from "@/actions/auth";
import { FormMessage, type Message, SubmitButton } from '@/components/default';
import { Input, Label } from "@/components/ui";
const ResetPassword = async (props: {
searchParams: Promise<Message>;
}) => {
const searchParams = await props.searchParams;
return (
<form className="flex flex-col w-full max-w-md p-4 gap-2 [&>input]:mb-4">
<h1 className="text-2xl font-medium">Reset password</h1>
<p className="text-sm text-foreground/60">
Please enter your new password below.
</p>
<Label htmlFor="password">New password</Label>
<Input
type="password"
name="password"
placeholder="New password"
required
/>
<Label htmlFor="confirmPassword">Confirm password</Label>
<Input
type="password"
name="confirmPassword"
placeholder="Confirm password"
required
/>
<SubmitButton formAction={resetPassword}>
Reset password
</SubmitButton>
<FormMessage message={searchParams} />
</form>
);
}
export default ResetPassword;

View File

@ -0,0 +1,24 @@
export type Message =
| { success: string }
| { error: string }
| { message: string };
export const FormMessage = ({ message }: { message: Message }) => {
return (
<div className='flex flex-col gap-2 w-full max-w-md text-sm'>
{'success' in message && (
<div className='text-foreground border-l-2 border-foreground px-4'>
{message.success}
</div>
)}
{'error' in message && (
<div className='text-destructive-foreground border-l-2 border-destructive-foreground px-4'>
{message.error}
</div>
)}
{'message' in message && (
<div className='text-foreground border-l-2 px-4'>{message.message}</div>
)}
</div>
);
};

View File

@ -0,0 +1,4 @@
import { FormMessage, type Message } from '@/components/default/form-message';
import { SubmitButton } from '@/components/default/submit-button';
export { FormMessage, type Message, SubmitButton };

View File

@ -0,0 +1,23 @@
'use client';
import { Button } from '@/components/ui/button';
import { type ComponentProps } from 'react';
import { useFormStatus } from 'react-dom';
type Props = ComponentProps<typeof Button> & {
pendingText?: string;
};
export const SubmitButton = ({
children,
pendingText = 'Submitting...',
...props
}: Props) => {
const { pending } = useFormStatus();
return (
<Button type='submit' aria-disabled={pending} {...props}>
{pending ? pendingText : children}
</Button>
);
};

View File

@ -0,0 +1,34 @@
'use server';
import Link from 'next/link';
import { Button } from '@/components/ui';
import { createClient } from '@/utils/supabase/server';
import { signOut } from '@/actions/auth';
const NavigationAuth = async () => {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
return user ? (
<div className='flex items-center gap-4'>
Hello, {user.email}!
<form action={signOut}>
<Button type='submit' variant='outline'>
Sign out
</Button>
</form>
</div>
) : (
<div className='flex gap-2'>
<Button asChild size='sm' variant={'outline'}>
<Link href='/sign-in'>Sign in</Link>
</Button>
<Button asChild size='sm' variant={'default'}>
<Link href='/sign-up'>Sign up</Link>
</Button>
</div>
);
};
export default NavigationAuth;

View File

@ -0,0 +1,32 @@
'use server';
import Link from 'next/link';
import { Button } from '@/components/ui';
import NavigationAuth from '@/components/navigation/auth';
const Navigation = () => {
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 href={'/'}>T3 Supabase Template</Link>
<div className='flex items-center gap-2'>
<Button asChild>
<Link href='https://git.gbrown.org/gib/T3-Template'>
Go to Git Repo
</Link>
</Button>
</div>
</div>
<NavigationAuth />
</div>
</nav>
);
};
export default Navigation;

View File

@ -0,0 +1,61 @@
'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

@ -0,0 +1,96 @@
import { TutorialStep, CodeBlock } from '@/components/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();
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>
);
};
export default FetchDataSteps;

View File

@ -0,0 +1,5 @@
import { CodeBlock } from '@/components/tutorial/code-block';
import FetchDataSteps from '@/components/tutorial/fetch-data-steps';
import { TutorialStep } from '@/components/tutorial/tutorial-step';
export { CodeBlock, FetchDataSteps, TutorialStep };

View File

@ -0,0 +1,30 @@
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

@ -0,0 +1,46 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot='badge'
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot='checkbox'
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot='checkbox-indicator'
className='flex items-center justify-center text-current transition-none'
>
<CheckIcon className='size-3.5' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@ -0,0 +1,7 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export { Badge, Button, Checkbox, Input, Label };

View File

@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };

View File

@ -0,0 +1,24 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };