Compare commits

...

20 Commits

Author SHA1 Message Date
Gib
7d7ed00c22 Add middleware to block misbehaving clients 2025-06-11 09:31:19 -05:00
Gib
42b07ea2da Move to tabs over spaces! 2025-06-09 09:57:27 -05:00
Gib
476d6c91b4 Update context imports for simplicity and consistency 2025-06-09 07:42:13 -05:00
Gib
6a6c0934d5 Got Apple Auth working 2025-06-08 18:31:35 -05:00
Gib
c47c43dc92 Committing because I really need to restart my laptop or something :( 2025-06-08 16:14:26 -05:00
Gib
5c5e992e7d Cleaned up some stuff. Messing with sign in button components. 2025-06-08 13:32:17 -05:00
Gib
23efcdee80 Adding Sentry is basically done now. We have a nice lil component to help us get started to! 2025-06-07 14:30:25 -05:00
Gib
eebc022928 Add sentry to template. Got a small error but we are gonna fix it soon! 2025-06-07 11:44:54 -05:00
Gib
930dc0867d Sign in with microsoft now works but sign out when signed in via microsoft does not 2025-06-06 16:35:28 -05:00
Gib
35e019558f Cleanup. Stuff from yesterday idk 2025-06-06 08:43:18 -05:00
Gib
a776c5a30a Minor changes 2025-06-05 09:24:39 -05:00
Gib
3e0c23054a Committing to hopefully figure out why photos aren't working in next.js 2025-06-04 23:16:12 -05:00
Gib
f51e78ed2f Start adding apple auth 2025-06-04 17:00:19 -05:00
Gib
ab7559555e Microsoft Login is mostly working now 2025-06-04 16:28:06 -05:00
Gib
04dceb93bd Reaching a finishing point! 2025-06-04 14:33:39 -05:00
Gib
bfb6e9e648 Fix bug in auth provider 2025-06-04 10:36:02 -05:00
Gib
e2f291e707 Made more client components to make the rendering not so crazy 2025-06-04 10:28:37 -05:00
Gib
ef24642128 update auth. deal with migraine 2025-06-03 17:00:53 -05:00
Gib
a9e7d8e126 Clean up icons 2025-06-01 16:45:15 -05:00
Gib
f49488123b Add default metadata & make replaceFile function 2025-06-01 16:36:29 -05:00
112 changed files with 7377 additions and 3336 deletions

View File

@ -1,11 +1,3 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js" # When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly. # should be updated accordingly.
@ -13,14 +5,26 @@
# SERVERVAR="foo" # SERVERVAR="foo"
# NEXT_PUBLIC_CLIENTVAR="bar" # NEXT_PUBLIC_CLIENTVAR="bar"
# Server Variables ### Server Variables ###
NODE_ENV= # Next Variables # Default Values:
#NODE_ENV= # development
#SKIP_ENV_VALIDATION= # false
# Sentry Variables # Default Values:
SENTRY_AUTH_TOKEN=
#CI= # true
# Client Variables ### Client Variables ###
# Next Variables # Default Values:
#NEXT_PUBLIC_SITE_URL= # http://localhost:3000
# Supabase Variables
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Sentry Variables
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_URL= # https://sentry.gbrown.org
# Script Variables # Default Values ### Script Variables ### These variables are only needed for our scripts, so do not add these to env.js! ###
# generateTypes # Default Values:
SUPABASE_DB_PASSWORD= SUPABASE_DB_PASSWORD=
#SUPABASE_DB_PORT= # 5432 #SUPABASE_DB_PORT= # 5432
#SUPABASE_DB_USER= # postgres #SUPABASE_DB_USER= # postgres

4
.gitignore vendored
View File

@ -43,4 +43,6 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
# idea files # idea files
.idea .idea
# Sentry Config File
.env.sentry-build-plugin

View File

@ -1,5 +1,6 @@
{ {
"singleQuote": true, "singleQuote": true,
"jsxSingleQuote": true, "jsxSingleQuote": true,
"trailingComma": "all" "trailingComma": "all",
"useTabs": true
} }

View File

@ -1,48 +1,53 @@
import { FlatCompat } from '@eslint/eslintrc'; import { FlatCompat } from '@eslint/eslintrc';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
const compat = new FlatCompat({ const compat = new FlatCompat({
baseDirectory: import.meta.dirname, baseDirectory: import.meta.dirname,
}); });
export default tseslint.config( export default tseslint.config(
{ {
ignores: ['.next'], ignores: ['.next'],
}, },
...compat.extends('next/core-web-vitals'), ...compat.extends('next/core-web-vitals'),
{ {
files: ['**/*.ts', '**/*.tsx'], files: ['**/*.ts', '**/*.tsx'],
extends: [ extends: [
...tseslint.configs.recommended, ...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked, ...tseslint.configs.stylisticTypeChecked,
], eslintPluginPrettierRecommended,
rules: { ],
'@typescript-eslint/array-type': 'off', rules: {
'@typescript-eslint/consistent-type-definitions': 'off', '@typescript-eslint/array-type': 'off',
'@typescript-eslint/consistent-type-imports': [ '@typescript-eslint/consistent-type-definitions': 'off',
'warn', '@typescript-eslint/consistent-type-imports': [
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' }, 'warn',
], { prefer: 'type-imports', fixStyle: 'inline-type-imports' },
'@typescript-eslint/no-unused-vars': [ ],
'warn', '@typescript-eslint/no-unused-vars': [
{ argsIgnorePattern: '^_' }, 'warn',
], { argsIgnorePattern: '^_' },
'@typescript-eslint/require-await': 'off', ],
'@typescript-eslint/no-misused-promises': [ '@typescript-eslint/require-await': 'off',
'error', '@typescript-eslint/no-misused-promises': [
{ checksVoidReturn: { attributes: false } }, 'error',
], { checksVoidReturn: { attributes: false } },
}, ],
}, '@typescript-eslint/no-explicit-any': 'warn',
{ '@typescript-eslint/no-floating-promises': 'warn',
linterOptions: { '@typescript-eslint/no-unsafe-argument': 'warn',
reportUnusedDisableDirectives: true, },
}, },
languageOptions: { {
parserOptions: { linterOptions: {
projectService: true, reportUnusedDisableDirectives: true,
}, },
}, languageOptions: {
}, parserOptions: {
projectService: true,
},
},
},
); );

View File

@ -1,25 +1,67 @@
/** /* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful * This is especially useful for Docker builds.
* for Docker builds.
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output: 'standalone', output: 'standalone',
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: 'https',
hostname: '*.gbrown.org', hostname: '*.gbrown.org',
}, },
], ],
}, },
experimental: { serverExternalPackages: ['require-in-the-middle'],
serverActions: { experimental: {
bodySizeLimit: '10mb', serverActions: {
}, bodySizeLimit: '10mb',
}, },
},
turbopack: {
rules: {
'*.svg': {
loaders: [
{
loader: '@svgr/webpack',
options: {
icon: true,
},
},
],
as: '*.js',
},
},
},
}; };
export default config; const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: 'gib',
project: 't3-supabase-template',
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
export default withSentryConfig(config, sentryConfig);

View File

@ -7,6 +7,7 @@
"build": "next build", "build": "next build",
"check": "next lint && tsc --noEmit", "check": "next lint && tsc --noEmit",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"dev:slow": "next dev",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint", "lint": "next lint",
@ -16,15 +17,16 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.1.0",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@sentry/nextjs": "^9.27.0",
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.50.0",
"@t3-oss/env-nextjs": "^0.12.0", "@t3-oss/env-nextjs": "^0.12.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -33,30 +35,36 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.56.4", "react-hook-form": "^7.57.0",
"sonner": "^2.0.4", "require-in-the-middle": "^7.5.2",
"zod": "^3.25.42" "sonner": "^2.0.5",
"zod": "^3.25.56"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@types/node": "^20.17.57", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^20.19.0",
"@types/react": "^19.1.6", "@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.6",
"eslint": "^9.28.0", "eslint": "^9.28.0",
"eslint-config-next": "^15.3.3", "eslint-config-next": "^15.3.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.1",
"import-in-the-middle": "^1.14.0",
"postcss": "^8.5.4", "postcss": "^8.5.4",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.12", "prettier-plugin-tailwindcss": "^0.6.12",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.2", "tw-animate-css": "^1.3.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.33.0" "typescript-eslint": "^8.33.1"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"
}, },
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
} }

2816
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- '@sentry/cli'
- '@tailwindcss/oxide' - '@tailwindcss/oxide'
- sharp - sharp
- unrs-resolver - unrs-resolver

View File

@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, '@tailwindcss/postcss': {},
}, },
}; };

View File

@ -1,4 +1,4 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
export default { export default {
plugins: ['prettier-plugin-tailwindcss'], plugins: ['prettier-plugin-tailwindcss'],
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/appicon/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

19
public/icons/apple.svg Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-1.5 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>apple [#173]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-102.000000, -7439.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M57.5708873,7282.19296 C58.2999598,7281.34797 58.7914012,7280.17098 58.6569121,7279 C57.6062792,7279.04 56.3352055,7279.67099 55.5818643,7280.51498 C54.905374,7281.26397 54.3148354,7282.46095 54.4735932,7283.60894 C55.6455696,7283.69593 56.8418148,7283.03894 57.5708873,7282.19296 M60.1989864,7289.62485 C60.2283111,7292.65181 62.9696641,7293.65879 63,7293.67179 C62.9777537,7293.74279 62.562152,7295.10677 61.5560117,7296.51675 C60.6853718,7297.73474 59.7823735,7298.94772 58.3596204,7298.97372 C56.9621472,7298.99872 56.5121648,7298.17973 54.9134635,7298.17973 C53.3157735,7298.17973 52.8162425,7298.94772 51.4935978,7298.99872 C50.1203933,7299.04772 49.0738052,7297.68074 48.197098,7296.46676 C46.4032359,7293.98379 45.0330649,7289.44985 46.8734421,7286.3899 C47.7875635,7284.87092 49.4206455,7283.90793 51.1942837,7283.88393 C52.5422083,7283.85893 53.8153044,7284.75292 54.6394294,7284.75292 C55.4635543,7284.75292 57.0106846,7283.67793 58.6366882,7283.83593 C59.3172232,7283.86293 61.2283842,7284.09893 62.4549652,7285.8199 C62.355868,7285.8789 60.1747177,7287.09489 60.1989864,7289.62485" id="apple-[#173]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#F35325" d="M1 1h6.5v6.5H1V1z"/><path fill="#81BC06" d="M8.5 1H15v6.5H8.5V1z"/><path fill="#05A6F0" d="M1 8.5h6.5V15H1V8.5z"/><path fill="#FFBA08" d="M8.5 8.5H15V15H8.5V8.5z"/></svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@ -3,29 +3,65 @@
* for Docker builds. * for Docker builds.
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output: 'standalone', output: 'standalone',
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: 'https',
hostname: '*.gbrown.org', hostname: '*.gbrown.org',
}, },
], ],
}, },
experimental: { serverExternalPackages: ['require-in-the-middle'],
serverActions: { experimental: {
bodySizeLimit: '10mb', serverActions: {
}, bodySizeLimit: '10mb',
}, },
typescript: { },
ignoreBuildErrors: true, typescript: {
}, ignoreBuildErrors: true,
eslint: { },
ignoreDuringBuilds: true, eslint: {
}, ignoreDuringBuilds: true,
},
//turbopack: {
//rules: {
//'*.svg': {
//loaders: ['@svgr/webpack'],
//as: '*.js',
//},
//},
//},
}; };
export default config; const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: 'gib',
project: 't3-supabase-template',
sentryUrl: process.env.SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
export default withSentryConfig(config, sentryConfig);

View File

@ -3,23 +3,59 @@
* for Docker builds. * for Docker builds.
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output: 'standalone', output: 'standalone',
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: 'https',
hostname: '*.gbrown.org', hostname: '*.gbrown.org',
}, },
], ],
}, },
experimental: { serverExternalPackages: ['require-in-the-middle'],
serverActions: { experimental: {
bodySizeLimit: '10mb', serverActions: {
}, bodySizeLimit: '10mb',
}, },
},
//turbopack: {
//rules: {
//'*.svg': {
//loaders: ['@svgr/webpack'],
//as: '*.js',
//},
//},
//},
}; };
export default config; const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: 'gib',
project: 't3-supabase-template',
sentryUrl: process.env.SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
export default withSentryConfig(config, sentryConfig);

15
sentry.server.config.ts Normal file
View File

@ -0,0 +1,15 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: 'https://0468176d5291bc2b914261147bfef117@sentry.gbrown.org/6',
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@ -1,5 +1,4 @@
'use server'; 'use server';
import 'server-only'; import 'server-only';
import { createServerClient } from '@/utils/supabase'; import { createServerClient } from '@/utils/supabase';
import { type EmailOtpType } from '@supabase/supabase-js'; import { type EmailOtpType } from '@supabase/supabase-js';
@ -7,27 +6,38 @@ import { type NextRequest } from 'next/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export const GET = async (request: NextRequest) => { export const GET = async (request: NextRequest) => {
const { searchParams } = new URL(request.url); const { searchParams, origin } = new URL(request.url);
const token_hash = searchParams.get('token'); const code = searchParams.get('code');
const type = searchParams.get('type') as EmailOtpType | null; const token_hash = searchParams.get('token');
const redirectTo = searchParams.get('redirect_to') ?? '/'; const type = searchParams.get('type') as EmailOtpType | null;
const redirectTo = searchParams.get('redirect_to') ?? '/';
const supabase = await createServerClient();
if (token_hash && type) { if (code) {
const supabase = await createServerClient(); const { error } = await supabase.auth.exchangeCodeForSession(code);
const { error } = await supabase.auth.verifyOtp({ if (error) {
type, console.error('OAuth error:', error);
token_hash, return redirect(`/sign-in?error=${encodeURIComponent(error.message)}`);
}); }
if (!error) { return redirect(redirectTo);
if (type === 'signup' || type === 'magiclink' || type === 'email') }
return redirect('/');
if (type === 'recovery' || type === 'email_change') if (token_hash && type) {
return redirect('/profile'); const { error } = await supabase.auth.verifyOtp({
if (type === 'invite') type,
return redirect('/sign-up'); token_hash,
else return redirect(`/?Could not identify type ${type as string}`) });
} if (!error) {
else return redirect(`/?${error.message}`); if (type === 'signup' || type === 'magiclink' || type === 'email')
} return redirect('/');
return redirect('/'); if (type === 'recovery' || type === 'email_change')
return redirect('/profile');
if (type === 'invite') return redirect('/sign-up');
}
return redirect(
`/?error=${encodeURIComponent(error?.message || 'Unknown error')}`,
);
}
return redirect('/');
}; };

View File

@ -1,77 +0,0 @@
'use server';
import 'server-only';
import { createServerClient } from '@/utils/supabase';
import { type EmailOtpType } from '@supabase/supabase-js';
import { type NextRequest, NextResponse } from 'next/server';
export const GET = async (request: NextRequest) => {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const token = searchParams.get('token');
const type = searchParams.get('type') as EmailOtpType | null;
const redirectTo = searchParams.get('redirect_to')?.toString();
const supabase = await createServerClient();
if (token && type) {
try {
if (type === 'signup') {
// Confirm email signup
const { error } = await supabase.auth.verifyOtp({
token_hash: token,
type: 'signup',
});
if (error) {
console.error('Email confirmation error:', error);
return NextResponse.redirect(`${origin}/sign-in?error=Invalid or expired confirmation link`);
}
} else if (type === 'recovery') {
// Handle password recovery
const { error } = await supabase.auth.verifyOtp({
token_hash: token,
type: 'recovery',
});
if (error) {
console.error('Password recovery error:', error);
return NextResponse.redirect(`${origin}/sign-in?error=Invalid or expired reset link`);
} else {
return NextResponse.redirect(`${origin}/reset-password`);
}
} else if (type === 'email_change') {
// Handle email change
const { error } = await supabase.auth.verifyOtp({
token_hash: token,
type: 'email_change',
});
if (error) {
console.error('Email change error:', error);
return NextResponse.redirect(`${origin}/profile?error=Invalid or expired email change link`);
}
}
} catch (error) {
console.error('Verification error:', error);
return NextResponse.redirect(`${origin}/sign-in?error=Verification failed`);
}
}
// Handle code-based flow (OAuth, etc.)
if (code) {
await supabase.auth.exchangeCodeForSession(code);
}
// Handle redirect
if (redirectTo) {
try {
new URL(redirectTo);
return NextResponse.redirect(redirectTo);
} catch {
return NextResponse.redirect(`${origin}${redirectTo}`);
}
}
return NextResponse.redirect(origin);
}

View File

@ -0,0 +1,39 @@
'use client';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Loader2 } from 'lucide-react';
const AuthSuccessPage = () => {
const { refreshUserData } = useAuth();
const router = useRouter();
useEffect(() => {
const handleAuthSuccess = async () => {
// Refresh the auth context to pick up the new session
await refreshUserData();
// Small delay to ensure state is updated
setTimeout(() => {
router.push('/');
}, 100);
};
handleAuthSuccess().catch((error) => {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
});
}, [refreshUserData, router]);
// Show loading while processing
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='flex flex-col items-center space-y-4'>
<Loader2 className='h-8 w-8 animate-spin' />
<p>Completing sign in...</p>
</div>
</div>
);
};
export default AuthSuccessPage;

View File

@ -1,37 +1,129 @@
'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 Link from 'next/link';
import { forgotPassword } from '@/lib/actions'; import { forgotPassword } from '@/lib/actions';
import { FormMessage, type Message, SubmitButton } from '@/components/default'; import { useRouter } from 'next/navigation';
import { Input, Label } from '@/components/ui'; import { useAuth } from '@/components/context';
import { SmtpMessage } from '@/app/(auth-pages)/smtp-message'; import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
const ForgotPassword = async (props: { searchParams: Promise<Message> }) => { const formSchema = z.object({
const searchParams = await props.searchParams; email: z.string().email({
return ( message: 'Please enter a valid email address.',
<> }),
<form });
className='flex-1 flex flex-col w-full gap-2 text-foreground
[&>input]:mb-6 min-w-64 max-w-64 mx-auto' const ForgotPassword = () => {
> const router = useRouter();
<div> const { isAuthenticated, isLoading, refreshUserData } = useAuth();
<h1 className='text-2xl font-medium'>Reset Password</h1> const [statusMessage, setStatusMessage] = useState('');
<p className='text-sm text-secondary-foreground'>
Already have an account?{' '} const form = useForm<z.infer<typeof formSchema>>({
<Link className='text-primary underline' href='/sign-in'> resolver: zodResolver(formSchema),
Sign in defaultValues: {
</Link> email: '',
</p> },
</div> });
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'>
<Label htmlFor='email'>Email</Label> // Redirect if already authenticated
<Input name='email' placeholder='you@example.com' required /> useEffect(() => {
<SubmitButton formAction={forgotPassword}> if (isAuthenticated) {
Reset Password router.push('/');
</SubmitButton> }
<FormMessage message={searchParams} /> }, [isAuthenticated, router]);
</div>
</form> const handleForgotPassword = async (values: z.infer<typeof formSchema>) => {
<SmtpMessage /> 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>
);
}; };
export default ForgotPassword; export default ForgotPassword;

View File

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

View File

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

View File

@ -1,53 +1,210 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import Link from 'next/link'; import Link from 'next/link';
import { signUp } from '@/lib/actions'; import { signUp } from '@/lib/actions';
import { FormMessage, type Message, SubmitButton } from '@/components/default'; import { StatusMessage, SubmitButton } from '@/components/default';
import { Input, Label } from '@/components/ui'; import { useRouter } from 'next/navigation';
import { redirect } from 'next/navigation'; import { useAuth } from '@/components/context';
import { getUser } from '@/lib/actions'; 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 SignUp = async (props: { searchParams: Promise<Message> }) => { const formSchema = z
const searchParams = await props.searchParams; .object({
const user = await getUser(); name: z.string().min(2, {
if (user.success) redirect('/profile'); message: 'Name must be at least 2 characters.',
if ('message' in searchParams) { }),
return ( email: z.string().email({
<div message: 'Please enter a valid email address.',
className='w-full flex-1 flex items-center h-screen }),
sm:max-w-md justify-center gap-2 p-4' password: z.string().min(8, {
> message: 'Password must be at least 8 characters.',
<FormMessage message={searchParams} /> }),
</div> confirmPassword: z.string().min(8, {
); message: 'Password must be at least 8 characters.',
} else { }),
return ( })
<form className='flex flex-col min-w-64 max-w-64 mx-auto'> .refine((data) => data.password === data.confirmPassword, {
<h1 className='text-2xl font-medium'>Sign up</h1> message: 'Passwords do not match!',
<p className='text-sm text text-foreground'> path: ['confirmPassword'],
Already have an account?{' '} });
<Link className='text-primary font-medium underline' href='/sign-in'>
Sign in const SignUp = () => {
</Link> const router = useRouter();
</p> const { isAuthenticated, isLoading, refreshUserData } = useAuth();
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'> const [statusMessage, setStatusMessage] = useState('');
<Label htmlFor='name'>Name</Label>
<Input name='name' placeholder='Full Name' required /> const form = useForm<z.infer<typeof formSchema>>({
<Label htmlFor='email'>Email</Label> resolver: zodResolver(formSchema),
<Input name='email' placeholder='you@example.com' required /> defaultValues: {
<Label htmlFor='password'>Password</Label> name: '',
<Input email: '',
type='password' password: '',
name='password' confirmPassword: '',
placeholder='Your password' },
minLength={6} mode: 'onChange',
required });
/>
<SubmitButton formAction={signUp} pendingText='Signing up...'> // Redirect if already authenticated
Sign up useEffect(() => {
</SubmitButton> if (isAuthenticated) {
<FormMessage message={searchParams} /> router.push('/');
</div> }
</form> }, [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 type='signUp' />
<SignInWithApple type='signUp' />
</CardContent>
</Card>
);
}; };
export default SignUp; export default SignUp;

View File

@ -1,25 +0,0 @@
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,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...' });
}

79
src/app/global-error.tsx Normal file
View File

@ -0,0 +1,79 @@
'use client';
import '@/styles/globals.css';
import { cn } from '@/lib/utils';
import { AuthProvider, ThemeProvider } from '@/components/context';
import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer';
import { Button, Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
import { Geist } from 'next/font/google';
const geist = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
});
type GlobalErrorProps = {
error: Error & { digest?: string };
reset?: () => void;
};
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
<body
className={cn('bg-background text-foreground font-sans antialiased')}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<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 />
<div
className='flex flex-col gap-20 max-w-5xl
p-5 w-full items-center'
>
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try again</Button>
)}
</div>
</div>
<Footer />
</main>
<Toaster />
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
return (
<html lang='en'>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try again</Button>
)}
</body>
</html>
);
};
export default GlobalError;

View File

@ -1,66 +1,378 @@
import { type Metadata } from 'next'; import type { Metadata } from 'next';
import '@/styles/globals.css'; 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 { AuthProvider, ThemeProvider } from '@/components/context';
import { AuthProvider } from '@/components/context/auth'
import Navigation from '@/components/default/navigation'; import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer'; import Footer from '@/components/default/footer';
import { Toaster } from '@/components/ui'; import { Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs';
export const metadata: Metadata = { export const generateMetadata = (): Metadata => {
title: 'T3 Template with Supabase', return {
description: 'Generated by create-t3-app', title: {
icons: [ template: '%s | T3 Template',
{ default: 'T3 Template with Supabase',
rel: 'icon', },
url: '/images/favicon.ico', description: 'Created by Gib with T3!',
}, applicationName: 'T3 Template',
{ keywords:
rel: 'icon', 'T3 Template, Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib, Theo',
type: 'image/png', authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
sizes: '32x32', creator: 'Gib Brown',
url: '/images/favicon.png', publisher: 'Gib Brown',
}, formatDetection: {
{ email: false,
rel: 'apple-touch-icon', address: false,
url: '/images/appicon.png', telephone: false,
}, },
], robots: {
index: true,
follow: true,
nocache: false,
googleBot: {
index: true,
follow: true,
noimageindex: false,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
icons: {
icon: [
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
{ url: '/favicon-16x16.png', type: 'image/png', sizes: '16x16' },
{ url: '/favicon-32x32.png', type: 'image/png', sizes: '32x32' },
{ url: '/favicon.png', type: 'image/png', sizes: '96x96' },
{
url: '/favicon.ico',
type: 'image/x-icon',
sizes: 'any',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-16x16.png',
type: 'image/png',
sizes: '16x16',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-32x32.png',
type: 'image/png',
sizes: '32x32',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{ url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36' },
{ url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48' },
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' },
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{ url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' },
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
shortcut: [
{ url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36' },
{ url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48' },
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' },
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{ url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' },
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
apple: [
{ url: 'appicon/icon-57x57.png', type: 'image/png', sizes: '57x57' },
{ url: 'appicon/icon-60x60.png', type: 'image/png', sizes: '60x60' },
{ url: 'appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
{ url: 'appicon/icon-76x76.png', type: 'image/png', sizes: '76x76' },
{
url: 'appicon/icon-114x114.png',
type: 'image/png',
sizes: '114x114',
},
{
url: 'appicon/icon-120x120.png',
type: 'image/png',
sizes: '120x120',
},
{
url: 'appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: 'appicon/icon-152x152.png',
type: 'image/png',
sizes: '152x152',
},
{
url: 'appicon/icon-180x180.png',
type: 'image/png',
sizes: '180x180',
},
{ url: 'appicon/icon.png', type: 'image/png', sizes: '192x192' },
{
url: 'appicon/icon-57x57.png',
type: 'image/png',
sizes: '57x57',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-60x60.png',
type: 'image/png',
sizes: '60x60',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-76x76.png',
type: 'image/png',
sizes: '76x76',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-114x114.png',
type: 'image/png',
sizes: '114x114',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-120x120.png',
type: 'image/png',
sizes: '120x120',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-152x152.png',
type: 'image/png',
sizes: '152x152',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-180x180.png',
type: 'image/png',
sizes: '180x180',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
other: [
{
rel: 'apple-touch-icon-precomposed',
url: '/appicon/icon-precomposed.png',
type: 'image/png',
sizes: '180x180',
},
],
},
other: {
...Sentry.getTraceData(),
},
twitter: {
card: 'app',
title: 'T3 Template',
description: 'Created by Gib with T3!',
siteId: '',
creator: '@cs_gib',
creatorId: '',
images: {
url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
alt: 'T3 Template',
},
app: {
name: 'T3 Template',
id: {
iphone: '',
ipad: '',
googleplay: '',
},
url: {
iphone: '',
ipad: '',
googleplay: '',
},
},
},
verification: {
google: 'google',
yandex: 'yandex',
yahoo: 'yahoo',
},
itunes: {
appId: '',
appArgument: '',
},
appleWebApp: {
title: 'T3 Template',
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://t3-template.gbrown.org/ios',
app_store_id: 't3_template',
},
android: {
package: 'org.gbrown.android/t3-template',
app_name: 'app_t3_template',
},
web: {
url: 'https://t3-template.gbrown.org/web',
should_fallback: true,
},
},
facebook: {
appId: '',
},
pinterest: {
richPin: true,
},
category: 'technology',
};
}; };
const geist = Geist({ const geist = Geist({
subsets: ['latin'], subsets: ['latin'],
variable: '--font-geist-sans', variable: '--font-geist-sans',
}); });
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return ( return (
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning> <html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
<body <body
className={cn('bg-background text-foreground font-sans antialiased')} className={cn('bg-background text-foreground font-sans antialiased')}
> >
<ThemeProvider <ThemeProvider
attribute='class' attribute='class'
defaultTheme='system' defaultTheme='system'
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<AuthProvider> <AuthProvider>
<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'>
<Navigation /> <Navigation />
<div className='flex flex-col gap-20 max-w-5xl p-5 w-full'> <div
{children} className='flex flex-col gap-20 max-w-5xl
</div> p-5 w-full items-center'
</div> >
<Footer /> {children}
</main> </div>
<Toaster /> </div>
</AuthProvider> <Footer />
</ThemeProvider> </main>
</body> <Toaster />
</html> </AuthProvider>
); </ThemeProvider>
</body>
</html>
);
}; };
export default RootLayout; export default RootLayout;

View File

@ -4,45 +4,93 @@ import { FetchDataSteps } from '@/components/default/tutorial';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { getUser } from '@/lib/actions'; import { getUser } from '@/lib/actions';
import type { User } from '@/utils/supabase'; 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';
const HomePage = async () => { const HomePage = async () => {
const response = await getUser(); const response = await getUser();
if (!response.success || !response.data) { if (!response.success || !response.data) {
return ( return (
<main className='w-full items-center justify-center'> <main className='w-full items-center justify-center'>
<div className='flex p-5 items-center justify-center'> <div className='flex flex-col p-5 items-center justify-center space-y-6'>
<h1>Make sure you can sign in!</h1> <Card className='md:min-w-2xl'>
</div> <CardHeader className='flex flex-col items-center'>
</main> <CardTitle className='text-3xl'>
); Welcome to the T3 Supabase Template!
} </CardTitle>
const user: User = response.data; <CardDescription className='text-[1.0rem] mb-2'>
return ( A great place to start is by creating a new user account &
<div className='flex-1 w-full flex flex-col gap-12'> ensuring you can sign up! If you already have an account, go
<div className='w-full'> ahead and sign in!
<div </CardDescription>
className='bg-accent text-sm p-3 px-5 <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>
);
}
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' rounded-md text-foreground flex gap-3 items-center'
> >
<InfoIcon size='16' strokeWidth={2} /> <InfoIcon size='16' strokeWidth={2} />
This is a protected component that you can only see as an This is a protected component that you can only see as an
authenticated user authenticated user
</div> </div>
</div> </div>
<div className='flex flex-col gap-2 items-start'> <div className='flex flex-col gap-2 items-start'>
<h2 className='font-bold text-2xl mb-4'>Your user details</h2> <h2 className='font-bold text-3xl mb-4'>Your user details</h2>
<pre <pre
className='text-xs font-mono p-3 rounded className='text-sm font-mono p-3 rounded
border max-h-32 overflow-auto' border max-h-50 overflow-auto'
> >
{JSON.stringify(user, null, 2)} {JSON.stringify(user, null, 2)}
</pre> </pre>
</div> </div>
<div> <TestSentryCard />
<h2 className='font-bold text-2xl mb-4'>Next steps</h2> <div>
<FetchDataSteps /> <h2 className='font-bold text-2xl mb-4'>Next steps</h2>
</div> <FetchDataSteps />
</div> </div>
); </div>
);
}; };
export default HomePage; export default HomePage;

View File

@ -0,0 +1,195 @@
'use client';
import React, {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import {
getProfile,
getSignedUrl,
getUser,
updateProfile as updateProfileAction,
} from '@/lib/hooks';
import { type User, type Profile, createClient } from '@/utils/supabase';
import { toast } from 'sonner';
type AuthContextType = {
user: User | null;
profile: Profile | null;
avatarUrl: string | null;
isLoading: boolean;
isAuthenticated: boolean;
updateProfile: (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
refreshUserData: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const fetchingRef = useRef(false);
const fetchUserData = useCallback(
async (showLoading = true) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
// Only show loading for initial load or manual refresh
if (showLoading) {
setIsLoading(true);
}
const userResponse = await getUser();
const profileResponse = await getProfile();
if (!userResponse.success || !profileResponse.success) {
setUser(null);
setProfile(null);
setAvatarUrl(null);
return;
}
setUser(userResponse.data);
setProfile(profileResponse.data);
// Get avatar URL if available
if (profileResponse.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: profileResponse.data.avatar_url,
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
} else {
setAvatarUrl(null);
}
} else {
setAvatarUrl(null);
}
} catch (error) {
console.error(
'Auth fetch error: ',
error instanceof Error
? `${error.message}`
: 'Failed to load user data!',
);
if (!isInitialized) {
toast.error('Failed to load user data!');
}
} finally {
if (showLoading) {
setIsLoading(false);
}
setIsInitialized(true);
fetchingRef.current = false;
}
},
[isInitialized],
);
useEffect(() => {
const supabase = createClient();
// Initial fetch with loading
fetchUserData(true).catch((error) => {
console.error('💥 Initial fetch error:', error);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
console.log('Auth state change:', event); // Debug log
if (event === 'SIGNED_IN') {
// Background refresh without loading state
await fetchUserData(false);
} else if (event === 'SIGNED_OUT') {
setUser(null);
setProfile(null);
setAvatarUrl(null);
setIsLoading(false);
} else if (event === 'TOKEN_REFRESHED') {
// Silent refresh - don't show loading
await fetchUserData(false);
}
});
return () => {
subscription.unsubscribe();
};
}, [fetchUserData]);
const updateProfile = useCallback(
async (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => {
try {
const result = await updateProfileAction(data);
if (!result.success) {
throw new Error(result.error ?? 'Failed to update profile');
}
setProfile(result.data);
// If avatar was updated, refresh the avatar URL
if (data.avatar_url && result.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: result.data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
}
}
toast.success('Profile updated successfully!');
return { success: true, data: result.data };
} catch (error) {
console.error('Error updating profile:', error);
toast.error(
error instanceof Error ? error.message : 'Failed to update profile',
);
return { success: false, error };
}
},
[],
);
const refreshUserData = useCallback(async () => {
await fetchUserData(true); // Manual refresh shows loading
}, [fetchUserData]);
const value = {
user,
profile,
avatarUrl,
isLoading,
isAuthenticated: !!user,
updateProfile,
refreshUserData,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -0,0 +1,68 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui';
export const ThemeProvider = ({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) => {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};
export interface ThemeToggleProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: number;
}
export const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => {
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant='outline' size='icon' {...props}>
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
</Button>
);
}
const toggleTheme = () => {
if (resolvedTheme === 'dark') setTheme('light');
else setTheme('dark');
};
return (
<Button
variant='outline'
size='icon'
className='cursor-pointer'
onClick={toggleTheme}
{...props}
>
<Sun
style={{ height: `${size}rem`, width: `${size}rem` }}
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
/>
<Moon
style={{ height: `${size}rem`, width: `${size}rem` }}
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
/>
<span className='sr-only'>Toggle theme</span>
</Button>
);
};

View File

@ -1,176 +0,0 @@
'use client';
import React, {
type ReactNode,
createContext,
useContext,
useEffect,
useState,
} from 'react';
import {
getProfile,
getSignedUrl,
getUser,
updateProfile as updateProfileAction,
} from '@/lib/actions';
import {
type User,
type Profile,
createClient,
} from '@/utils/supabase';
import { toast } from 'sonner';
type AuthContextType = {
user: User | null;
profile: Profile | null;
avatarUrl: string | null;
isLoading: boolean;
isAuthenticated: boolean;
updateProfile: (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
refreshUserData: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchUserData = async () => {
try {
setIsLoading(true);
// Get user data
const userResponse = await getUser();
if (!userResponse.success) {
setUser(null);
setProfile(null);
setAvatarUrl(null);
return;
}
setUser(userResponse.data);
// Get profile data
const profileResponse = await getProfile();
if (!profileResponse.success) {
setProfile(null);
setAvatarUrl(null);
return;
}
setProfile(profileResponse.data);
// Get avatar URL if available
if (profileResponse.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: profileResponse.data.avatar_url,
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
}
}
} catch (error) {
toast.error('Failed to load user data');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const supabase = createClient();
fetchUserData().catch((error) => {
console.error('💥 Initial fetch error:', error);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
await fetchUserData();
} else if (event === 'SIGNED_OUT') {
setUser(null);
setProfile(null);
setAvatarUrl(null);
setIsLoading(false);
}
});
return () => {
subscription.unsubscribe();
};
}, []);
const updateProfile = async (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => {
try {
setIsLoading(true);
const result = await updateProfileAction(data);
if (!result.success) {
throw new Error(result.error ?? 'Failed to update profile');
}
setProfile(result.data);
// If avatar was updated, refresh the avatar URL
if (data.avatar_url && result.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: result.data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
}
}
toast.success('Profile updated successfully!');
return { success: true, data: result.data };
} catch (error) {
console.error('Error updating profile:', error);
toast.error(error instanceof Error ? error.message : 'Failed to update profile');
return { success: false, error };
} finally {
setIsLoading(false);
}
};
const refreshUserData = async () => {
console.log('Manual refresh triggered');
await fetchUserData();
};
const value = {
user,
profile,
avatarUrl,
isLoading,
isAuthenticated: !!user,
updateProfile,
refreshUserData,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@ -0,0 +1,2 @@
export { AuthProvider, useAuth } from './Auth';
export { ThemeProvider, ThemeToggle } from './Theme';

View File

@ -1,68 +0,0 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui';
export const ThemeProvider = ({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) => {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};
export interface ThemeToggleProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: number;
}
export const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => {
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant='outline' size='icon' {...props}>
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
</Button>
);
}
const toggleTheme = () => {
if (resolvedTheme === 'dark') setTheme('light');
else setTheme('dark');
};
return (
<Button
variant='outline'
size='icon'
className='cursor-pointer'
onClick={toggleTheme}
{...props}
>
<Sun
style={{ height: `${size}rem`, width: `${size}rem` }}
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
/>
<Moon
style={{ height: `${size}rem`, width: `${size}rem` }}
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
/>
<span className='sr-only'>Toggle theme</span>
</Button>
);
};

View File

@ -0,0 +1,25 @@
export type Message =
| { success: string }
| { error: string }
| { message: string };
export const StatusMessage = ({ message }: { message: Message }) => {
return (
<div
className='flex flex-col gap-2 w-full max-w-md
text-sm bg-accent rounded-md p-2 px-4'
>
{'success' in message && (
<div className='dark:text-green-500 text-green-700'>
{message.success}
</div>
)}
{'error' in message && (
<div className='text-destructive'>{message.error}</div>
)}
{'message' in message && (
<div className='text-foreground'>{message.message}</div>
)}
</div>
);
};

View File

@ -0,0 +1,39 @@
'use client';
import { Button } from '@/components/ui';
import { type ComponentProps } from 'react';
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';
type Props = ComponentProps<typeof Button> & {
disabled?: boolean;
pendingText?: string;
};
export const SubmitButton = ({
children,
disabled = false,
pendingText = 'Submitting...',
...props
}: Props) => {
const { pending } = useFormStatus();
return (
<Button
className='cursor-pointer'
type='submit'
aria-disabled={pending}
disabled={disabled}
{...props}
>
{pending || disabled ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{pendingText}
</>
) : (
children
)}
</Button>
);
};

View File

@ -0,0 +1,33 @@
'use server';
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 = async ({
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: ${result.error}`);
}
} 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: ${result.error}`);
}
} 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,3 @@
export * from './SignInSignUp';
export * from './SignInWithApple';
export * from './SignInWithMicrosoft';

View File

@ -1,20 +1,20 @@
'use server'; 'use server';
const FooterTest = () => { const Footer = () => {
return ( 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 flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'>
<p> <p>
Powered by{' '} Powered by{' '}
<a <a
href='https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs' href='https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs'
target='_blank' target='_blank'
className='font-bold hover:underline' className='font-bold hover:underline'
rel='noreferrer' rel='noreferrer'
> >
Supabase Supabase
</a> </a>
</p> </p>
</footer> </footer>
); );
}; };
export default FooterTest; export default Footer;

View File

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

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

View File

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

View File

@ -1,29 +1,22 @@
'use server'; 'use server';
import Link from 'next/link';
import { Button } from '@/components/ui';
import { getProfile } from '@/lib/actions'; import { getProfile } from '@/lib/actions';
import AvatarDropdown from './AvatarDropdown'; import AvatarDropdown from './AvatarDropdown';
import { SignInSignUp } from '@/components/default/auth';
const NavigationAuth = async () => { const NavigationAuth = async () => {
try { try {
const profile = await getProfile(); const profile = await getProfile();
return profile.success ? ( return profile.success ? (
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<AvatarDropdown /> <AvatarDropdown />
</div> </div>
) : ( ) : (
<div className='flex gap-2'> <SignInSignUp />
<Button asChild size='default' variant={'outline'}> );
<Link href='/sign-in'>Sign in</Link> } catch (error) {
</Button> console.error(`Error getting profile: ${error as string}`);
<Button asChild size='sm' variant={'default'}> return <SignInSignUp />;
<Link href='/sign-up'>Sign up</Link> }
</Button>
</div>
);
} catch (error) {
console.error('Error getting profile:', error);
}
}; };
export default NavigationAuth; export default NavigationAuth;

View File

@ -3,34 +3,38 @@
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui'; import { Button } from '@/components/ui';
import NavigationAuth from './auth'; import NavigationAuth from './auth';
import { ThemeToggle } from '@/components/context/theme'; import { ThemeToggle } from '@/components/context';
import Image from 'next/image';
const Navigation = () => { const Navigation = () => {
return ( return (
<nav <nav
className='w-full flex justify-center className='w-full flex justify-center
border-b border-b-foreground/10 h-16' border-b border-b-foreground/10 h-16'
> >
<div <div
className='w-full max-w-5xl flex justify-between className='w-full max-w-5xl flex justify-between
items-center p-3 px-5 text-sm' items-center p-3 px-5 text-sm'
> >
<div className='flex gap-5 items-center font-semibold'> <div className='flex gap-5 items-center font-semibold'>
<Link href={'/'}>T3 Supabase Template</Link> <Link className='flex flex-row my-auto gap-2' href='/'>
<div className='flex items-center gap-2'> <Image src='/favicon.png' alt='T3 Logo' width={50} height={50} />
<Button asChild> <h1 className='my-auto text-2xl'>T3 Supabase Template</h1>
<Link href='https://git.gbrown.org/gib/T3-Template'> </Link>
Go to Git Repo <div className='flex items-center gap-2'>
</Link> <Button asChild>
</Button> <Link href='https://git.gbrown.org/gib/T3-Template'>
</div> Go to Git Repo
</div> </Link>
<div className='flex items-center gap-2'> </Button>
<ThemeToggle /> </div>
<NavigationAuth /> </div>
</div> <div className='flex items-center gap-2'>
</div> <ThemeToggle />
</nav> <NavigationAuth />
); </div>
</div>
</nav>
);
}; };
export default Navigation; export default Navigation;

View File

@ -1,100 +1,112 @@
import { useFileUpload } from '@/lib/hooks/useFileUpload'; import { useFileUpload } from '@/lib/hooks/useFileUpload';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { Avatar, AvatarFallback, AvatarImage, CardContent } from '@/components/ui'; import {
Avatar,
AvatarFallback,
AvatarImage,
CardContent,
} from '@/components/ui';
import { Loader2, Pencil, Upload, User } from 'lucide-react'; import { Loader2, Pencil, Upload, User } from 'lucide-react';
type AvatarUploadProps = { type AvatarUploadProps = {
onAvatarUploaded: (path: string) => Promise<void>; onAvatarUploaded: (path: string) => Promise<void>;
}; };
export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => { export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
const { profile, avatarUrl } = useAuth(); const { profile, avatarUrl } = useAuth();
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload(); const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
const handleAvatarClick = () => { const handleAvatarClick = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const result = await uploadToStorage({ const result = await uploadToStorage({
file, file,
bucket: 'avatars', bucket: 'avatars',
resize: true, resize: true,
options: { options: {
maxWidth: 500, maxWidth: 500,
maxHeight: 500, maxHeight: 500,
quality: 0.8, quality: 0.8,
}, },
prevPath: profile?.avatar_url, replace: { replace: true, path: profile?.avatar_url ?? file.name },
}); });
if (result.success && result.path) { if (result.success && result.data) {
await onAvatarUploaded(result.path); await onAvatarUploaded(result.data);
} }
}; };
const getInitials = (name: string | null | undefined): string => { const getInitials = (name: string | null | undefined): string => {
if (!name) return ''; if (!name) return '';
return name.split(' ') return name
.map((n) => n[0]) .split(' ')
.join('') .map((n) => n[0])
.toUpperCase(); .join('')
}; .toUpperCase();
};
return ( return (
<CardContent> <CardContent>
<div className='flex flex-col items-center'>
<div className='flex flex-col items-center'> <div
<div className='relative group cursor-pointer mb-4'
className='relative group cursor-pointer mb-4' onClick={handleAvatarClick}
onClick={handleAvatarClick} >
> <Avatar className='h-32 w-32'>
<Avatar className='h-32 w-32'> {avatarUrl ? (
{avatarUrl ? ( <AvatarImage
<AvatarImage src={avatarUrl} alt={getInitials(profile?.full_name)} width={128} height={128} /> src={avatarUrl}
) : ( alt={getInitials(profile?.full_name)}
<AvatarFallback className='text-4xl'> width={128}
{profile?.full_name height={128}
? getInitials(profile.full_name) />
: <User size={32} />} ) : (
</AvatarFallback> <AvatarFallback className='text-4xl'>
)} {profile?.full_name ? (
</Avatar> getInitials(profile.full_name)
<div ) : (
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50 <User size={32} />
)}
</AvatarFallback>
)}
</Avatar>
<div
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
transition-all flex items-center justify-center' transition-all flex items-center justify-center'
> >
<Upload <Upload
className='text-white opacity-0 group-hover:opacity-100 className='text-white opacity-0 group-hover:opacity-100
transition-opacity' transition-opacity'
size={24} size={24}
/> />
</div> </div>
<div className='absolute inset-1 transition-all flex items-end justify-end'> <div className='absolute inset-1 transition-all flex items-end justify-end'>
<Pencil <Pencil
className='text-white opacity-100 group-hover:opacity-0 className='text-white opacity-100 group-hover:opacity-0
transition-opacity' transition-opacity'
size={24} size={24}
/> />
</div> </div>
</div> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type='file' type='file'
accept='image/*' accept='image/*'
className='hidden' className='hidden'
onChange={handleFileChange} onChange={handleFileChange}
disabled={isUploading} disabled={isUploading}
/> />
{isUploading && ( {isUploading && (
<div className='flex items-center text-sm text-gray-500 mt-2'> <div className='flex items-center text-sm text-gray-500 mt-2'>
<Loader2 className='h-4 w-4 mr-2 animate-spin' /> <Loader2 className='h-4 w-4 mr-2 animate-spin' />
Uploading... Uploading...
</div> </div>
)} )}
</div> </div>
</CardContent> </CardContent>
); );
}; };

View File

@ -2,107 +2,99 @@ import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
Button, CardContent,
CardContent, Form,
Form, FormControl,
FormControl, FormDescription,
FormDescription, FormField,
FormField, FormItem,
FormItem, FormLabel,
FormLabel, FormMessage,
FormMessage, Input,
Input,
} from '@/components/ui'; } from '@/components/ui';
import { Loader2 } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { SubmitButton } from '@/components/default';
const formSchema = z.object({ const formSchema = z.object({
full_name: z.string().min(5, { full_name: z.string().min(5, {
message: 'Full name is required & must be at least 5 characters.', message: 'Full name is required & must be at least 5 characters.',
}), }),
email: z.string().email(), email: z.string().email(),
}); });
type ProfileFormProps = { type ProfileFormProps = {
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>; onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
}; };
export const ProfileForm = ({onSubmit}: ProfileFormProps) => { export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
const { profile, isLoading } = useAuth(); const { profile, isLoading } = useAuth();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
full_name: profile?.full_name ?? '', full_name: profile?.full_name ?? '',
email: profile?.email ?? '', email: profile?.email ?? '',
}, },
}); });
// Update form values when profile changes // Update form values when profile changes
useEffect(() => { useEffect(() => {
if (profile) { if (profile) {
form.reset({ form.reset({
full_name: profile.full_name ?? '', full_name: profile.full_name ?? '',
email: profile.email ?? '', email: profile.email ?? '',
}); });
} }
}, [profile, form]); }, [profile, form]);
const handleSubmit = async (values: z.infer<typeof formSchema>) => { const handleSubmit = async (values: z.infer<typeof formSchema>) => {
await onSubmit(values); await onSubmit(values);
}; };
return ( return (
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'> <form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
<FormField <FormField
control={form.control} control={form.control}
name='full_name' name='full_name'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Full Name</FormLabel> <FormLabel>Full Name</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription>Your public display name.</FormDescription> <FormDescription>Your public display name.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your email address associated with your account. Your email address associated with your account.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div className='flex justify-center'> <div className='flex justify-center'>
<Button type='submit' disabled={isLoading}> <SubmitButton disabled={isLoading} pendingText='Saving...'>
{isLoading ? ( Save Changes
<> </SubmitButton>
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> </div>
Saving... </form>
</> </Form>
) : ( </CardContent>
'Save Changes' );
)} };
</Button>
</div>
</form>
</Form>
</CardContent>
);
}

View File

@ -2,149 +2,146 @@ import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
Form, Form,
FormControl, FormControl,
FormDescription, FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
Input, Input,
} from '@/components/ui'; } from '@/components/ui';
import { SubmitButton } from '@/components/default'; import { SubmitButton } from '@/components/default';
import { useState } from 'react'; import { useState } from 'react';
import { type Result } from '@/lib/actions'; import { type Result } from '@/lib/actions';
import { FormMessage as Pw } from '@/components/default'; import { StatusMessage } from '@/components/default';
const formSchema = z const formSchema = z
.object({ .object({
password: z.string().min(8, { password: z.string().min(8, {
message: 'Password must be at least 8 characters.', message: 'Password must be at least 8 characters.',
}), }),
confirmPassword: z.string(), confirmPassword: z.string(),
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.', message: 'Passwords do not match.',
path: ['confirmPassword'], path: ['confirmPassword'],
}); });
type ResetPasswordFormProps = { type ResetPasswordFormProps = {
onSubmit: (formData: FormData) => Promise<Result<null>>; onSubmit: (formData: FormData) => Promise<Result<null>>;
message?: string; message?: string;
}; };
export const ResetPasswordForm = ({ export const ResetPasswordForm = ({
onSubmit, onSubmit,
message, message,
}: ResetPasswordFormProps) => { }: ResetPasswordFormProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [statusMessage, setStatusMessage] = useState(message ?? ''); const [statusMessage, setStatusMessage] = useState(message ?? '');
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
password: '', password: '',
confirmPassword: '', confirmPassword: '',
}, },
}); });
const handleUpdatePassword = async (values: z.infer<typeof formSchema>) => { const handleUpdatePassword = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true); setIsLoading(true);
try { try {
// Convert form values to FormData for your server action // Convert form values to FormData for your server action
const formData = new FormData(); const formData = new FormData();
formData.append('password', values.password); formData.append('password', values.password);
formData.append('confirmPassword', values.confirmPassword); formData.append('confirmPassword', values.confirmPassword);
const result = await onSubmit(formData); const result = await onSubmit(formData);
if (result?.success) { if (result?.success) {
setStatusMessage('Password updated successfully!'); setStatusMessage('Password updated successfully!');
form.reset(); // Clear the form on success form.reset(); // Clear the form on success
} else { } else {
setStatusMessage('Error: Unable to update password!'); setStatusMessage('Error: Unable to update password!');
} }
} catch (error) { } catch (error) {
setStatusMessage( setStatusMessage(
error instanceof Error ? error.message : 'Password was not updated!' error instanceof Error ? error.message : 'Password was not updated!',
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<div> <div>
<CardHeader className='pb-5'> <CardHeader className='pb-5'>
<CardTitle className='text-2xl'>Change Password</CardTitle> <CardTitle className='text-2xl'>Change Password</CardTitle>
<CardDescription> <CardDescription>
Update your password to keep your account secure Update your password to keep your account secure
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form {...form}>
<Form {...form}> <form
<form onSubmit={form.handleSubmit(handleUpdatePassword)}
onSubmit={form.handleSubmit(handleUpdatePassword)} className='space-y-6'
className='space-y-6' >
> <FormField
<FormField control={form.control}
control={form.control} name='password'
name='password' render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>New Password</FormLabel>
<FormLabel>New Password</FormLabel> <FormControl>
<FormControl> <Input type='password' {...field} />
<Input type='password' {...field} /> </FormControl>
</FormControl> <FormDescription>
<FormDescription> Enter your new password. Must be at least 8 characters.
Enter your new password. Must be at least 8 characters. </FormDescription>
</FormDescription> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
)} />
/> <FormField
<FormField control={form.control}
control={form.control} name='confirmPassword'
name='confirmPassword' render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>Confirm Password</FormLabel>
<FormLabel>Confirm Password</FormLabel> <FormControl>
<FormControl> <Input type='password' {...field} />
<Input type='password' {...field} /> </FormControl>
</FormControl> <FormDescription>
<FormDescription> Please re-enter your new password to confirm.
Please re-enter your new password to confirm. </FormDescription>
</FormDescription> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
)} />
/> {statusMessage &&
{statusMessage && ( (statusMessage.includes('Error') ||
<div statusMessage.includes('error') ||
className={`text-sm text-center ${ statusMessage.includes('failed') ||
statusMessage.includes('Error') || statusMessage.includes('failed') statusMessage.includes('invalid') ? (
? 'text-destructive' <StatusMessage message={{ error: statusMessage }} />
: 'text-green-600' ) : (
}`} <StatusMessage message={{ message: statusMessage }} />
> ))}
{statusMessage} <div className='flex justify-center'>
</div> <SubmitButton
)} disabled={isLoading}
<div className='flex justify-center'> pendingText='Updating Password...'
<SubmitButton >
disabled={isLoading} Update Password
pendingText='Updating Password...' </SubmitButton>
> </div>
Update Password </form>
</SubmitButton> </Form>
</div> </CardContent>
</form> </div>
</Form> );
</CardContent>
</div>
);
}; };

View File

@ -0,0 +1,34 @@
'use client';
import { CardHeader } from '@/components/ui';
import { SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context';
import { signOut } from '@/lib/actions';
export const SignOut = () => {
const { isLoading, refreshUserData } = useAuth();
const router = useRouter();
const handleSignOut = async () => {
const result = await signOut();
if (result?.success) {
await refreshUserData();
router.push('/sign-in');
}
};
return (
<div className='flex justify-center'>
<CardHeader className='md:w-5/6 w-full'>
<SubmitButton
className='text-[1.0rem] font-semibold cursor-pointer
hover:bg-red-700/60 dark:hover:bg-red-300/80'
disabled={isLoading}
onClick={handleSignOut}
>
Sign Out
</SubmitButton>
</CardHeader>
</div>
);
};

View File

@ -1,3 +1,4 @@
export * from './AvatarUpload'; export * from './AvatarUpload';
export * from './ProfileForm'; export * from './ProfileForm';
export * from './ResetPasswordForm'; export * from './ResetPasswordForm';
export * from './SignOut';

View File

@ -0,0 +1,126 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import { useState, useEffect } from 'react';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from '@/components/ui';
import Link from 'next/link';
import { CheckCircle, MessageCircleWarning } from 'lucide-react';
class SentryExampleFrontendError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = 'SentryExampleFrontendError';
}
}
export const TestSentryCard = () => {
const [hasSentError, setHasSentError] = useState(false);
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
const checkConnectivity = async () => {
console.log('Checking Sentry SDK connectivity...');
const result = await Sentry.diagnoseSdkConnectivity();
setIsConnected(result !== 'sentry-unreachable');
};
checkConnectivity().catch((error) => {
console.error('Error trying to connect to Sentry: ', error);
});
}, []);
const createError = async () => {
await Sentry.startSpan(
{
name: 'Example Frontend Span',
op: 'test',
},
async () => {
const res = await fetch('/api/sentry/example');
if (!res.ok) {
setHasSentError(true);
throw new SentryExampleFrontendError(
'This error is raised in our TestSentry component on the main page.',
);
}
},
);
};
return (
<Card>
<CardHeader>
<div className='flex flex-row my-auto space-x-4'>
<svg
height='40'
width='40'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M21.85 2.995a3.698 3.698 0 0 1 1.353 1.354l16.303 28.278a3.703 3.703 0 0 1-1.354 5.053 3.694 3.694 0 0 1-1.848.496h-3.828a31.149 31.149 0 0 0 0-3.09h3.815a.61.61 0 0 0 .537-.917L20.523 5.893a.61.61 0 0 0-1.057 0l-3.739 6.494a28.948 28.948 0 0 1 9.63 10.453 28.988 28.988 0 0 1 3.499 13.78v1.542h-9.852v-1.544a19.106 19.106 0 0 0-2.182-8.85 19.08 19.08 0 0 0-6.032-6.829l-1.85 3.208a15.377 15.377 0 0 1 6.382 12.484v1.542H3.696A3.694 3.694 0 0 1 0 34.473c0-.648.17-1.286.494-1.849l2.33-4.074a8.562 8.562 0 0 1 2.689 1.536L3.158 34.17a.611.611 0 0 0 .538.917h8.448a12.481 12.481 0 0 0-6.037-9.09l-1.344-.772 4.908-8.545 1.344.77a22.16 22.16 0 0 1 7.705 7.444 22.193 22.193 0 0 1 3.316 10.193h3.699a25.892 25.892 0 0 0-3.811-12.033 25.856 25.856 0 0 0-9.046-8.796l-1.344-.772 5.269-9.136a3.698 3.698 0 0 1 3.2-1.849c.648 0 1.285.17 1.847.495Z'
fill='currentcolor'
/>
</svg>
<CardTitle className='text-3xl my-auto'>Test Sentry</CardTitle>
</div>
<CardDescription className='text-[1.0rem]'>
Click the button below & view the sample error on{' '}
<Link
href={`${process.env.NEXT_PUBLIC_SENTRY_URL}`}
className='text-accent-foreground underline hover:text-primary'
>
the Sentry website
</Link>
. Navigate to the {"'"}Issues{"'"} page & you should see the sample
error!
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-row gap-4 my-auto'>
<Button
type='button'
onClick={createError}
className='cursor-pointer text-md my-auto py-6'
>
<span>Throw Sample Error</span>
</Button>
{hasSentError ? (
<div className='rounded-md bg-green-500/80 dark:bg-green-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
<CheckCircle size={30} className='my-auto' />
<p className='text-lg'>Sample error was sent to Sentry!</p>
</div>
) : !isConnected ? (
<div className='rounded-md bg-red-600/50 dark:bg-red-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
<MessageCircleWarning size={40} className='my-auto' />
<p>
Wait! The Sentry SDK is not able to reach Sentry right now -
this may be due to an adblocker. For more information, see{' '}
<Link
href='https://docs.sentry.io/platforms/javascript/guides/nextjs/troubleshooting/#the-sdk-is-not-sending-any-data'
className='text-accent-foreground underline hover:text-primary'
>
the troubleshooting guide.
</Link>
</p>
</div>
) : (
<div className='success_placeholder' />
)}
</div>
<Separator className='my-4 bg-accent' />
<p className='description'>
Warning! Sometimes Adblockers will prevent errors from being sent to
Sentry.
</p>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1 @@
export { TestSentryCard } from './TestSentry';

View File

@ -1,33 +0,0 @@
'use client';
import { Button } from '@/components/ui';
import { type ComponentProps } from 'react';
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';
type Props = ComponentProps<typeof Button> & {
disabled?: boolean;
pendingText?: string;
};
export const SubmitButton = ({
children,
disabled = false,
pendingText = 'Submitting...',
...props
}: Props) => {
const { pending } = useFormStatus();
return (
<Button type='submit' aria-disabled={pending} disabled={disabled} {...props}>
{pending || disabled ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{pendingText}
</>
) : (
children
)}
</Button>
);
};

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

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

@ -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,3 +1,3 @@
export { CodeBlock } from './code-block'; export { CodeBlock } from './CodeBlock';
export { FetchDataSteps } from './fetch-data-steps'; export { FetchDataSteps } from './FetchDataSteps';
export { TutorialStep } from './tutorial-step'; export { TutorialStep } from './TutorialStep';

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

@ -6,48 +6,48 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Avatar({ function Avatar({
className, className,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot='avatar' data-slot='avatar'
className={cn( className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full', 'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function AvatarImage({ function AvatarImage({
className, className,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) { }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot='avatar-image' data-slot='avatar-image'
className={cn('aspect-square size-full', className)} className={cn('aspect-square size-full', className)}
{...props} {...props}
/> />
); );
} }
function AvatarFallback({ function AvatarFallback({
className, className,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return ( return (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot='avatar-fallback' data-slot='avatar-fallback'
className={cn( className={cn(
'bg-muted flex size-full items-center justify-center rounded-full', 'bg-muted flex size-full items-center justify-center rounded-full',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
export { Avatar, AvatarImage, AvatarFallback }; export { Avatar, AvatarImage, AvatarFallback };

View File

@ -5,42 +5,42 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const badgeVariants = cva( 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', '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: { variants: {
variant: { variant: {
default: default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive: 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', '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: outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
}, },
}, },
); );
function Badge({ function Badge({
className, className,
variant, variant,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'span'> & }: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'; const Comp = asChild ? Slot : 'span';
return ( return (
<Comp <Comp
data-slot='badge' data-slot='badge'
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
); );
} }
export { Badge, badgeVariants }; export { Badge, badgeVariants };

View File

@ -5,56 +5,58 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
{ {
variants: { variants: {
variant: { variant: {
default: default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9', xl: 'h-12 rounded-md px-8 has-[>svg]:px-6',
smicon: 'size-6', xxl: 'h-14 rounded-md px-10 has-[>svg]:px-8',
}, icon: 'size-9',
}, smicon: 'size-6',
defaultVariants: { },
variant: 'default', },
size: 'default', defaultVariants: {
}, variant: 'default',
}, size: 'default',
},
},
); );
function Button({ function Button({
className, className,
variant, variant,
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'button'> & }: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
data-slot='button' data-slot='button'
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
); );
} }
export { Button, buttonVariants }; export { Button, buttonVariants };

View File

@ -3,90 +3,90 @@ import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) { function Card({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot='card' data-slot='card'
className={cn( className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', 'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot='card-header' data-slot='card-header'
className={cn( className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot='card-title' data-slot='card-title'
className={cn('leading-none font-semibold', className)} className={cn('leading-none font-semibold', className)}
{...props} {...props}
/> />
); );
} }
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot='card-description' data-slot='card-description'
className={cn('text-muted-foreground text-sm', className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
); );
} }
function CardAction({ className, ...props }: React.ComponentProps<'div'>) { function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot='card-action' data-slot='card-action'
className={cn( className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end', 'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function CardContent({ className, ...props }: React.ComponentProps<'div'>) { function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot='card-content' data-slot='card-content'
className={cn('px-6', className)} className={cn('px-6', className)}
{...props} {...props}
/> />
); );
} }
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot='card-footer' data-slot='card-footer'
className={cn('flex items-center px-6 [.border-t]:pt-6', className)} className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props} {...props}
/> />
); );
} }
export { export {
Card, Card,
CardHeader, CardHeader,
CardFooter, CardFooter,
CardTitle, CardTitle,
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
}; };

View File

@ -7,26 +7,26 @@ import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Checkbox({ function Checkbox({
className, className,
...props ...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) { }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot='checkbox' data-slot='checkbox'
className={cn( 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', '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, className,
)} )}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
data-slot='checkbox-indicator' data-slot='checkbox-indicator'
className='flex items-center justify-center text-current transition-none' className='flex items-center justify-center text-current transition-none'
> >
<CheckIcon className='size-3.5' /> <CheckIcon className='size-3.5' />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
); );
} }
export { Checkbox }; export { Checkbox };

View File

@ -7,251 +7,251 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />; return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} /> <DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
); );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return ( return (
<DropdownMenuPrimitive.Trigger <DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger' data-slot='dropdown-menu-trigger'
{...props} {...props}
/> />
); );
} }
function DropdownMenuContent({ function DropdownMenuContent({
className, className,
sideOffset = 4, sideOffset = 4,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return ( return (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content' data-slot='dropdown-menu-content'
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className, className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
); );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} /> <DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
); );
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = 'default', variant = 'default',
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean;
variant?: 'default' | 'destructive'; variant?: 'default' | 'destructive';
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item' data-slot='dropdown-menu-item'
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
className, className,
children, children,
checked, checked,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return ( return (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item' data-slot='dropdown-menu-checkbox-item'
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'> <span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' /> <CheckIcon className='size-4' />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
); );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return ( return (
<DropdownMenuPrimitive.RadioGroup <DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group' data-slot='dropdown-menu-radio-group'
{...props} {...props}
/> />
); );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return ( return (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item' data-slot='dropdown-menu-radio-item'
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
> >
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'> <span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' /> <CircleIcon className='size-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
); );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
className, className,
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean; inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label' data-slot='dropdown-menu-label'
data-inset={inset} data-inset={inset}
className={cn( className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', 'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator' data-slot='dropdown-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)} className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
); );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
className, className,
...props ...props
}: React.ComponentProps<'span'>) { }: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot='dropdown-menu-shortcut' data-slot='dropdown-menu-shortcut'
className={cn( className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest', 'text-muted-foreground ml-auto text-xs tracking-widest',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />; return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
className, className,
inset, inset,
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean; inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger' data-slot='dropdown-menu-sub-trigger'
data-inset={inset} data-inset={inset}
className={cn( className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8', 'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className='ml-auto size-4' /> <ChevronRightIcon className='ml-auto size-4' />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
); );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content' data-slot='dropdown-menu-sub-content'
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
}; };

View File

@ -4,13 +4,13 @@ import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label'; import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { import {
Controller, Controller,
FormProvider, FormProvider,
useFormContext, useFormContext,
useFormState, useFormState,
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
} from 'react-hook-form'; } from 'react-hook-form';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -19,150 +19,150 @@ import { Label } from '@/components/ui/label';
const Form = FormProvider; const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName; name: TName;
}; };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue, {} as FormFieldContextValue,
); );
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ >({
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
return ( return (
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
); );
}; };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext); const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext); const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext(); const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name }); const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState); const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>'); throw new Error('useFormField should be used within <FormField>');
} }
const { id } = itemContext; const { id } = itemContext;
return { return {
id, id,
name: fieldContext.name, name: fieldContext.name,
formItemId: `${id}-form-item`, formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
}; };
}; };
type FormItemContextValue = { type FormItemContextValue = {
id: string; id: string;
}; };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue, {} as FormItemContextValue,
); );
function FormItem({ className, ...props }: React.ComponentProps<'div'>) { function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId(); const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div <div
data-slot='form-item' data-slot='form-item'
className={cn('grid gap-2', className)} className={cn('grid gap-2', className)}
{...props} {...props}
/> />
</FormItemContext.Provider> </FormItemContext.Provider>
); );
} }
function FormLabel({ function FormLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField(); const { error, formItemId } = useFormField();
return ( return (
<Label <Label
data-slot='form-label' data-slot='form-label'
data-error={!!error} data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)} className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
); );
} }
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = const { error, formItemId, formDescriptionId, formMessageId } =
useFormField(); useFormField();
return ( return (
<Slot <Slot
data-slot='form-control' data-slot='form-control'
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={
!error !error
? `${formDescriptionId}` ? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}` : `${formDescriptionId} ${formMessageId}`
} }
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
); );
} }
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) { function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField(); const { formDescriptionId } = useFormField();
return ( return (
<p <p
data-slot='form-description' data-slot='form-description'
id={formDescriptionId} id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
); );
} }
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) { function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField(); const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children; const body = error ? String(error?.message ?? '') : props.children;
if (!body) { if (!body) {
return null; return null;
} }
return ( return (
<p <p
data-slot='form-message' data-slot='form-message'
id={formMessageId} id={formMessageId}
className={cn('text-destructive text-sm', className)} className={cn('text-destructive text-sm', className)}
{...props} {...props}
> >
{body} {body}
</p> </p>
); );
} }
export { export {
useFormField, useFormField,
Form, Form,
FormItem, FormItem,
FormLabel, FormLabel,
FormControl, FormControl,
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
}; };

View File

@ -3,19 +3,19 @@ import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) { function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return ( return (
<input <input
type={type} type={type}
data-slot='input' data-slot='input'
className={cn( 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', '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]', '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', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
export { Input }; export { Input };

View File

@ -6,19 +6,19 @@ import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Label({ function Label({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot='label' data-slot='label'
className={cn( 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', '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, className,
)} )}
{...props} {...props}
/> />
); );
} }
export { Label }; export { Label };

View File

@ -6,23 +6,23 @@ import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Separator({ function Separator({
className, className,
orientation = 'horizontal', orientation = 'horizontal',
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return ( return (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
data-slot='separator-root' data-slot='separator-root'
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
export { Separator }; export { Separator };

View File

@ -4,22 +4,22 @@ import { useTheme } from 'next-themes';
import { Toaster as Sonner, ToasterProps } from 'sonner'; import { Toaster as Sonner, ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme(); const { theme = 'system' } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps['theme']} theme={theme as ToasterProps['theme']}
className='toaster group' className='toaster group'
style={ style={
{ {
'--normal-bg': 'var(--popover)', '--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)', '--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)', '--normal-border': 'var(--border)',
} as React.CSSProperties } as React.CSSProperties
} }
{...props} {...props}
/> />
); );
}; };
export { Toaster }; export { Toaster };

View File

@ -2,42 +2,57 @@ import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod'; import { z } from 'zod';
export const env = createEnv({ export const env = createEnv({
/** /**
* Specify your server-side environment variables schema here. * Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars. * This way you can ensure the app isn't built with invalid env vars.
*/ */
server: { server: {
NODE_ENV: z.enum(['development', 'test', 'production']), NODE_ENV: z
}, .enum(['development', 'test', 'production'])
.default('development'),
SENTRY_AUTH_TOKEN: z.string().min(1),
CI: z.enum(['true', 'false']).default('false'),
},
/** /**
* Specify your client-side environment variables schema here. * Specify your client-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars. * This way you can ensure the app isn't built with invalid env vars.
* To expose them to the client, prefix them with `NEXT_PUBLIC_`. * To expose them to the client, prefix them with `NEXT_PUBLIC_`.
*/ */
client: { client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().url(), NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1), NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
}, NEXT_PUBLIC_SITE_URL: z.string().url().default('http://localhost:3000'),
NEXT_PUBLIC_SENTRY_DSN: z.string().min(1),
NEXT_PUBLIC_SENTRY_URL: z
.string()
.url()
.default('https://sentry.gbrown.org'),
},
/** /**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually. * middlewares) or client-side so we need to destruct manually.
*/ */
runtimeEnv: { runtimeEnv: {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI,
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
}, NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
/** NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
* useful for Docker builds. },
*/ /**
skipValidation: !!process.env.SKIP_ENV_VALIDATION, * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
/** * useful for Docker builds.
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and */
* `SOME_VAR=''` will throw an error. skipValidation: !!process.env.SKIP_ENV_VALIDATION,
*/ /**
emptyStringAsUndefined: true, * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
}); });

View File

@ -0,0 +1,36 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
// Adds request headers and IP for users, for more info visit:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true,
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for tracing.
// We recommend adjusting this value in production
// Learn more at
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
tracesSampleRate: 1.0,
// Replay may only be enabled for the client-side
integrations: [Sentry.replayIntegration()],
// Capture Replay for 10% of all sessions,
// plus for 100% of sessions with an error
// Learn more at
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});
// This export will instrument router navigations, and is only relevant if you enable tracing.
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

10
src/instrumentation.ts Normal file
View File

@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs';
import type { Instrumentation } from 'next';
export const register = async () => {
await import('../sentry.server.config');
};
export const onRequestError: Instrumentation.onRequestError = (...args) => {
Sentry.captureRequestError(...args);
};

View File

@ -1,136 +1,155 @@
'use server'; 'use server';
import 'server-only'; import 'server-only';
import { encodedRedirect } from '@/utils/utils';
import { createServerClient } from '@/utils/supabase'; import { createServerClient } from '@/utils/supabase';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import type { User } from '@/utils/supabase'; import type { User } from '@/utils/supabase';
import type { Result } from './index'; import type { Result } from '.';
export const signUp = async (formData: FormData) => { export const signUp = async (
const name = formData.get('name') as string; formData: FormData,
const email = formData.get('email') as string; ): Promise<Result<string | null>> => {
const password = formData.get('password') as string; const name = formData.get('name') as string;
const supabase = await createServerClient(); const email = formData.get('email') as string;
const origin = (await headers()).get('origin'); const password = formData.get('password') as string;
const supabase = await createServerClient();
const origin = (await headers()).get('origin');
if (!email || !password) { if (!email || !password) {
return encodedRedirect( return { success: false, error: 'Email and password are required' };
'error', }
'/sign-up',
'Email & password are required',
);
}
const { error } = await supabase.auth.signUp({ const { error } = await supabase.auth.signUp({
email, email,
password, password,
options: { options: {
emailRedirectTo: `${origin}/auth/callback`, emailRedirectTo: `${origin}/auth/callback`,
data: { data: {
full_name: name, full_name: name,
email, email,
provider: 'email', provider: 'email',
}, },
}, },
}); });
if (error) {
return encodedRedirect('error', '/sign-up', error.message); if (error) {
} else { return { success: false, error: error.message };
return encodedRedirect( } else {
'success', return {
'/sign-up', success: true,
'Thanks for signing up! Please check your email for a verification link.', data: 'Thanks for signing up! Please check your email for a verification link.',
); };
} }
}; };
export const signIn = async ( export const signIn = async (formData: FormData): Promise<Result<null>> => {
formData: FormData, const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = await createServerClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return { success: false, error: error.message };
} else {
return { success: true, data: null };
}
};
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
const supabase = await createServerClient();
const origin = (await headers()).get('origin');
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 };
return { success: true, data: data.url };
};
export const signInWithApple = async (): Promise<Result<string>> => {
const supabase = await createServerClient();
const origin = process.env.BASE_URL!;
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
};
export const forgotPassword = async (
formData: FormData,
): Promise<Result<string | null>> => {
const email = formData.get('email') as string;
const supabase = await createServerClient();
const origin = (await headers()).get('origin');
if (!email) {
return { success: false, error: 'Email is required' };
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
});
if (error) {
return { success: false, error: 'Could not reset password' };
}
return {
success: true,
data: 'Check your email for a link to reset your password.',
};
};
export const resetPassword = async (
formData: FormData,
): Promise<Result<null>> => { ): Promise<Result<null>> => {
const email = formData.get('email') as string; const password = formData.get('password') as string;
const password = formData.get('password') as string; const confirmPassword = formData.get('confirmPassword') as string;
const supabase = await createServerClient(); if (!password || !confirmPassword) {
return {
const { error } = await supabase.auth.signInWithPassword({ success: false,
email, error: 'Password and confirm password are required!',
password, };
}); }
if (error) { const supabase = await createServerClient();
return { success: false, error: error.message }; if (password !== confirmPassword) {
} else { return { success: false, error: 'Passwords do not match!' };
return { success: true, data: null }; }
} const { error } = await supabase.auth.updateUser({
}; password,
});
export const forgotPassword = async (formData: FormData) => { if (error) {
const email = formData.get('email') as string; return {
const supabase = await createServerClient(); success: false,
const origin = (await headers()).get('origin'); error: `Password update failed: ${error.message}`,
const callbackUrl = formData.get('callbackUrl') as string; };
}
if (!email) { return { success: true, data: null };
return encodedRedirect('error', '/forgot-password', 'Email is required');
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
});
if (error) {
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): Promise<Result<null>> => {
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (!password || !confirmPassword) {
return { success: false, error: 'Password and confirm password are required!' };
}
const supabase = await createServerClient();
if (password !== confirmPassword) {
return { success: false, error: 'Passwords do not match!' };
}
const { error } = await supabase.auth.updateUser({
password,
});
if (error) {
return { success: false, error: `Password update failed: ${error.message}` };
}
return { success: true, data: null };
}; };
export const signOut = async (): Promise<Result<null>> => { export const signOut = async (): Promise<Result<null>> => {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { error } = await supabase.auth.signOut(); const { error } = await supabase.auth.signOut();
if (error) return { success: false, error: error.message } if (error) return { success: false, error: error.message };
return { success: true, data: null }; return { success: true, data: null };
}; };
export const getUser = async (): Promise<Result<User>> => { export const getUser = async (): Promise<Result<User>> => {
try { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { data, error } = await supabase.auth.getUser(); const { data, error } = await supabase.auth.getUser();
if (error) throw error; if (error) throw error;
return { success: true, data: data.user }; return { success: true, data: data.user };
} catch (error) { } catch (error) {
return { success: false, error: 'Could not get user!' }; return { success: false, error: 'Could not get user!' };
} }
}; };

View File

@ -3,5 +3,5 @@ export * from './storage';
export * from './public'; export * from './public';
export type Result<T> = export type Result<T> =
| { success: true; data: T } | { success: true; data: T }
| { success: false; error: string }; | { success: false; error: string };

View File

@ -3,78 +3,78 @@
import 'server-only'; import 'server-only';
import { createServerClient, type Profile } from '@/utils/supabase'; import { createServerClient, type Profile } from '@/utils/supabase';
import { getUser } from '@/lib/actions'; import { getUser } from '@/lib/actions';
import type { Result } from './index'; import type { Result } from '.';
export const getProfile = async (): Promise<Result<Profile>> => { export const getProfile = async (): Promise<Result<Profile>> => {
try { try {
const user = await getUser(); const user = await getUser();
if (!user.success || user.data === undefined) if (!user.success || user.data === undefined)
throw new Error('User not found'); throw new Error('User not found');
const supabase = await createServerClient(); const supabase = await createServerClient();
const { data, error } = await supabase const { data, error } = await supabase
.from('profiles') .from('profiles')
.select('*') .select('*')
.eq('id', user.data.id) .eq('id', user.data.id)
.single(); .single();
if (error) throw error; if (error) throw error;
return { success: true, data: data as Profile }; return { success: true, data: data as Profile };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error
? error.message ? error.message
: 'Unknown error getting profile', : 'Unknown error getting profile',
}; };
} }
}; };
type updateProfileProps = { type updateProfileProps = {
full_name?: string; full_name?: string;
email?: string; email?: string;
avatar_url?: string; avatar_url?: string;
}; };
export const updateProfile = async ({ export const updateProfile = async ({
full_name, full_name,
email, email,
avatar_url, avatar_url,
}: updateProfileProps): Promise<Result<Profile>> => { }: updateProfileProps): Promise<Result<Profile>> => {
try { try {
if ( if (
full_name === undefined && full_name === undefined &&
email === undefined && email === undefined &&
avatar_url === undefined avatar_url === undefined
) )
throw new Error('No profile data provided'); throw new Error('No profile data provided');
const userResponse = await getUser(); const userResponse = await getUser();
if (!userResponse.success || userResponse.data === undefined) if (!userResponse.success || userResponse.data === undefined)
throw new Error('User not found'); throw new Error('User not found');
const supabase = await createServerClient(); const supabase = await createServerClient();
const { data, error } = await supabase const { data, error } = await supabase
.from('profiles') .from('profiles')
.update({ .update({
...(full_name !== undefined && { full_name }), ...(full_name !== undefined && { full_name }),
...(email !== undefined && { email }), ...(email !== undefined && { email }),
...(avatar_url !== undefined && { avatar_url }), ...(avatar_url !== undefined && { avatar_url }),
}) })
.eq('id', userResponse.data.id) .eq('id', userResponse.data.id)
.select() .select()
.single(); .single();
if (error) throw error; if (error) throw error;
return { return {
success: true, success: true,
data: data as Profile, data: data as Profile,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error
? error.message ? error.message
: 'Unknown error updating profile', : 'Unknown error updating profile',
}; };
} }
}; };

422
src/lib/actions/storage.ts Normal file → Executable file
View File

@ -1,270 +1,256 @@
'use server'; 'use server';
import 'server-only'; import 'server-only';
import { createServerClient } from '@/utils/supabase'; import { createServerClient } from '@/utils/supabase';
import type { Result } from './index'; import type { Result } from '.';
export type GetStorageProps = { export type GetStorageProps = {
bucket: string; bucket: string;
url: string; url: string;
seconds?: number; seconds?: number;
transform?: { transform?: {
width?: number; width?: number;
height?: number; height?: number;
quality?: number; quality?: number;
format?: 'origin'; format?: 'origin';
resize?: 'cover' | 'contain' | 'fill'; resize?: 'cover' | 'contain' | 'fill';
}; };
download?: boolean | string; download?: boolean | string;
}; };
export type UploadStorageProps = { export type UploadStorageProps = {
bucket: string; bucket: string;
path: string; path: string;
file: File; file: File;
options?: { options?: {
cacheControl?: string; cacheControl?: string;
upsert?: boolean; contentType?: string;
contentType?: string; };
};
}; };
export type ReplaceStorageProps = { export type ReplaceStorageProps = {
bucket: string; bucket: string;
prevPath: string; path: string;
path: string; file: File;
file: File; options?: {
options?: { cacheControl?: string;
cacheControl?: string; contentType?: string;
upsert?: boolean; };
contentType?: string;
};
}; };
export type resizeImageProps = { export type resizeImageProps = {
file: File, file: File;
options?: { options?: {
maxWidth?: number, maxWidth?: number;
maxHeight?: number, maxHeight?: number;
quality?: number, quality?: number;
} };
}; };
export const getSignedUrl = async ({ export const getSignedUrl = async ({
bucket, bucket,
url, url,
seconds = 3600, seconds = 3600,
transform = {}, transform = {},
download = false, download = false,
}: GetStorageProps): Promise<Result<string>> => { }: GetStorageProps): Promise<Result<string>> => {
try { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { data, error } = await supabase.storage const { data, error } = await supabase.storage
.from(bucket) .from(bucket)
.createSignedUrl(url, seconds, { .createSignedUrl(url, seconds, {
download, download,
transform, transform,
}); });
if (error) throw error; if (error) throw error;
if (!data?.signedUrl) throw new Error('No signed URL returned'); if (!data?.signedUrl) throw new Error('No signed URL returned');
return { success: true, data: data.signedUrl }; return { success: true, data: data.signedUrl };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error
? error.message ? error.message
: 'Unknown error getting signed URL', : 'Unknown error getting signed URL',
}; };
} }
} };
export const getPublicUrl = async ({ export const getPublicUrl = async ({
bucket, bucket,
url, url,
transform = {}, transform = {},
download = false, download = false,
}: GetStorageProps): Promise<Result<string>> => { }: GetStorageProps): Promise<Result<string>> => {
try { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { data } = supabase.storage const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
.from(bucket) download,
.getPublicUrl(url, { transform,
download, });
transform,
});
if (!data?.publicUrl) throw new Error('No public URL returned'); if (!data?.publicUrl) throw new Error('No public URL returned');
return { success: true, data: data.publicUrl }; return { success: true, data: data.publicUrl };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error
? error.message ? error.message
: 'Unknown error getting public URL', : 'Unknown error getting public URL',
}; };
} }
} };
export const uploadFile = async ({ export const uploadFile = async ({
bucket, bucket,
path, path,
file, file,
options = {}, options = {},
}: UploadStorageProps): Promise<Result<string>> => { }: UploadStorageProps): Promise<Result<string>> => {
try { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { data, error } = await supabase.storage const { data, error } = await supabase.storage
.from(bucket) .from(bucket)
.upload(path, file, options); .upload(path, file, options);
if (error) throw error; if (error) throw error;
if (!data?.path) throw new Error('No path returned from upload'); if (!data?.path) throw new Error('No path returned from upload');
return { success: true, data: data.path }; return { success: true, data: data.path };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error:
error instanceof Error ? error.message : 'Unknown error uploading file', error instanceof Error ? error.message : 'Unknown error uploading file',
}; };
} }
} };
export const replaceFile = async ({ export const replaceFile = async ({
bucket, bucket,
prevPath, path,
path, file,
file, options = {},
options = {},
}: ReplaceStorageProps): Promise<Result<string>> => { }: ReplaceStorageProps): Promise<Result<string>> => {
try { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { data, error } = await supabase.storage const { data, error } = await supabase.storage
.from(bucket) .from(bucket)
.update(path, file, options); .update(path, file, { ...options, upsert: true });
if (error) throw error; if (error) throw error;
if (!data?.path) throw new Error('No path returned from upload'); if (!data?.path) throw new Error('No path returned from upload');
const deleteFileData = await deleteFile({ return { success: true, data: data.path };
bucket, } catch (error) {
path: [...prevPath], return {
}); success: false,
return { success: true, data: data.path }; error:
} catch (error) { error instanceof Error ? error.message : 'Unknown error replacing file',
return { };
success: false, }
error:
error instanceof Error ? error.message : 'Unknown error replacing file',
};
}
}; };
// Add a helper to delete files // Add a helper to delete files
export const deleteFile = async ({ export const deleteFile = async ({
bucket, bucket,
path, path,
}: { }: {
bucket: string; bucket: string;
path: string[]; path: string[];
}): Promise<Result<null>> => { }): Promise<Result<null>> => {
try { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { error } = await supabase.storage.from(bucket).remove(path); const { error } = await supabase.storage.from(bucket).remove(path);
if (error) throw error; if (error) throw error;
return { success: true, data: null }; return { success: true, data: null };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error:
error instanceof Error ? error.message : 'Unknown error deleting file', error instanceof Error ? error.message : 'Unknown error deleting file',
}; };
} }
} };
// Add a helper to list files in a bucket // Add a helper to list files in a bucket
export const listFiles = async ({ export const listFiles = async ({
bucket, bucket,
path = '', path = '',
options = {}, options = {},
}: { }: {
bucket: string; bucket: string;
path?: string; path?: string;
options?: { options?: {
limit?: number; limit?: number;
offset?: number; offset?: number;
sortBy?: { column: string; order: 'asc' | 'desc' }; sortBy?: { column: string; order: 'asc' | 'desc' };
}; };
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> => { }): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> => {
try { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { data, error } = await supabase.storage const { data, error } = await supabase.storage
.from(bucket) .from(bucket)
.list(path, options); .list(path, options);
if (error) throw error; if (error) throw error;
if (!data) throw new Error('No data returned from list operation'); if (!data) throw new Error('No data returned from list operation');
return { success: true, data }; return { success: true, data };
} catch (error) { } catch (error) {
console.error('Could not list files!', error); console.error('Could not list files!', error);
return { return {
success: false, success: false,
error: error:
error instanceof Error ? error.message : 'Unknown error listing files', error instanceof Error ? error.message : 'Unknown error listing files',
}; };
} }
} };
export const resizeImage = async ({ export const resizeImage = async ({
file, file,
options = {}, options = {},
}: resizeImageProps): Promise<File> => { }: resizeImageProps): Promise<File> => {
const { const { maxWidth = 800, maxHeight = 800, quality = 0.8 } = options;
maxWidth = 800, return new Promise((resolve) => {
maxHeight = 800, const reader = new FileReader();
quality = 0.8, reader.readAsDataURL(file);
} = options; reader.onload = (event) => {
return new Promise((resolve) => { const img = new Image();
const reader = new FileReader(); img.src = event.target?.result as string;
reader.readAsDataURL(file); img.onload = () => {
reader.onload = (event) => { let width = img.width;
const img = new Image(); let height = img.height;
img.src = event.target?.result as string; if (width > height) {
img.onload = () => { if (width > maxWidth) {
let width = img.width; height = Math.round((height * maxWidth) / width);
let height = img.height; width = maxWidth;
if (width > height) { }
if (width > maxWidth) { } else if (height > maxHeight) {
height = Math.round((height * maxWidth / width)); width = Math.round((width * maxHeight) / height);
width = maxWidth; height = maxHeight;
} }
} else if (height > maxHeight) { const canvas = document.createElement('canvas');
width = Math.round((width * maxHeight / height)); canvas.width = width;
height = maxHeight; canvas.height = height;
} const ctx = canvas.getContext('2d');
const canvas = document.createElement('canvas'); ctx?.drawImage(img, 0, 0, width, height);
canvas.width = width; canvas.toBlob(
canvas.height = height; (blob) => {
const ctx = canvas.getContext('2d'); if (!blob) return;
ctx?.drawImage(img, 0, 0, width, height); const resizedFile = new File([blob], file.name, {
canvas.toBlob( type: 'imgage/jpeg',
(blob) => { lastModified: Date.now(),
if (!blob) return; });
const resizedFile = new File([blob], file.name, { resolve(resizedFile);
type: 'imgage/jpeg', },
lastModified: Date.now(), 'image/jpeg',
}); quality,
resolve(resizedFile); );
}, };
'image/jpeg', };
quality });
);
};
};
});
}; };

148
src/lib/hooks/auth.ts Normal file
View File

@ -0,0 +1,148 @@
'use client';
import { createClient } from '@/utils/supabase';
import type { User } from '@/utils/supabase';
import type { Result } from '.';
export const signUp = async (
formData: FormData,
): Promise<Result<string | null>> => {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = createClient();
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
if (!email || !password) {
return { success: false, error: 'Email and password are required' };
}
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/callback`,
data: {
full_name: name,
email,
provider: 'email',
},
},
});
if (error) {
return { success: false, error: error.message };
} else {
return {
success: true,
data: 'Thanks for signing up! Please check your email for a verification link.',
};
}
};
export const signIn = async (formData: FormData): Promise<Result<null>> => {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return { success: false, error: error.message };
} else {
return { success: true, data: null };
}
};
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
const supabase = createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'azure',
options: {
scopes: 'openid, profile email offline_access',
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
};
export const signInWithApple = async (): Promise<Result<string>> => {
const supabase = createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
scopes: 'openid, profile email offline_access',
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
};
export const forgotPassword = async (
formData: FormData,
): Promise<Result<string | null>> => {
const email = formData.get('email') as string;
const supabase = createClient();
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
if (!email) {
return { success: false, error: 'Email is required' };
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
});
if (error) {
return { success: false, error: 'Could not reset password' };
}
return {
success: true,
data: 'Check your email for a link to reset your password.',
};
};
export const resetPassword = async (
formData: FormData,
): Promise<Result<null>> => {
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (!password || !confirmPassword) {
return {
success: false,
error: 'Password and confirm password are required!',
};
}
const supabase = createClient();
if (password !== confirmPassword) {
return { success: false, error: 'Passwords do not match!' };
}
const { error } = await supabase.auth.updateUser({
password,
});
if (error) {
return {
success: false,
error: `Password update failed: ${error.message}`,
};
}
return { success: true, data: null };
};
export const signOut = async (): Promise<Result<null>> => {
const supabase = createClient();
const { error } = await supabase.auth.signOut();
if (error) return { success: false, error: error.message };
return { success: true, data: null };
};
export const getUser = async (): Promise<Result<User>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
return { success: true, data: data.user };
} catch (error) {
return { success: false, error: 'Could not get user!' };
}
};

8
src/lib/hooks/index.ts Normal file → Executable file
View File

@ -1,2 +1,8 @@
export * from './resizeImage'; export * from './auth';
export * from './public';
export * from './storage';
export * from './useFileUpload'; export * from './useFileUpload';
export type Result<T> =
| { success: true; data: T }
| { success: false; error: string };

79
src/lib/hooks/public.ts Normal file
View File

@ -0,0 +1,79 @@
'use client';
import { createClient, type Profile } from '@/utils/supabase';
import { getUser } from '@/lib/hooks';
import type { Result } from '.';
export const getProfile = async (): Promise<Result<Profile>> => {
try {
const user = await getUser();
if (!user.success || user.data === undefined)
throw new Error('User not found');
const supabase = createClient();
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', user.data.id)
.single();
if (error) throw error;
return { success: true, data: data as Profile };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting profile',
};
}
};
type updateProfileProps = {
full_name?: string;
email?: string;
avatar_url?: string;
};
export const updateProfile = async ({
full_name,
email,
avatar_url,
}: updateProfileProps): Promise<Result<Profile>> => {
try {
if (
full_name === undefined &&
email === undefined &&
avatar_url === undefined
)
throw new Error('No profile data provided');
const userResponse = await getUser();
if (!userResponse.success || userResponse.data === undefined)
throw new Error('User not found');
const supabase = createClient();
const { data, error } = await supabase
.from('profiles')
.update({
...(full_name !== undefined && { full_name }),
...(email !== undefined && { email }),
...(avatar_url !== undefined && { avatar_url }),
})
.eq('id', userResponse.data.id)
.select()
.single();
if (error) throw error;
return {
success: true,
data: data as Profile,
};
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error updating profile',
};
}
};

View File

@ -1,59 +0,0 @@
'use client'
export type resizeImageProps = {
file: File,
options?: {
maxWidth?: number,
maxHeight?: number,
quality?: number,
}
};
export const resizeImage = async ({
file,
options = {},
}: resizeImageProps): Promise<File> => {
const {
maxWidth = 800,
maxHeight = 800,
quality = 0.8,
} = options;
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round((height * maxWidth / width));
width = maxWidth;
}
} else if (height > maxHeight) {
width = Math.round((width * maxHeight / height));
height = maxHeight;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return;
const resizedFile = new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now(),
});
resolve(resizedFile);
},
'image/jpeg',
quality
);
};
};
});
};

259
src/lib/hooks/storage.ts Normal file
View File

@ -0,0 +1,259 @@
'use client';
import { createClient } from '@/utils/supabase';
import type { Result } from '.';
export type GetStorageProps = {
bucket: string;
url: string;
seconds?: number;
transform?: {
width?: number;
height?: number;
quality?: number;
format?: 'origin';
resize?: 'cover' | 'contain' | 'fill';
};
download?: boolean | string;
};
export type UploadStorageProps = {
bucket: string;
path: string;
file: File;
options?: {
cacheControl?: string;
contentType?: string;
};
};
export type ReplaceStorageProps = {
bucket: string;
path: string;
file: File;
options?: {
cacheControl?: string;
contentType?: string;
};
};
export type resizeImageProps = {
file: File;
options?: {
maxWidth?: number;
maxHeight?: number;
quality?: number;
};
};
export const getSignedUrl = async ({
bucket,
url,
seconds = 3600,
transform = {},
download = false,
}: GetStorageProps): Promise<Result<string>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(url, seconds, {
download,
transform,
});
if (error) throw error;
if (!data?.signedUrl) throw new Error('No signed URL returned');
return { success: true, data: data.signedUrl };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting signed URL',
};
}
};
export const getPublicUrl = async ({
bucket,
url,
transform = {},
download = false,
}: GetStorageProps): Promise<Result<string>> => {
try {
const supabase = createClient();
const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
download,
transform,
});
if (!data?.publicUrl) throw new Error('No public URL returned');
return { success: true, data: data.publicUrl };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting public URL',
};
}
};
export const uploadFile = async ({
bucket,
path,
file,
options = {},
}: UploadStorageProps): Promise<Result<string>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.storage
.from(bucket)
.upload(path, file, options);
if (error) throw error;
if (!data?.path) throw new Error('No path returned from upload');
return { success: true, data: data.path };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error uploading file',
};
}
};
export const replaceFile = async ({
bucket,
path,
file,
options = {},
}: ReplaceStorageProps): Promise<Result<string>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.storage
.from(bucket)
.update(path, file, {
...options,
upsert: true,
});
if (error) throw error;
if (!data?.path) throw new Error('No path returned from upload');
return { success: true, data: data.path };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error replacing file',
};
}
};
// Add a helper to delete files
export const deleteFile = async ({
bucket,
path,
}: {
bucket: string;
path: string[];
}): Promise<Result<null>> => {
try {
const supabase = createClient();
const { error } = await supabase.storage.from(bucket).remove(path);
if (error) throw error;
return { success: true, data: null };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error deleting file',
};
}
};
// Add a helper to list files in a bucket
export const listFiles = async ({
bucket,
path = '',
options = {},
}: {
bucket: string;
path?: string;
options?: {
limit?: number;
offset?: number;
sortBy?: { column: string; order: 'asc' | 'desc' };
};
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.storage
.from(bucket)
.list(path, options);
if (error) throw error;
if (!data) throw new Error('No data returned from list operation');
return { success: true, data };
} catch (error) {
console.error('Could not list files!', error);
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error listing files',
};
}
};
export const resizeImage = async ({
file,
options = {},
}: resizeImageProps): Promise<File> => {
const { maxWidth = 800, maxHeight = 800, quality = 0.8 } = options;
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
} else if (height > maxHeight) {
width = Math.round((width * maxHeight) / height);
height = maxHeight;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return;
const resizedFile = new File([blob], file.name, {
type: 'imgage/jpeg',
lastModified: Date.now(),
});
resolve(resizedFile);
},
'image/jpeg',
quality,
);
};
};
});
};

Some files were not shown because too many files have changed in this diff Show More