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"
# should be updated accordingly.
@ -13,14 +5,26 @@
# SERVERVAR="foo"
# NEXT_PUBLIC_CLIENTVAR="bar"
# Server Variables
NODE_ENV=
### Server Variables ###
# 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_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_PORT= # 5432
#SUPABASE_DB_USER= # postgres

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -1,25 +1,67 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
/* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */
const config = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
serverExternalPackages: ['require-in-the-middle'],
experimental: {
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",
"check": "next lint && tsc --noEmit",
"dev": "next dev --turbo",
"dev:slow": "next dev",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
@ -16,15 +17,16 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@hookform/resolvers": "^5.1.0",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@sentry/nextjs": "^9.27.0",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.8",
"@supabase/supabase-js": "^2.50.0",
"@t3-oss/env-nextjs": "^0.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -33,30 +35,36 @@
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"sonner": "^2.0.4",
"zod": "^3.25.42"
"react-hook-form": "^7.57.0",
"require-in-the-middle": "^7.5.2",
"sonner": "^2.0.5",
"zod": "^3.25.56"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@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-dom": "^19.1.5",
"@types/react-dom": "^19.1.6",
"eslint": "^9.28.0",
"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",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.12",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.2",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"typescript-eslint": "^8.33.0"
"typescript-eslint": "^8.33.1"
},
"ct3aMetadata": {
"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:
- '@sentry/cli'
- '@tailwindcss/oxide'
- sharp
- unrs-resolver

View File

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

View File

@ -1,4 +1,4 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
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.
*/
import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */
const config = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
serverExternalPackages: ['require-in-the-middle'],
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
typescript: {
ignoreBuildErrors: 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.
*/
import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */
const config = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
serverExternalPackages: ['require-in-the-middle'],
experimental: {
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';
import 'server-only';
import { createServerClient } from '@/utils/supabase';
import { type EmailOtpType } from '@supabase/supabase-js';
@ -7,27 +6,38 @@ import { type NextRequest } from 'next/server';
import { redirect } from 'next/navigation';
export const GET = async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get('token');
const type = searchParams.get('type') as EmailOtpType | null;
const redirectTo = searchParams.get('redirect_to') ?? '/';
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const token_hash = searchParams.get('token');
const type = searchParams.get('type') as EmailOtpType | null;
const redirectTo = searchParams.get('redirect_to') ?? '/';
const supabase = await createServerClient();
if (token_hash && type) {
const supabase = await createServerClient();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
if (type === 'signup' || type === 'magiclink' || type === 'email')
return redirect('/');
if (type === 'recovery' || type === 'email_change')
return redirect('/profile');
if (type === 'invite')
return redirect('/sign-up');
else return redirect(`/?Could not identify type ${type as string}`)
}
else return redirect(`/?${error.message}`);
}
return redirect('/');
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
console.error('OAuth error:', error);
return redirect(`/sign-in?error=${encodeURIComponent(error.message)}`);
}
return redirect(redirectTo);
}
if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
if (type === 'signup' || type === 'magiclink' || type === 'email')
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 { forgotPassword } from '@/lib/actions';
import { FormMessage, type Message, SubmitButton } from '@/components/default';
import { Input, Label } from '@/components/ui';
import { SmtpMessage } from '@/app/(auth-pages)/smtp-message';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
const ForgotPassword = async (props: { searchParams: Promise<Message> }) => {
const searchParams = await props.searchParams;
return (
<>
<form
className='flex-1 flex flex-col w-full gap-2 text-foreground
[&>input]:mb-6 min-w-64 max-w-64 mx-auto'
>
<div>
<h1 className='text-2xl font-medium'>Reset Password</h1>
<p className='text-sm text-secondary-foreground'>
Already have an account?{' '}
<Link className='text-primary underline' href='/sign-in'>
Sign in
</Link>
</p>
</div>
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'>
<Label htmlFor='email'>Email</Label>
<Input name='email' placeholder='you@example.com' required />
<SubmitButton formAction={forgotPassword}>
Reset Password
</SubmitButton>
<FormMessage message={searchParams} />
</div>
</form>
<SmtpMessage />
</>
);
const formSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
});
const ForgotPassword = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
},
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleForgotPassword = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('email', values.email);
const result = await forgotPassword(formData);
if (result?.success) {
await refreshUserData();
setStatusMessage(
result?.data ?? 'Check your email for a link to reset your password.',
);
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<Card className='min-w-xs md:min-w-sm'>
<CardHeader>
<CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
<CardDescription className='text-sm text-foreground'>
Don&apos;t have an account?{' '}
<Link className='font-medium underline' href='/sign-up'>
Sign up
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleForgotPassword)}
className='flex flex-col min-w-64 space-y-6'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SubmitButton
disabled={isLoading}
pendingText='Resetting Password...'
>
Reset Password
</SubmitButton>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ success: statusMessage }} />
))}
</form>
</Form>
</CardContent>
</Card>
);
};
export default ForgotPassword;

