Almost done with template perhaps. Have not tested yet though.
This commit is contained in:
parent
a2f61ece94
commit
eeffb93ab9
@ -16,6 +16,8 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.1",
|
||||||
|
"@radix-ui/react-label": "^2.1.6",
|
||||||
"@radix-ui/react-slot": "^1.2.2",
|
"@radix-ui/react-slot": "^1.2.2",
|
||||||
"@supabase/ssr": "^0.6.1",
|
"@supabase/ssr": "^0.6.1",
|
||||||
"@supabase/supabase-js": "^2.49.4",
|
"@supabase/supabase-js": "^2.49.4",
|
||||||
|
201
pnpm-lock.yaml
generated
201
pnpm-lock.yaml
generated
@ -8,6 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.2
|
specifier: ^1.2.2
|
||||||
version: 1.2.2(@types/react@19.1.4)(react@19.1.0)
|
version: 1.2.2(@types/react@19.1.4)(react@19.1.0)
|
||||||
@ -380,6 +386,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
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':
|
'@radix-ui/react-compose-refs@1.1.2':
|
||||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -389,6 +411,54 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-slot@1.2.2':
|
||||||
resolution: {integrity: sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==}
|
resolution: {integrity: sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -398,6 +468,51 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
@ -2475,12 +2590,64 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@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)':
|
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.4)(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.1.4
|
'@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)':
|
'@radix-ui/react-slot@1.2.2(@types/react@19.1.4)(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.0)
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.0)
|
||||||
@ -2488,6 +2655,40 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.1.4
|
'@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': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
'@rushstack/eslint-patch@1.11.0': {}
|
'@rushstack/eslint-patch@1.11.0': {}
|
||||||
|
138
src/actions/auth.ts
Normal file
138
src/actions/auth.ts
Normal 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');
|
||||||
|
};
|
37
src/app/(auth-pages)/forgot-password/page.tsx
Normal file
37
src/app/(auth-pages)/forgot-password/page.tsx
Normal 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;
|
6
src/app/(auth-pages)/layout.tsx
Normal file
6
src/app/(auth-pages)/layout.tsx
Normal 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;
|
43
src/app/(auth-pages)/sign-in/page.tsx
Normal file
43
src/app/(auth-pages)/sign-in/page.tsx
Normal 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'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;
|
54
src/app/(auth-pages)/sign-up/page.tsx
Normal file
54
src/app/(auth-pages)/sign-up/page.tsx
Normal 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;
|
25
src/app/(auth-pages)/smtp-message.tsx
Normal file
25
src/app/(auth-pages)/smtp-message.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
24
src/app/auth/callback/route.ts
Normal file
24
src/app/auth/callback/route.ts
Normal 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`);
|
||||||
|
}
|
@ -3,6 +3,7 @@ import '@/styles/globals.css';
|
|||||||
import { Geist } from 'next/font/google';
|
import { Geist } from 'next/font/google';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ThemeProvider } from '@/components/context/theme';
|
import { ThemeProvider } from '@/components/context/theme';
|
||||||
|
import Navigation from '@/components/navigation';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'T3 Template with Supabase',
|
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'>
|
<main className='min-h-screen flex flex-col items-center'>
|
||||||
<div className='flex-1 w-full flex flex-col gap-20 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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
@ -1,37 +1,14 @@
|
|||||||
import Link from 'next/link';
|
const HomePage = () => {
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
return (
|
||||||
<main className='flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white'>
|
<main
|
||||||
<div className='container flex flex-col items-center justify-center gap-12 px-4 py-16'>
|
className='flex min-h-screen flex-col items-center justify-center
|
||||||
<h1 className='text-5xl font-extrabold tracking-tight text-white sm:text-[5rem]'>
|
bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white'
|
||||||
Create <span className='text-[hsl(280,100%,70%)]'>T3</span> App
|
>
|
||||||
</h1>
|
<div
|
||||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8'>
|
className='container flex flex-col items-center justify-center
|
||||||
<Link
|
gap-12 px-4 py-16'
|
||||||
className='flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20'
|
></div>
|
||||||
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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
export default HomePage;
|
||||||
|
40
src/app/protected/page.tsx
Normal file
40
src/app/protected/page.tsx
Normal 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;
|
36
src/app/protected/reset-password/page.tsx
Normal file
36
src/app/protected/reset-password/page.tsx
Normal 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;
|
24
src/components/default/form-message.tsx
Normal file
24
src/components/default/form-message.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
4
src/components/default/index.tsx
Normal file
4
src/components/default/index.tsx
Normal 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 };
|
23
src/components/default/submit-button.tsx
Normal file
23
src/components/default/submit-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
34
src/components/navigation/auth.tsx
Normal file
34
src/components/navigation/auth.tsx
Normal 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;
|
32
src/components/navigation/index.tsx
Normal file
32
src/components/navigation/index.tsx
Normal 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;
|
61
src/components/tutorial/code-block.tsx
Normal file
61
src/components/tutorial/code-block.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
96
src/components/tutorial/fetch-data-steps.tsx
Normal file
96
src/components/tutorial/fetch-data-steps.tsx
Normal 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'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're ready to launch your product to the world! 🚀</p>
|
||||||
|
</TutorialStep>
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default FetchDataSteps;
|
5
src/components/tutorial/index.tsx
Normal file
5
src/components/tutorial/index.tsx
Normal 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 };
|
30
src/components/tutorial/tutorial-step.tsx
Normal file
30
src/components/tutorial/tutorial-step.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal 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 };
|
32
src/components/ui/checkbox.tsx
Normal file
32
src/components/ui/checkbox.tsx
Normal 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 };
|
7
src/components/ui/index.tsx
Normal file
7
src/components/ui/index.tsx
Normal 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 };
|
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 };
|
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 };
|
Loading…
x
Reference in New Issue
Block a user