Compare commits
4 Commits
c47c43dc92
...
main
Author | SHA1 | Date | |
---|---|---|---|
7d7ed00c22 | |||
42b07ea2da | |||
476d6c91b4 | |||
6a6c0934d5 |
@ -1,5 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"trailingComma": "all"
|
||||
"trailingComma": "all",
|
||||
"useTabs": true
|
||||
}
|
||||
|
@ -3,51 +3,51 @@ 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,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
106
next.config.js
106
next.config.js
@ -6,62 +6,62 @@ import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
// 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);
|
||||
|
@ -6,8 +6,8 @@
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"dev": "next dev",
|
||||
"dev:turbo": "next dev --turbo",
|
||||
"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",
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
export default {
|
||||
plugins: ['prettier-plugin-tailwindcss'],
|
||||
plugins: ['prettier-plugin-tailwindcss'],
|
||||
};
|
||||
|
@ -7,61 +7,61 @@ import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
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',
|
||||
//},
|
||||
//},
|
||||
//},
|
||||
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',
|
||||
//},
|
||||
//},
|
||||
//},
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
// 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);
|
||||
|
@ -7,55 +7,55 @@ import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
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',
|
||||
//},
|
||||
//},
|
||||
//},
|
||||
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',
|
||||
//},
|
||||
//},
|
||||
//},
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
// 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);
|
||||
|
@ -5,11 +5,11 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://0468176d5291bc2b914261147bfef117@sentry.gbrown.org/6',
|
||||
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,
|
||||
// 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,
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
|
@ -6,38 +6,38 @@ import { type NextRequest } from 'next/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const GET = async (request: NextRequest) => {
|
||||
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();
|
||||
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 (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 (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')}`,
|
||||
);
|
||||
}
|
||||
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('/');
|
||||
return redirect('/');
|
||||
};
|
||||
|
@ -1,39 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const AuthSuccessPage = () => {
|
||||
const { refreshUserData, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const { refreshUserData } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const handleAuthSuccess = async () => {
|
||||
// Refresh the auth context to pick up the new session
|
||||
await refreshUserData();
|
||||
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);
|
||||
};
|
||||
// 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]);
|
||||
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>
|
||||
);
|
||||
// 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;
|
||||
|
@ -3,127 +3,127 @@ 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,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import Link from 'next/link';
|
||||
import { forgotPassword } from '@/lib/actions';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
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 router = useRouter();
|
||||
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push('/');
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
// 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!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
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'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>
|
||||
);
|
||||
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'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;
|
||||
|
@ -1,19 +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,
|
||||
SignOut,
|
||||
AvatarUpload,
|
||||
ProfileForm,
|
||||
ResetPasswordForm,
|
||||
SignOut,
|
||||
} from '@/components/default/profile';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
Separator,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
Separator,
|
||||
} from '@/components/ui';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { resetPassword } from '@/lib/actions';
|
||||
@ -21,103 +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-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>
|
||||
);
|
||||
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;
|
||||
|
@ -4,23 +4,23 @@ 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,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import Link from 'next/link';
|
||||
import { signIn } from '@/lib/actions';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { Separator } from '@/components/ui';
|
||||
@ -28,144 +28,144 @@ import { SignInWithMicrosoft } from '@/components/default/auth/SignInWithMicroso
|
||||
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 {
|
||||
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!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const handleSignIn = async (values: z.infer<typeof formSchema>) => {
|
||||
try {
|
||||
setStatusMessage('');
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
formData.append('password', values.password);
|
||||
const result = await signIn(formData);
|
||||
if (result?.success) {
|
||||
await refreshUserData();
|
||||
form.reset();
|
||||
router.push('');
|
||||
} else {
|
||||
setStatusMessage(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='min-w-xs md:min-w-sm'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
|
||||
<CardDescription className='text-foreground'>
|
||||
Don'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>
|
||||
)}
|
||||
/>
|
||||
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't have an account?{' '}
|
||||
<Link className='font-medium underline' href='/sign-up'>
|
||||
Sign up
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSignIn)}
|
||||
className='flex flex-col min-w-64 space-y-6 pb-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex justify-between'>
|
||||
<FormLabel className='text-lg'>Password</FormLabel>
|
||||
<Link
|
||||
className='text-xs text-foreground underline text-right'
|
||||
href='/forgot-password'
|
||||
>
|
||||
Forgot Password?
|
||||
</Link>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{statusMessage &&
|
||||
(statusMessage.includes('Error') ||
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ message: statusMessage }} />
|
||||
))}
|
||||
<SubmitButton
|
||||
disabled={isLoading}
|
||||
pendingText='Signing In...'
|
||||
className='text-[1.0rem] cursor-pointer'
|
||||
>
|
||||
Sign in
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex justify-between'>
|
||||
<FormLabel className='text-lg'>Password</FormLabel>
|
||||
<Link
|
||||
className='text-xs text-foreground underline text-right'
|
||||
href='/forgot-password'
|
||||
>
|
||||
Forgot Password?
|
||||
</Link>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{statusMessage &&
|
||||
(statusMessage.includes('Error') ||
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ message: statusMessage }} />
|
||||
))}
|
||||
<SubmitButton
|
||||
disabled={isLoading}
|
||||
pendingText='Signing In...'
|
||||
className='text-[1.0rem] cursor-pointer'
|
||||
>
|
||||
Sign in
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className='flex items-center w-full gap-4'>
|
||||
<Separator className='flex-1 bg-accent py-0.5' />
|
||||
<span className='text-sm text-muted-foreground'>or</span>
|
||||
<Separator className='flex-1 bg-accent py-0.5' />
|
||||
</div>
|
||||
<SignInWithMicrosoft />
|
||||
<SignInWithApple />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
<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;
|
||||
|
@ -7,204 +7,204 @@ import Link from 'next/link';
|
||||
import { signUp } from '@/lib/actions';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
} from '@/components/ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
SignInWithApple,
|
||||
SignInWithMicrosoft
|
||||
SignInWithApple,
|
||||
SignInWithMicrosoft,
|
||||
} from '@/components/default/auth';
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(2, {
|
||||
message: 'Name must be at least 2 characters.',
|
||||
}),
|
||||
email: z.string().email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
password: z.string().min(8, {
|
||||
message: 'Password must be at least 8 characters.',
|
||||
}),
|
||||
confirmPassword: z.string().min(8, {
|
||||
message: 'Password must be at least 8 characters.',
|
||||
}),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match!',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
.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 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',
|
||||
});
|
||||
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]);
|
||||
// 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!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const handleSignUp = async (values: z.infer<typeof formSchema>) => {
|
||||
try {
|
||||
setStatusMessage('');
|
||||
const formData = new FormData();
|
||||
formData.append('name', values.name);
|
||||
formData.append('email', values.email);
|
||||
formData.append('password', values.password);
|
||||
const result = await signUp(formData);
|
||||
if (result?.success) {
|
||||
await refreshUserData();
|
||||
setStatusMessage(
|
||||
result.data ??
|
||||
'Thanks for signing up! Please check your email for a verification link.',
|
||||
);
|
||||
form.reset();
|
||||
router.push('');
|
||||
} else {
|
||||
setStatusMessage(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='min-w-xs md:min-w-sm'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
|
||||
<CardDescription className='text-foreground'>
|
||||
Already have an account?{' '}
|
||||
<Link className='text-primary font-medium underline' href='/sign-in'>
|
||||
Sign in
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSignUp)}
|
||||
className='flex flex-col mx-auto space-y-4 mb-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='Full Name' {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Confirm password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{statusMessage &&
|
||||
(statusMessage.includes('Error') ||
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ success: statusMessage }} />
|
||||
))}
|
||||
<SubmitButton
|
||||
className='text-[1.0rem] cursor-pointer'
|
||||
disabled={isLoading}
|
||||
pendingText='Signing Up...'
|
||||
>
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex items-center w-full gap-4'>
|
||||
<Separator className='flex-1 bg-accent py-0.5' />
|
||||
<span className='text-sm text-muted-foreground'>or</span>
|
||||
<Separator className='flex-1 bg-accent py-0.5' />
|
||||
</div>
|
||||
<SignInWithMicrosoft />
|
||||
<SignInWithApple />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
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;
|
||||
|
@ -2,15 +2,15 @@ import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
class SentryExampleAPIError extends Error {
|
||||
constructor(message: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'SentryExampleAPIError';
|
||||
}
|
||||
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...' });
|
||||
throw new SentryExampleAPIError(
|
||||
'This error is raised on the backend called by the example page.',
|
||||
);
|
||||
return NextResponse.json({ data: 'Testing Sentry Error...' });
|
||||
}
|
||||
|
@ -2,8 +2,7 @@
|
||||
|
||||
import '@/styles/globals.css';
|
||||
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 { Button, Toaster } from '@/components/ui';
|
||||
@ -13,68 +12,68 @@ import { useEffect } from 'react';
|
||||
import { Geist } from 'next/font/google';
|
||||
|
||||
const geist = Geist({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-sans',
|
||||
});
|
||||
|
||||
type GlobalErrorProps = {
|
||||
error: Error & { digest?: string };
|
||||
reset?: () => void;
|
||||
error: Error & { digest?: string };
|
||||
reset?: () => void;
|
||||
};
|
||||
|
||||
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
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
|
||||
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>
|
||||
);
|
||||
>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try again</Button>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalError;
|
||||
|
@ -2,378 +2,377 @@ 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 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)',
|
||||
},
|
||||
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',
|
||||
};
|
||||
{ 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
|
||||
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>
|
||||
);
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
export default RootLayout;
|
||||
|
158
src/app/page.tsx
158
src/app/page.tsx
@ -6,91 +6,91 @@ import { getUser } from '@/lib/actions';
|
||||
import type { User } from '@/utils/supabase';
|
||||
import { TestSentryCard } from '@/components/default/sentry';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Separator,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Separator,
|
||||
} from '@/components/ui';
|
||||
import {
|
||||
SignInSignUp,
|
||||
SignInWithApple,
|
||||
SignInWithMicrosoft
|
||||
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 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
|
||||
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-3xl mb-4'>Your user details</h2>
|
||||
<pre
|
||||
className='text-sm font-mono p-3 rounded
|
||||
>
|
||||
<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>
|
||||
);
|
||||
>
|
||||
{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;
|
||||
|
195
src/components/context/Auth.tsx
Normal file
195
src/components/context/Auth.tsx
Normal 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;
|
||||
};
|
68
src/components/context/Theme.tsx
Normal file
68
src/components/context/Theme.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -1,195 +0,0 @@
|
||||
'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;
|
||||
};
|
2
src/components/context/index.tsx
Normal file
2
src/components/context/index.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export { AuthProvider, useAuth } from './Auth';
|
||||
export { ThemeProvider, ThemeToggle } from './Theme';
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,25 +1,25 @@
|
||||
export type Message =
|
||||
| { success: string }
|
||||
| { error: string }
|
||||
| { message: string };
|
||||
| { 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
|
||||
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>
|
||||
);
|
||||
>
|
||||
{'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>
|
||||
);
|
||||
};
|
||||
|
@ -6,34 +6,34 @@ import { useFormStatus } from 'react-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type Props = ComponentProps<typeof Button> & {
|
||||
disabled?: boolean;
|
||||
pendingText?: string;
|
||||
disabled?: boolean;
|
||||
pendingText?: string;
|
||||
};
|
||||
|
||||
export const SubmitButton = ({
|
||||
children,
|
||||
disabled = false,
|
||||
pendingText = 'Submitting...',
|
||||
...props
|
||||
children,
|
||||
disabled = false,
|
||||
pendingText = 'Submitting...',
|
||||
...props
|
||||
}: Props) => {
|
||||
const { pending } = useFormStatus();
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -6,28 +6,28 @@ 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'];
|
||||
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',
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -1,77 +1,77 @@
|
||||
'use client';
|
||||
import { signInWithApple } from '@/lib/actions';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Button, type buttonVariants } from '@/components/ui';
|
||||
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'];
|
||||
className?: ComponentProps<'div'>['className'];
|
||||
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
||||
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||
};
|
||||
|
||||
export const SignInWithApple = ({
|
||||
className = 'my-4',
|
||||
buttonSize = 'default',
|
||||
buttonVariant = 'default',
|
||||
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 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 handleSignInWithApple = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setStatusMessage('');
|
||||
setIsSigningIn(true);
|
||||
|
||||
const result = await signInWithApple();
|
||||
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('');
|
||||
}
|
||||
};
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -1,70 +1,70 @@
|
||||
'use client';
|
||||
import { signInWithMicrosoft } from '@/lib/actions';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Button, type buttonVariants } from '@/components/ui';
|
||||
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'];
|
||||
className?: ComponentProps<'div'>['className'];
|
||||
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
||||
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||
};
|
||||
|
||||
export const SignInWithMicrosoft = ({
|
||||
className = 'my-4',
|
||||
buttonSize = 'default',
|
||||
buttonVariant = 'default',
|
||||
className = 'my-4',
|
||||
buttonSize = 'default',
|
||||
buttonVariant = 'default',
|
||||
}: SignInWithMicrosoftProps) => {
|
||||
const { isLoading } = useAuth();
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
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 handleSignInWithMicrosoft = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setStatusMessage('');
|
||||
setIsSigningIn(true);
|
||||
|
||||
const result = await signInWithMicrosoft();
|
||||
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!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -1,20 +1,20 @@
|
||||
'use server';
|
||||
|
||||
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>
|
||||
);
|
||||
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 Footer;
|
||||
|
@ -1,5 +1,5 @@
|
||||
export {
|
||||
StatusMessage,
|
||||
type Message,
|
||||
StatusMessage,
|
||||
type Message,
|
||||
} from '@/components/default/StatusMessage';
|
||||
export { SubmitButton } from '@/components/default/SubmitButton';
|
||||
|
@ -2,86 +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'
|
||||
>
|
||||
Sign 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;
|
||||
|
@ -5,18 +5,18 @@ 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>
|
||||
) : (
|
||||
<SignInSignUp />
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Error getting profile: ${error as string}`);
|
||||
return <SignInSignUp />;
|
||||
}
|
||||
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;
|
||||
|
@ -3,38 +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 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>
|
||||
);
|
||||
>
|
||||
<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;
|
||||
|
@ -1,112 +1,112 @@
|
||||
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
CardContent,
|
||||
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,
|
||||
},
|
||||
replace: { replace: true, path: profile?.avatar_url ?? file.name },
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
await onAvatarUploaded(result.data);
|
||||
}
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -2,99 +2,99 @@ import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
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();
|
||||
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'>
|
||||
<SubmitButton disabled={isLoading} pendingText='Saving...'>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
);
|
||||
<div className='flex justify-center'>
|
||||
<SubmitButton disabled={isLoading} pendingText='Saving...'>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
|
@ -2,18 +2,18 @@ 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';
|
||||
@ -21,130 +21,127 @@ import { type Result } from '@/lib/actions';
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -3,32 +3,32 @@
|
||||
import { CardHeader } from '@/components/ui';
|
||||
import { SubmitButton } from '@/components/default';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { signOut } from '@/lib/actions';
|
||||
|
||||
export const SignOut = () => {
|
||||
const { isLoading, refreshUserData } = useAuth();
|
||||
const router = useRouter();
|
||||
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
|
||||
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>
|
||||
);
|
||||
disabled={isLoading}
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign Out
|
||||
</SubmitButton>
|
||||
</CardHeader>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -3,124 +3,124 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Separator,
|
||||
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';
|
||||
}
|
||||
constructor(message: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'SentryExampleFrontendError';
|
||||
}
|
||||
}
|
||||
|
||||
export const TestSentryCard = () => {
|
||||
const [hasSentError, setHasSentError] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(true);
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
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.',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -4,58 +4,58 @@ 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>
|
||||
<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>
|
||||
<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 [icon, setIcon] = useState(CopyIcon);
|
||||
|
||||
const copy = async () => {
|
||||
await navigator?.clipboard?.writeText(code);
|
||||
setIcon(CheckIcon);
|
||||
setTimeout(() => setIcon(CopyIcon), 2000);
|
||||
};
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -44,52 +44,52 @@ export default function Page() {
|
||||
`.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'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>
|
||||
return (
|
||||
<ol className='flex flex-col gap-6'>
|
||||
<TutorialStep title='Create some tables and insert some data'>
|
||||
<p>
|
||||
Head over to the{' '}
|
||||
<a
|
||||
href='https://supabase.com/dashboard/project/_/editor'
|
||||
className='font-bold hover:underline text-foreground/80'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Table Editor
|
||||
</a>{' '}
|
||||
for your Supabase project to create a table and insert some example
|
||||
data. If you're stuck for creativity, you can copy and paste the
|
||||
following into the{' '}
|
||||
<a
|
||||
href='https://supabase.com/dashboard/project/_/sql/new'
|
||||
className='font-bold hover:underline text-foreground/80'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
SQL Editor
|
||||
</a>{' '}
|
||||
and click RUN!
|
||||
</p>
|
||||
<CodeBlock code={create} />
|
||||
</TutorialStep>
|
||||
|
||||
<TutorialStep title='Query Supabase data from Next.js'>
|
||||
<p>
|
||||
To create a Supabase client and query data from an Async Server
|
||||
Component, create a new page.tsx file at{' '}
|
||||
<span className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs font-medium text-secondary-foreground border'>
|
||||
/app/notes/page.tsx
|
||||
</span>{' '}
|
||||
and add the following.
|
||||
</p>
|
||||
<CodeBlock code={server} />
|
||||
<p>Alternatively, you can use a Client Component.</p>
|
||||
<CodeBlock code={client} />
|
||||
</TutorialStep>
|
||||
<TutorialStep title='Query Supabase data from Next.js'>
|
||||
<p>
|
||||
To create a Supabase client and query data from an Async Server
|
||||
Component, create a new page.tsx file at{' '}
|
||||
<span className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs font-medium text-secondary-foreground border'>
|
||||
/app/notes/page.tsx
|
||||
</span>{' '}
|
||||
and add the following.
|
||||
</p>
|
||||
<CodeBlock code={server} />
|
||||
<p>Alternatively, you can use a Client Component.</p>
|
||||
<CodeBlock code={client} />
|
||||
</TutorialStep>
|
||||
|
||||
<TutorialStep title='Build in a weekend and scale to millions!'>
|
||||
<p>You're ready to launch your product to the world! 🚀</p>
|
||||
</TutorialStep>
|
||||
</ol>
|
||||
);
|
||||
<TutorialStep title='Build in a weekend and scale to millions!'>
|
||||
<p>You're ready to launch your product to the world! 🚀</p>
|
||||
</TutorialStep>
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
|
@ -1,30 +1,30 @@
|
||||
import { Checkbox } from '@/components/ui';
|
||||
|
||||
export const TutorialStep = ({
|
||||
title,
|
||||
children,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -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 };
|
||||
|
@ -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 };
|
||||
|
@ -5,58 +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',
|
||||
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',
|
||||
},
|
||||
},
|
||||
"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 };
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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 };
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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 };
|
||||
|
@ -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 };
|
||||
|
@ -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 };
|
||||
|
@ -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 };
|
||||
|
100
src/env.js
100
src/env.js
@ -2,57 +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'])
|
||||
.default('development'),
|
||||
SENTRY_AUTH_TOKEN: z.string().min(1),
|
||||
CI: z.enum(['true', 'false']).default('false'),
|
||||
},
|
||||
/**
|
||||
* 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),
|
||||
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'),
|
||||
},
|
||||
/**
|
||||
* 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,
|
||||
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
||||
CI: process.env.CI,
|
||||
/**
|
||||
* 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,
|
||||
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,
|
||||
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,
|
||||
});
|
||||
|
@ -4,31 +4,31 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
|
||||
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,
|
||||
// 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()],
|
||||
// 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,
|
||||
// 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
|
||||
// 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.
|
||||
|
@ -2,9 +2,9 @@ import * as Sentry from '@sentry/nextjs';
|
||||
import type { Instrumentation } from 'next';
|
||||
|
||||
export const register = async () => {
|
||||
await import('../sentry.server.config');
|
||||
await import('../sentry.server.config');
|
||||
};
|
||||
|
||||
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||
Sentry.captureRequestError(...args);
|
||||
Sentry.captureRequestError(...args);
|
||||
};
|
||||
|
@ -7,150 +7,149 @@ import type { User } from '@/utils/supabase';
|
||||
import type { Result } from '.';
|
||||
|
||||
export const signUp = async (
|
||||
formData: FormData,
|
||||
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');
|
||||
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 { success: false, error: 'Email and 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
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.',
|
||||
};
|
||||
}
|
||||
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 = await createServerClient();
|
||||
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 };
|
||||
}
|
||||
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 };
|
||||
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: {
|
||||
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 };
|
||||
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,
|
||||
formData: FormData,
|
||||
): Promise<Result<string | null>> => {
|
||||
const email = formData.get('email') as string;
|
||||
const supabase = await createServerClient();
|
||||
const origin = (await headers()).get('origin');
|
||||
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' };
|
||||
}
|
||||
if (!email) {
|
||||
return { success: false, error: 'Email is required' };
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
|
||||
});
|
||||
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.',
|
||||
};
|
||||
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,
|
||||
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!' };
|
||||
}
|
||||
};
|
||||
|
@ -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 };
|
||||
|
@ -6,75 +6,75 @@ import { getUser } from '@/lib/actions';
|
||||
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',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -4,253 +4,253 @@ import { createServerClient } 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;
|
||||
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;
|
||||
};
|
||||
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;
|
||||
};
|
||||
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,
|
||||
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, 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',
|
||||
};
|
||||
}
|
||||
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,
|
||||
);
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
@ -4,145 +4,145 @@ import type { User } from '@/utils/supabase';
|
||||
import type { Result } from '.';
|
||||
|
||||
export const signUp = async (
|
||||
formData: FormData,
|
||||
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!;
|
||||
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' };
|
||||
}
|
||||
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.',
|
||||
};
|
||||
}
|
||||
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 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 };
|
||||
}
|
||||
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 };
|
||||
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 };
|
||||
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,
|
||||
formData: FormData,
|
||||
): Promise<Result<string | null>> => {
|
||||
const email = formData.get('email') as string;
|
||||
const supabase = createClient();
|
||||
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
||||
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' };
|
||||
}
|
||||
if (!email) {
|
||||
return { success: false, error: 'Email is required' };
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
|
||||
});
|
||||
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.',
|
||||
};
|
||||
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,
|
||||
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 };
|
||||
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 };
|
||||
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!' };
|
||||
}
|
||||
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!' };
|
||||
}
|
||||
};
|
||||
|
@ -1,9 +1,8 @@
|
||||
export * from './auth';
|
||||
export * from './public';
|
||||
//export * from './resizeImage';
|
||||
export * from './storage';
|
||||
export * from './useFileUpload';
|
||||
|
||||
export type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
@ -5,75 +5,75 @@ 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',
|
||||
};
|
||||
}
|
||||
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;
|
||||
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 = 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',
|
||||
};
|
||||
}
|
||||
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',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -4,256 +4,256 @@ 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;
|
||||
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;
|
||||
};
|
||||
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;
|
||||
};
|
||||
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 = createClient();
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.createSignedUrl(url, seconds, {
|
||||
download,
|
||||
transform,
|
||||
});
|
||||
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');
|
||||
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 = createClient();
|
||||
const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
|
||||
download,
|
||||
transform,
|
||||
});
|
||||
try {
|
||||
const supabase = createClient();
|
||||
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 = createClient();
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(path, file, options);
|
||||
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');
|
||||
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,
|
||||
path,
|
||||
file,
|
||||
options = {},
|
||||
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',
|
||||
};
|
||||
}
|
||||
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,
|
||||
path,
|
||||
}: {
|
||||
bucket: string;
|
||||
path: string[];
|
||||
bucket: string;
|
||||
path: string[];
|
||||
}): Promise<Result<null>> => {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { error } = await supabase.storage.from(bucket).remove(path);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
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 = createClient();
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.list(path, options);
|
||||
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');
|
||||
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,
|
||||
);
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
@ -3,103 +3,103 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { replaceFile, uploadFile } from '@/lib/hooks';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { resizeImage } from '@/lib/hooks';
|
||||
import type { Result } from '.';
|
||||
|
||||
export type Replace = { replace: true; path: string } | false;
|
||||
|
||||
export type uploadToStorageProps = {
|
||||
file: File;
|
||||
bucket: string;
|
||||
resize: boolean;
|
||||
options?: {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
};
|
||||
replace?: Replace;
|
||||
file: File;
|
||||
bucket: string;
|
||||
resize: boolean;
|
||||
options?: {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
};
|
||||
replace?: Replace;
|
||||
};
|
||||
|
||||
export const useFileUpload = () => {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { profile, isAuthenticated } = useAuth();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { profile, isAuthenticated } = useAuth();
|
||||
|
||||
const uploadToStorage = async ({
|
||||
file,
|
||||
bucket,
|
||||
resize = false,
|
||||
options = {},
|
||||
replace = false,
|
||||
}: uploadToStorageProps): Promise<Result<string>> => {
|
||||
try {
|
||||
if (!isAuthenticated) throw new Error('User is not authenticated');
|
||||
const uploadToStorage = async ({
|
||||
file,
|
||||
bucket,
|
||||
resize = false,
|
||||
options = {},
|
||||
replace = false,
|
||||
}: uploadToStorageProps): Promise<Result<string>> => {
|
||||
try {
|
||||
if (!isAuthenticated) throw new Error('User is not authenticated');
|
||||
|
||||
setIsUploading(true);
|
||||
if (replace) {
|
||||
const updateResult = await replaceFile({
|
||||
bucket,
|
||||
path: replace.path,
|
||||
file,
|
||||
options: {
|
||||
contentType: file.type,
|
||||
},
|
||||
});
|
||||
if (!updateResult.success) {
|
||||
return { success: false, error: updateResult.error };
|
||||
} else {
|
||||
return { success: true, data: updateResult.data };
|
||||
}
|
||||
}
|
||||
setIsUploading(true);
|
||||
if (replace) {
|
||||
const updateResult = await replaceFile({
|
||||
bucket,
|
||||
path: replace.path,
|
||||
file,
|
||||
options: {
|
||||
contentType: file.type,
|
||||
},
|
||||
});
|
||||
if (!updateResult.success) {
|
||||
return { success: false, error: updateResult.error };
|
||||
} else {
|
||||
return { success: true, data: updateResult.data };
|
||||
}
|
||||
}
|
||||
|
||||
let fileToUpload = file;
|
||||
if (resize && file.type.startsWith('image/'))
|
||||
fileToUpload = await resizeImage({ file, options });
|
||||
let fileToUpload = file;
|
||||
if (resize && file.type.startsWith('image/'))
|
||||
fileToUpload = await resizeImage({ file, options });
|
||||
|
||||
// Generate a unique filename to avoid collisions
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${Date.now()}-${profile?.id}.${fileExt}`;
|
||||
// Generate a unique filename to avoid collisions
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${Date.now()}-${profile?.id}.${fileExt}`;
|
||||
|
||||
// Upload the file to Supabase storage
|
||||
const uploadResult = await uploadFile({
|
||||
bucket,
|
||||
path: fileName,
|
||||
file: fileToUpload,
|
||||
options: {
|
||||
contentType: file.type,
|
||||
},
|
||||
});
|
||||
// Upload the file to Supabase storage
|
||||
const uploadResult = await uploadFile({
|
||||
bucket,
|
||||
path: fileName,
|
||||
file: fileToUpload,
|
||||
options: {
|
||||
contentType: file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
throw new Error(uploadResult.error || `Failed to upload to ${bucket}`);
|
||||
}
|
||||
if (!uploadResult.success) {
|
||||
throw new Error(uploadResult.error || `Failed to upload to ${bucket}`);
|
||||
}
|
||||
|
||||
return { success: true, data: uploadResult.data };
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to upload to ${bucket}`,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: `Error: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to upload to ${bucket}`
|
||||
}`,
|
||||
};
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Clear the input value so the same file can be selected again
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
return { success: true, data: uploadResult.data };
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to upload to ${bucket}`,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: `Error: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to upload to ${bucket}`
|
||||
}`,
|
||||
};
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Clear the input value so the same file can be selected again
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isUploading,
|
||||
fileInputRef,
|
||||
uploadToStorage,
|
||||
};
|
||||
return {
|
||||
isUploading,
|
||||
fileInputRef,
|
||||
uploadToStorage,
|
||||
};
|
||||
};
|
||||
|
@ -2,5 +2,5 @@ import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
@ -1,21 +1,133 @@
|
||||
import { type NextRequest } from 'next/server';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { updateSession } from '@/utils/supabase/middleware';
|
||||
|
||||
// In-memory store for tracking IPs (use Redis in production)
|
||||
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||
const bannedIPs = new Set<string>();
|
||||
|
||||
// Suspicious patterns that indicate malicious activity
|
||||
const MALICIOUS_PATTERNS = [
|
||||
/web-inf/i,
|
||||
/\.jsp/i,
|
||||
/\.php/i,
|
||||
/puttest/i,
|
||||
/WEB-INF/i,
|
||||
/\.xml$/i,
|
||||
/perl/i,
|
||||
/xampp/i,
|
||||
/phpwebgallery/i,
|
||||
/FileManager/i,
|
||||
/standalonemanager/i,
|
||||
/h2console/i,
|
||||
/WebAdmin/i,
|
||||
/login_form\.php/i,
|
||||
/%2e/i,
|
||||
/%u002e/i,
|
||||
/\.%00/i,
|
||||
/\.\./,
|
||||
/lcgi/i,
|
||||
];
|
||||
|
||||
// Suspicious HTTP methods
|
||||
const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH'];
|
||||
|
||||
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
||||
const MAX_ATTEMPTS = 10; // Max suspicious requests per window
|
||||
const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
const getClientIP = (request: NextRequest): string => {
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
const realIP = request.headers.get('x-real-ip');
|
||||
|
||||
if (forwarded) {
|
||||
return forwarded.split(',')[0].trim();
|
||||
}
|
||||
|
||||
if (realIP) {
|
||||
return realIP;
|
||||
}
|
||||
|
||||
return request.ip ?? 'unknown';
|
||||
};
|
||||
|
||||
const isPathSuspicious = (pathname: string): boolean => {
|
||||
return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
|
||||
};
|
||||
|
||||
const isMethodSuspicious = (method: string): boolean => {
|
||||
return SUSPICIOUS_METHODS.includes(method);
|
||||
};
|
||||
|
||||
const updateIPAttempts = (ip: string): boolean => {
|
||||
const now = Date.now();
|
||||
const attempts = ipAttempts.get(ip);
|
||||
|
||||
if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
|
||||
ipAttempts.set(ip, { count: 1, lastAttempt: now });
|
||||
return false;
|
||||
}
|
||||
|
||||
attempts.count++;
|
||||
attempts.lastAttempt = now;
|
||||
|
||||
if (attempts.count > MAX_ATTEMPTS) {
|
||||
bannedIPs.add(ip);
|
||||
// Clean up the attempts record
|
||||
ipAttempts.delete(ip);
|
||||
|
||||
// Auto-unban after duration (in production, use a proper scheduler)
|
||||
setTimeout(() => {
|
||||
bannedIPs.delete(ip);
|
||||
}, BAN_DURATION);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const middleware = async (request: NextRequest) => {
|
||||
return await updateSession(request);
|
||||
const { pathname } = request.nextUrl;
|
||||
const method = request.method;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
// Check if IP is already banned
|
||||
if (bannedIPs.has(ip)) {
|
||||
console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`);
|
||||
return new NextResponse('Access denied.', { status: 403 });
|
||||
}
|
||||
|
||||
// Check for suspicious activity
|
||||
const isSuspiciousPath = isPathSuspicious(pathname);
|
||||
const isSuspiciousMethod = isMethodSuspicious(method);
|
||||
|
||||
if (isSuspiciousPath || isSuspiciousMethod) {
|
||||
console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`);
|
||||
|
||||
const shouldBan = updateIPAttempts(ip);
|
||||
|
||||
if (shouldBan) {
|
||||
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
|
||||
return new NextResponse('Access denied - IP banned', { status: 403 });
|
||||
}
|
||||
|
||||
// Return 404 to not reveal the blocking mechanism
|
||||
return new NextResponse('Not Found', { status: 404 });
|
||||
}
|
||||
return await updateSession(request);
|
||||
};
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - /monitoring-tunnel (Sentry monitoring)
|
||||
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp
|
||||
* Feel free to modify this pattern to include more paths.
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
],
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - /monitoring-tunnel (Sentry monitoring)
|
||||
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp
|
||||
* Feel free to modify this pattern to include more paths.
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
],
|
||||
};
|
||||
|
@ -5,9 +5,9 @@
|
||||
import { serve } from 'https://deno.land/std@0.177.1/http/server.ts';
|
||||
|
||||
serve(async () => {
|
||||
return new Response(`"Hello from Edge Functions!"`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(`"Hello from Edge Functions!"`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
// To invoke:
|
||||
|
@ -7,88 +7,88 @@ const JWT_SECRET = Deno.env.get('JWT_SECRET');
|
||||
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true';
|
||||
|
||||
function getAuthToken(req: Request) {
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader) {
|
||||
throw new Error('Missing authorization header');
|
||||
}
|
||||
const [bearer, token] = authHeader.split(' ');
|
||||
if (bearer !== 'Bearer') {
|
||||
throw new Error(`Auth header is not 'Bearer {token}'`);
|
||||
}
|
||||
return token;
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader) {
|
||||
throw new Error('Missing authorization header');
|
||||
}
|
||||
const [bearer, token] = authHeader.split(' ');
|
||||
if (bearer !== 'Bearer') {
|
||||
throw new Error(`Auth header is not 'Bearer {token}'`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function verifyJWT(jwt: string): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const secretKey = encoder.encode(JWT_SECRET);
|
||||
try {
|
||||
await jose.jwtVerify(jwt, secretKey);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
const encoder = new TextEncoder();
|
||||
const secretKey = encoder.encode(JWT_SECRET);
|
||||
try {
|
||||
await jose.jwtVerify(jwt, secretKey);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
|
||||
try {
|
||||
const token = getAuthToken(req);
|
||||
const isValidJWT = await verifyJWT(token);
|
||||
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
|
||||
try {
|
||||
const token = getAuthToken(req);
|
||||
const isValidJWT = await verifyJWT(token);
|
||||
|
||||
if (!isValidJWT) {
|
||||
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return new Response(JSON.stringify({ msg: e.toString() }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!isValidJWT) {
|
||||
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return new Response(JSON.stringify({ msg: e.toString() }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const { pathname } = url;
|
||||
const path_parts = pathname.split('/');
|
||||
const service_name = path_parts[1];
|
||||
const url = new URL(req.url);
|
||||
const { pathname } = url;
|
||||
const path_parts = pathname.split('/');
|
||||
const service_name = path_parts[1];
|
||||
|
||||
if (!service_name || service_name === '') {
|
||||
const error = { msg: 'missing function name in request' };
|
||||
return new Response(JSON.stringify(error), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!service_name || service_name === '') {
|
||||
const error = { msg: 'missing function name in request' };
|
||||
return new Response(JSON.stringify(error), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const servicePath = `/home/deno/functions/${service_name}`;
|
||||
console.error(`serving the request with ${servicePath}`);
|
||||
const servicePath = `/home/deno/functions/${service_name}`;
|
||||
console.error(`serving the request with ${servicePath}`);
|
||||
|
||||
const memoryLimitMb = 150;
|
||||
const workerTimeoutMs = 1 * 60 * 1000;
|
||||
const noModuleCache = false;
|
||||
const importMapPath = null;
|
||||
const envVarsObj = Deno.env.toObject();
|
||||
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]);
|
||||
const memoryLimitMb = 150;
|
||||
const workerTimeoutMs = 1 * 60 * 1000;
|
||||
const noModuleCache = false;
|
||||
const importMapPath = null;
|
||||
const envVarsObj = Deno.env.toObject();
|
||||
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]);
|
||||
|
||||
try {
|
||||
const worker = await EdgeRuntime.userWorkers.create({
|
||||
servicePath,
|
||||
memoryLimitMb,
|
||||
workerTimeoutMs,
|
||||
noModuleCache,
|
||||
importMapPath,
|
||||
envVars,
|
||||
});
|
||||
return await worker.fetch(req);
|
||||
} catch (e) {
|
||||
const error = { msg: e.toString() };
|
||||
return new Response(JSON.stringify(error), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const worker = await EdgeRuntime.userWorkers.create({
|
||||
servicePath,
|
||||
memoryLimitMb,
|
||||
workerTimeoutMs,
|
||||
noModuleCache,
|
||||
importMapPath,
|
||||
envVars,
|
||||
});
|
||||
return await worker.fetch(req);
|
||||
} catch (e) {
|
||||
const error = { msg: e.toString() };
|
||||
return new Response(JSON.stringify(error), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -4,7 +4,7 @@ import { createBrowserClient } from '@supabase/ssr';
|
||||
import type { Database } from '@/utils/supabase/types';
|
||||
|
||||
export const createClient = () =>
|
||||
createBrowserClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
);
|
||||
createBrowserClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
);
|
||||
|
@ -3,54 +3,54 @@ import { type NextRequest, NextResponse } from 'next/server';
|
||||
import type { Database } from '@/utils/supabase/types';
|
||||
|
||||
export const updateSession = async (
|
||||
request: NextRequest,
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse> => {
|
||||
try {
|
||||
// Create an unmodified response
|
||||
let response = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Create an unmodified response
|
||||
let response = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const supabase = createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value),
|
||||
);
|
||||
response = NextResponse.next({
|
||||
request,
|
||||
});
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
response.cookies.set(name, value, options),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const supabase = createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value),
|
||||
);
|
||||
response = NextResponse.next({
|
||||
request,
|
||||
});
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
response.cookies.set(name, value, options),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// This will refresh session if expired - required for Server Components
|
||||
// https://supabase.com/docs/guides/auth/server-side/nextjs
|
||||
const user = await supabase.auth.getUser();
|
||||
// This will refresh session if expired - required for Server Components
|
||||
// https://supabase.com/docs/guides/auth/server-side/nextjs
|
||||
const user = await supabase.auth.getUser();
|
||||
|
||||
// protected routes
|
||||
if (request.nextUrl.pathname.startsWith('/reset-password') && user.error) {
|
||||
return NextResponse.redirect(new URL('/sign-in', request.url));
|
||||
}
|
||||
// protected routes
|
||||
if (request.nextUrl.pathname.startsWith('/reset-password') && user.error) {
|
||||
return NextResponse.redirect(new URL('/sign-in', request.url));
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -6,28 +6,28 @@ import type { Database } from '@/utils/supabase/types';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const createServerClient = async () => {
|
||||
const cookieStore = await cookies();
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return CreateServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
cookieStore.set(name, value, options);
|
||||
});
|
||||
} catch (error) {
|
||||
// The `set` method was called from a Server Component.
|
||||
// This can be ignored if you have middleware refreshing
|
||||
// user sessions.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
return CreateServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
cookieStore.set(name, value, options);
|
||||
});
|
||||
} catch (error) {
|
||||
// The `set` method was called from a Server Component.
|
||||
// This can be ignored if you have middleware refreshing
|
||||
// user sessions.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
@ -1,188 +1,188 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[];
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[];
|
||||
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_url: string | null;
|
||||
email: string | null;
|
||||
full_name: string | null;
|
||||
id: string;
|
||||
provider: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
Insert: {
|
||||
avatar_url?: string | null;
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
id: string;
|
||||
provider?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
Update: {
|
||||
avatar_url?: string | null;
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
id?: string;
|
||||
provider?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
statuses: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
id: string;
|
||||
status: string;
|
||||
updated_by_id: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
status: string;
|
||||
updated_by_id?: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
status?: string;
|
||||
updated_by_id?: string | null;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
};
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Enums: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
public: {
|
||||
Tables: {
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_url: string | null;
|
||||
email: string | null;
|
||||
full_name: string | null;
|
||||
id: string;
|
||||
provider: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
Insert: {
|
||||
avatar_url?: string | null;
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
id: string;
|
||||
provider?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
Update: {
|
||||
avatar_url?: string | null;
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
id?: string;
|
||||
provider?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
statuses: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
id: string;
|
||||
status: string;
|
||||
updated_by_id: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
status: string;
|
||||
updated_by_id?: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
status?: string;
|
||||
updated_by_id?: string | null;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
};
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Enums: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type DefaultSchema = Database[Extract<keyof Database, 'public'>];
|
||||
|
||||
export type Tables<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
||||
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])
|
||||
: never = never,
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
||||
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
||||
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
|
||||
Row: infer R;
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] &
|
||||
DefaultSchema['Views'])
|
||||
? (DefaultSchema['Tables'] &
|
||||
DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends {
|
||||
Row: infer R;
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: never;
|
||||
? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
||||
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
|
||||
Row: infer R;
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] &
|
||||
DefaultSchema['Views'])
|
||||
? (DefaultSchema['Tables'] &
|
||||
DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends {
|
||||
Row: infer R;
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type TablesInsert<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema['Tables']
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
||||
: never = never,
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema['Tables']
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||
Insert: infer I;
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
||||
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
||||
Insert: infer I;
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: never;
|
||||
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||
Insert: infer I;
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
||||
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
||||
Insert: infer I;
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type TablesUpdate<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema['Tables']
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
||||
: never = never,
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema['Tables']
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||
Update: infer U;
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
||||
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
||||
Update: infer U;
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: never;
|
||||
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||
Update: infer U;
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
||||
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
||||
Update: infer U;
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type Enums<
|
||||
DefaultSchemaEnumNameOrOptions extends
|
||||
| keyof DefaultSchema['Enums']
|
||||
| { schema: keyof Database },
|
||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
|
||||
: never = never,
|
||||
DefaultSchemaEnumNameOrOptions extends
|
||||
| keyof DefaultSchema['Enums']
|
||||
| { schema: keyof Database },
|
||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
|
||||
: never = never,
|
||||
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
|
||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums']
|
||||
? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions]
|
||||
: never;
|
||||
? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
|
||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums']
|
||||
? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions]
|
||||
: never;
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof DefaultSchema['CompositeTypes']
|
||||
| { schema: keyof Database },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
|
||||
: never = never,
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof DefaultSchema['CompositeTypes']
|
||||
| { schema: keyof Database },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes']
|
||||
? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
|
||||
: never;
|
||||
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes']
|
||||
? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
|
||||
: never;
|
||||
|
||||
export const Constants = {
|
||||
public: {
|
||||
Enums: {},
|
||||
},
|
||||
public: {
|
||||
Enums: {},
|
||||
},
|
||||
} as const;
|
||||
|
@ -15,12 +15,12 @@ export type StatusUpdate = Database['public']['Tables']['statuses']['Update'];
|
||||
|
||||
// Generic helper to get any table's row type
|
||||
export type TableRow<T extends keyof Database['public']['Tables']> =
|
||||
Database['public']['Tables'][T]['Row'];
|
||||
Database['public']['Tables'][T]['Row'];
|
||||
|
||||
// Generic helper to get any table's insert type
|
||||
export type TableInsert<T extends keyof Database['public']['Tables']> =
|
||||
Database['public']['Tables'][T]['Insert'];
|
||||
Database['public']['Tables'][T]['Insert'];
|
||||
|
||||
// Generic helper to get any table's update type
|
||||
export type TableUpdate<T extends keyof Database['public']['Tables']> =
|
||||
Database['public']['Tables'][T]['Update'];
|
||||
Database['public']['Tables'][T]['Update'];
|
||||
|
Reference in New Issue
Block a user