View File

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

View File

@ -4,155 +4,168 @@ import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Label,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@/components/ui';
import Link from 'next/link';
import { signIn } from '@/lib/actions';
import { SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
import { Separator } from '@/components/ui';
import { SignInWithMicrosoft } from '@/components/default/auth/SignInWithMicrosoft';
import { SignInWithApple } from '@/components/default/auth/SignInWithApple';
const formSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
})
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
});
const Login = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleSignIn = async (values: z.infer<typeof formSchema>) => {
try {
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
const result = await signIn(formData);
if (result?.success) {
await refreshUserData();
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`)
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`
);
}
};
const handleSignIn = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
const result = await signIn(formData);
if (result?.success) {
await refreshUserData();
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<Card>
<CardHeader>
<CardTitle className='text-2xl font-medium'>
Sign In
</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(handleSignIn)}
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>
)}
/>
return (
<Card className='min-w-xs md:min-w-sm'>
<CardHeader>
<CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
<CardDescription className='text-foreground'>
Don&apos;t have an account?{' '}
<Link className='font-medium underline' href='/sign-up'>
Sign up
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSignIn)}
className='flex flex-col min-w-64 space-y-6 pb-4'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<div className='flex justify-between'>
<FormLabel>Password</FormLabel>
<Link
className='text-xs text-foreground underline text-right'
href='/forgot-password'
>
Forgot Password?
</Link>
</div>
<FormControl>
<Input type='password' placeholder='Your password' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{statusMessage && (
<div
className={`text-sm text-center ${
statusMessage.includes('Error') || statusMessage.includes('failed')
? 'text-destructive'
: 'text-green-800'
}`}
>
{statusMessage}
</div>
)}
<SubmitButton
disabled={isLoading}
pendingText='Signing In...'
>
Sign in
</SubmitButton>
</form>
</Form>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<div className='flex justify-between'>
<FormLabel className='text-lg'>Password</FormLabel>
<Link
className='text-xs text-foreground underline text-right'
href='/forgot-password'
>
Forgot Password?
</Link>
</div>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ message: statusMessage }} />
))}
<SubmitButton
disabled={isLoading}
pendingText='Signing In...'
className='text-[1.0rem] cursor-pointer'
>
Sign in
</SubmitButton>
</form>
</Form>
</CardContent>
</Card>
);
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft />
<SignInWithApple />
</CardContent>
</Card>
);
};
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 { signUp } from '@/lib/actions';
import { FormMessage, type Message, SubmitButton } from '@/components/default';
import { Input, Label } from '@/components/ui';
import { redirect } from 'next/navigation';
import { getUser } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
} from '@/components/ui';
import { useEffect, useState } from 'react';
import {
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const SignUp = async (props: { searchParams: Promise<Message> }) => {
const searchParams = await props.searchParams;
const user = await getUser();
if (user.success) redirect('/profile');
if ('message' in searchParams) {
return (
<div
className='w-full flex-1 flex items-center h-screen
sm:max-w-md justify-center gap-2 p-4'
>
<FormMessage message={searchParams} />
</div>
);
} else {
return (
<form className='flex flex-col min-w-64 max-w-64 mx-auto'>
<h1 className='text-2xl font-medium'>Sign up</h1>
<p className='text-sm text text-foreground'>
Already have an account?{' '}
<Link className='text-primary font-medium underline' href='/sign-in'>
Sign in
</Link>
</p>
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'>
<Label htmlFor='name'>Name</Label>
<Input name='name' placeholder='Full Name' required />
<Label htmlFor='email'>Email</Label>
<Input name='email' placeholder='you@example.com' required />
<Label htmlFor='password'>Password</Label>
<Input
type='password'
name='password'
placeholder='Your password'
minLength={6}
required
/>
<SubmitButton formAction={signUp} pendingText='Signing up...'>
Sign up
</SubmitButton>
<FormMessage message={searchParams} />
</div>
</form>
);
}
const formSchema = z
.object({
name: z.string().min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
confirmPassword: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const SignUp = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
mode: 'onChange',
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleSignUp = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('name', values.name);
formData.append('email', values.email);
formData.append('password', values.password);
const result = await signUp(formData);
if (result?.success) {
await refreshUserData();
setStatusMessage(
result.data ??
'Thanks for signing up! Please check your email for a verification link.',
);
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<Card className='min-w-xs md:min-w-sm'>
<CardHeader>
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
<CardDescription className='text-foreground'>
Already have an account?{' '}
<Link className='text-primary font-medium underline' href='/sign-in'>
Sign in
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSignUp)}
className='flex flex-col mx-auto space-y-4 mb-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Name</FormLabel>
<FormControl>
<Input type='text' placeholder='Full Name' {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Confirm Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ success: statusMessage }} />
))}
<SubmitButton
className='text-[1.0rem] cursor-pointer'
disabled={isLoading}
pendingText='Signing Up...'
>
Sign Up
</SubmitButton>
</form>
</Form>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft type='signUp' />
<SignInWithApple type='signUp' />
</CardContent>
</Card>
);
};
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 { Geist } from 'next/font/google';
import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme';
import { AuthProvider } from '@/components/context/auth'
import { AuthProvider, ThemeProvider } from '@/components/context';
import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer';
import { Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs';
export const metadata: Metadata = {
title: 'T3 Template with Supabase',
description: 'Generated by create-t3-app',
icons: [
{
rel: 'icon',
url: '/images/favicon.ico',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
url: '/images/favicon.png',
},
{
rel: 'apple-touch-icon',
url: '/images/appicon.png',
},
],
export const generateMetadata = (): Metadata => {
return {
title: {
template: '%s | T3 Template',
default: 'T3 Template with Supabase',
},
description: 'Created by Gib with T3!',
applicationName: 'T3 Template',
keywords:
'T3 Template, Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib, Theo',
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
creator: 'Gib Brown',
publisher: 'Gib Brown',
formatDetection: {
email: false,
address: false,
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({
subsets: ['latin'],
variable: '--font-geist-sans',
subsets: ['latin'],
variable: '--font-geist-sans',
});
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
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'>
{children}
</div>
</div>
<Footer />
</main>
<Toaster />
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
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'
>
{children}
</div>
</div>
<Footer />
</main>
<Toaster />
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
};
export default RootLayout;

View File

@ -4,45 +4,93 @@ import { FetchDataSteps } from '@/components/default/tutorial';
import { InfoIcon } from 'lucide-react';
import { getUser } from '@/lib/actions';
import type { User } from '@/utils/supabase';
import { TestSentryCard } from '@/components/default/sentry';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from '@/components/ui';
import {
SignInSignUp,
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const HomePage = async () => {
const response = await getUser();
if (!response.success || !response.data) {
return (
<main className='w-full items-center justify-center'>
<div className='flex p-5 items-center justify-center'>
<h1>Make sure you can sign in!</h1>
</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
const response = await getUser();
if (!response.success || !response.data) {
return (
<main className='w-full items-center justify-center'>
<div className='flex flex-col p-5 items-center justify-center space-y-6'>
<Card className='md:min-w-2xl'>
<CardHeader className='flex flex-col items-center'>
<CardTitle className='text-3xl'>
Welcome to the T3 Supabase Template!
</CardTitle>
<CardDescription className='text-[1.0rem] mb-2'>
A great place to start is by creating a new user account &
ensuring you can sign up! If you already have an account, go
ahead and sign in!
</CardDescription>
<SignInSignUp
className='flex gap-4 w-full justify-center'
signInSize='xl'
signUpSize='xl'
/>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<div className='flex gap-4'>
<SignInWithMicrosoft buttonSize='lg' />
<SignInWithApple buttonSize='lg' />
</div>
</CardHeader>
<Separator className='bg-accent' />
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'>
<CardTitle className='text-lg mb-6 w-2/3 text-center'>
You can also test out your connection to Sentry if you want to
start there!
</CardTitle>
<TestSentryCard />
</CardContent>
</Card>
</div>
</main>
);
}
const user: User = response.data;
return (
<div className='flex-1 w-full flex flex-col gap-12'>
<div className='w-full'>
<div
className='bg-accent text-sm p-3 px-5
rounded-md text-foreground flex gap-3 items-center'
>
<InfoIcon size='16' strokeWidth={2} />
This is a protected component that you can only see as an
authenticated user
</div>
</div>
<div className='flex flex-col gap-2 items-start'>
<h2 className='font-bold text-2xl mb-4'>Your user details</h2>
<pre
className='text-xs font-mono p-3 rounded
border max-h-32 overflow-auto'
>
{JSON.stringify(user, null, 2)}
</pre>
</div>
<div>
<h2 className='font-bold text-2xl mb-4'>Next steps</h2>
<FetchDataSteps />
</div>
</div>
);
>
<InfoIcon size='16' strokeWidth={2} />
This is a protected component that you can only see as an
authenticated user
</div>
</div>
<div className='flex flex-col gap-2 items-start'>
<h2 className='font-bold text-3xl mb-4'>Your user details</h2>
<pre
className='text-sm font-mono p-3 rounded
border max-h-50 overflow-auto'
>
{JSON.stringify(user, null, 2)}
</pre>
</div>
<TestSentryCard />
<div>
<h2 className='font-bold text-2xl mb-4'>Next steps</h2>
<FetchDataSteps />
</div>
</div>
);
};
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';
const FooterTest = () => {
return (
<footer className='w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'>
<p>
Powered by{' '}
<a
href='https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs'
target='_blank'
className='font-bold hover:underline'
rel='noreferrer'
>
Supabase
</a>
</p>
</footer>
);
const Footer = () => {
return (
<footer className='w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'>
<p>
Powered by{' '}
<a
href='https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs'
target='_blank'
className='font-bold hover:underline'
rel='noreferrer'
>
Supabase
</a>
</p>
</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';
import { SubmitButton } from '@/components/default/submit-button';
export { FormMessage, type Message, SubmitButton };
export {
StatusMessage,
type Message,
} from '@/components/default/StatusMessage';
export { SubmitButton } from '@/components/default/SubmitButton';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,56 +5,58 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
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",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
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',
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',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
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',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
smicon: 'size-6',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
"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: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
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',
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',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
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',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
xl: 'h-12 rounded-md px-8 has-[>svg]:px-6',
xxl: 'h-14 rounded-md px-10 has-[>svg]:px-8',
icon: 'size-9',
smicon: 'size-6',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
return (
<Comp
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

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

View File

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

View File

@ -7,251 +7,251 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function DropdownMenu({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
}
function DropdownMenuPortal({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
);
return (
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
);
}
function DropdownMenuTrigger({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger'
{...props}
/>
);
return (
<DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger'
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
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',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
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',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
);
return (
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
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",
className,
)}
{...props}
/>
);
return (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
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",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
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",
className,
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
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",
className,
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
);
return (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
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",
className,
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
return (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
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",
className,
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
);
return (
<DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
return (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
return (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
...props
}: 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({
className,
inset,
children,
...props
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
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',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto size-4' />
</DropdownMenuPrimitive.SubTrigger>
);
return (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
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',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto size-4' />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content'
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',
className,
)}
{...props}
/>
);
return (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content'
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',
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

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

View File

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

View File

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

View File

@ -6,23 +6,23 @@ import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot='separator-root'
decorative={decorative}
orientation={orientation}
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',
className,
)}
{...props}
/>
);
return (
<SeparatorPrimitive.Root
data-slot='separator-root'
decorative={decorative}
orientation={orientation}
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',
className,
)}
{...props}
/>
);
}
export { Separator };

View File

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

View File

@ -2,42 +2,57 @@ import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
NODE_ENV: z.enum(['development', 'test', 'production']),
},
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
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.
* 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_`.
*/
client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
},
/**
* Specify your client-side environment variables schema here.
* 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_`.
*/
client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
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.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
/**
* 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.
*/
runtimeEnv: {
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_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
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,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* 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';
import 'server-only';
import { encodedRedirect } from '@/utils/utils';
import { createServerClient } from '@/utils/supabase';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import type { User } from '@/utils/supabase';
import type { Result } from './index';
import type { Result } from '.';
export const signUp = async (formData: FormData) => {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = await createServerClient();
const origin = (await headers()).get('origin');
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 = await createServerClient();
const origin = (await headers()).get('origin');
if (!email || !password) {
return encodedRedirect(
'error',
'/sign-up',
'Email & password are required',
);
}
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 encodedRedirect('error', '/sign-up', error.message);
} else {
return encodedRedirect(
'success',
'/sign-up',
'Thanks for signing up! Please check your email for a verification link.',
);
}
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,
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 = 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>> => {
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 forgotPassword = async (formData: FormData) => {
const email = formData.get('email') as string;
const supabase = await createServerClient();
const origin = (await headers()).get('origin');
const callbackUrl = formData.get('callbackUrl') as string;
if (!email) {
return encodedRedirect('error', '/forgot-password', 'Email is required');
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/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 };
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>> => {
const supabase = await createServerClient();
const { error } = await supabase.auth.signOut();
if (error) return { success: false, error: error.message }
return { success: true, data: null };
const supabase = await createServerClient();
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 = await createServerClient();
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!' };
}
try {
const supabase = await createServerClient();
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!' };
}
};

View File

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

View File

@ -3,78 +3,78 @@
import 'server-only';
import { createServerClient, type Profile } from '@/utils/supabase';
import { getUser } from '@/lib/actions';
import type { Result } from './index';
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 = await createServerClient();
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',
};
}
try {
const user = await getUser();
if (!user.success || user.data === undefined)
throw new Error('User not found');
const supabase = await createServerClient();
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;
full_name?: string;
email?: string;
avatar_url?: string;
};
export const updateProfile = async ({
full_name,
email,
avatar_url,
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');
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 userResponse = await getUser();
if (!userResponse.success || userResponse.data === undefined)
throw new Error('User not found');
const supabase = await createServerClient();
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',
};
}
const supabase = await createServerClient();
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',
};
}
};

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

@ -1,270 +1,256 @@
'use server';
import 'server-only';
import { createServerClient } from '@/utils/supabase';
import type { Result } from './index';
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;
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;
upsert?: boolean;
contentType?: string;
};
bucket: string;
path: string;
file: File;
options?: {
cacheControl?: string;
contentType?: string;
};
};
export type ReplaceStorageProps = {
bucket: string;
prevPath: string;
path: string;
file: File;
options?: {
cacheControl?: string;
upsert?: boolean;
contentType?: string;
};
bucket: string;
path: string;
file: File;
options?: {
cacheControl?: string;
contentType?: string;
};
};
export type resizeImageProps = {
file: File,
options?: {
maxWidth?: number,
maxHeight?: number,
quality?: number,
}
file: File;
options?: {
maxWidth?: number;
maxHeight?: number;
quality?: number;
};
};
export const getSignedUrl = async ({
bucket,
url,
seconds = 3600,
transform = {},
download = false,
bucket,
url,
seconds = 3600,
transform = {},
download = false,
}: GetStorageProps): Promise<Result<string>> => {
try {
const supabase = await createServerClient();
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(url, seconds, {
download,
transform,
});
try {
const supabase = await createServerClient();
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');
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',
};
}
}
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,
bucket,
url,
transform = {},
download = false,
}: GetStorageProps): Promise<Result<string>> => {
try {
const supabase = await createServerClient();
const { data } = supabase.storage
.from(bucket)
.getPublicUrl(url, {
download,
transform,
});
try {
const supabase = await createServerClient();
const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
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 };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting public URL',
};
}
}
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 = {},
bucket,
path,
file,
options = {},
}: UploadStorageProps): Promise<Result<string>> => {
try {
const supabase = await createServerClient();
const { data, error } = await supabase.storage
.from(bucket)
.upload(path, file, options);
try {
const supabase = await createServerClient();
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');
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',
};
}
}
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,
prevPath,
path,
file,
options = {},
bucket,
path,
file,
options = {},
}: ReplaceStorageProps): Promise<Result<string>> => {
try {
const supabase = await createServerClient();
const { data, error } = await supabase.storage
.from(bucket)
.update(path, file, options);
if (error) throw error;
if (!data?.path) throw new Error('No path returned from upload');
const deleteFileData = await deleteFile({
bucket,
path: [...prevPath],
});
return { success: true, data: data.path };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error replacing file',
};
}
try {
const supabase = await createServerClient();
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,
path,
}: {
bucket: string;
path: string[];
bucket: string;
path: string[];
}): Promise<Result<null>> => {
try {
const supabase = await createServerClient();
const { error } = await supabase.storage.from(bucket).remove(path);
try {
const supabase = await createServerClient();
const { error } = await supabase.storage.from(bucket).remove(path);
if (error) throw error;
if (error) throw error;
return { success: true, data: null };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error deleting file',
};
}
}
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,
path = '',
options = {},
}: {
bucket: string;
path?: string;
options?: {
limit?: number;
offset?: number;
sortBy?: { column: string; order: 'asc' | 'desc' };
};
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 = await createServerClient();
const { data, error } = await supabase.storage
.from(bucket)
.list(path, options);
try {
const supabase = await createServerClient();
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');
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',
};
}
}
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 = {},
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
);
};
};
});
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,
);
};
};
});
};

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