Switching to tabs over spaces!
This commit is contained in:
@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"jsxSingleQuote": true,
|
"jsxSingleQuote": true,
|
||||||
"trailingComma": "all"
|
"trailingComma": "all",
|
||||||
|
"useTabs": true,
|
||||||
|
"tabWidth": 2
|
||||||
}
|
}
|
||||||
|
@ -3,51 +3,51 @@ import tseslint from 'typescript-eslint';
|
|||||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
const compat = new FlatCompat({
|
||||||
baseDirectory: import.meta.dirname,
|
baseDirectory: import.meta.dirname,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['.next'],
|
ignores: ['.next'],
|
||||||
},
|
},
|
||||||
...compat.extends('next/core-web-vitals'),
|
...compat.extends('next/core-web-vitals'),
|
||||||
{
|
{
|
||||||
files: ['**/*.ts', '**/*.tsx'],
|
files: ['**/*.ts', '**/*.tsx'],
|
||||||
extends: [
|
extends: [
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
eslintPluginPrettierRecommended,
|
eslintPluginPrettierRecommended,
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/array-type': 'off',
|
'@typescript-eslint/array-type': 'off',
|
||||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||||
'@typescript-eslint/consistent-type-imports': [
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
'warn',
|
'warn',
|
||||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn',
|
'warn',
|
||||||
{ argsIgnorePattern: '^_' },
|
{ argsIgnorePattern: '^_' },
|
||||||
],
|
],
|
||||||
'@typescript-eslint/require-await': 'off',
|
'@typescript-eslint/require-await': 'off',
|
||||||
'@typescript-eslint/no-misused-promises': [
|
'@typescript-eslint/no-misused-promises': [
|
||||||
'error',
|
'error',
|
||||||
{ checksVoidReturn: { attributes: false } },
|
{ checksVoidReturn: { attributes: false } },
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
linterOptions: {
|
linterOptions: {
|
||||||
reportUnusedDisableDirectives: true,
|
reportUnusedDisableDirectives: true,
|
||||||
},
|
},
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
projectService: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
106
next.config.js
106
next.config.js
@ -6,62 +6,62 @@ import { withSentryConfig } from '@sentry/nextjs';
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: '*.gbrown.org',
|
hostname: '*.gbrown.org',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
serverExternalPackages: ['require-in-the-middle'],
|
serverExternalPackages: ['require-in-the-middle'],
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10mb',
|
bodySizeLimit: '10mb',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
turbopack: {
|
turbopack: {
|
||||||
rules: {
|
rules: {
|
||||||
'*.svg': {
|
'*.svg': {
|
||||||
loaders: [
|
loaders: [
|
||||||
{
|
{
|
||||||
loader: '@svgr/webpack',
|
loader: '@svgr/webpack',
|
||||||
options: {
|
options: {
|
||||||
icon: true,
|
icon: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
as: '*.js',
|
as: '*.js',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const sentryConfig = {
|
const sentryConfig = {
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
org: 'gib',
|
org: 'gib',
|
||||||
project: 't3-supabase-template',
|
project: 't3-supabase-template',
|
||||||
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
// Only print logs for uploading source maps in CI
|
// Only print logs for uploading source maps in CI
|
||||||
silent: !process.env.CI,
|
silent: !process.env.CI,
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
widenClientFileUpload: true,
|
widenClientFileUpload: true,
|
||||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
// 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.
|
// 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-
|
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||||
// side errors will fail.
|
// side errors will fail.
|
||||||
tunnelRoute: '/monitoring',
|
tunnelRoute: '/monitoring',
|
||||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||||
disableLogger: true,
|
disableLogger: true,
|
||||||
// Capture React Component Names
|
// Capture React Component Names
|
||||||
reactComponentAnnotation: {
|
reactComponentAnnotation: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withSentryConfig(config, sentryConfig);
|
export default withSentryConfig(config, sentryConfig);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
'@tailwindcss/postcss': {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||||
export default {
|
export default {
|
||||||
plugins: ['prettier-plugin-tailwindcss'],
|
plugins: ['prettier-plugin-tailwindcss'],
|
||||||
};
|
};
|
||||||
|
@ -7,61 +7,61 @@ import { withSentryConfig } from '@sentry/nextjs';
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: '*.gbrown.org',
|
hostname: '*.gbrown.org',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
serverExternalPackages: ['require-in-the-middle'],
|
serverExternalPackages: ['require-in-the-middle'],
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10mb',
|
bodySizeLimit: '10mb',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
//turbopack: {
|
//turbopack: {
|
||||||
//rules: {
|
//rules: {
|
||||||
//'*.svg': {
|
//'*.svg': {
|
||||||
//loaders: ['@svgr/webpack'],
|
//loaders: ['@svgr/webpack'],
|
||||||
//as: '*.js',
|
//as: '*.js',
|
||||||
//},
|
//},
|
||||||
//},
|
//},
|
||||||
//},
|
//},
|
||||||
};
|
};
|
||||||
|
|
||||||
const sentryConfig = {
|
const sentryConfig = {
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
org: 'gib',
|
org: 'gib',
|
||||||
project: 't3-supabase-template',
|
project: 't3-supabase-template',
|
||||||
sentryUrl: process.env.SENTRY_URL,
|
sentryUrl: process.env.SENTRY_URL,
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
// Only print logs for uploading source maps in CI
|
// Only print logs for uploading source maps in CI
|
||||||
silent: !process.env.CI,
|
silent: !process.env.CI,
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
widenClientFileUpload: true,
|
widenClientFileUpload: true,
|
||||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
// 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.
|
// 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-
|
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||||
// side errors will fail.
|
// side errors will fail.
|
||||||
tunnelRoute: '/monitoring',
|
tunnelRoute: '/monitoring',
|
||||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||||
disableLogger: true,
|
disableLogger: true,
|
||||||
// Capture React Component Names
|
// Capture React Component Names
|
||||||
reactComponentAnnotation: {
|
reactComponentAnnotation: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withSentryConfig(config, sentryConfig);
|
export default withSentryConfig(config, sentryConfig);
|
||||||
|
@ -7,55 +7,55 @@ import { withSentryConfig } from '@sentry/nextjs';
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: '*.gbrown.org',
|
hostname: '*.gbrown.org',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
serverExternalPackages: ['require-in-the-middle'],
|
serverExternalPackages: ['require-in-the-middle'],
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10mb',
|
bodySizeLimit: '10mb',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
//turbopack: {
|
//turbopack: {
|
||||||
//rules: {
|
//rules: {
|
||||||
//'*.svg': {
|
//'*.svg': {
|
||||||
//loaders: ['@svgr/webpack'],
|
//loaders: ['@svgr/webpack'],
|
||||||
//as: '*.js',
|
//as: '*.js',
|
||||||
//},
|
//},
|
||||||
//},
|
//},
|
||||||
//},
|
//},
|
||||||
};
|
};
|
||||||
|
|
||||||
const sentryConfig = {
|
const sentryConfig = {
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
org: 'gib',
|
org: 'gib',
|
||||||
project: 't3-supabase-template',
|
project: 't3-supabase-template',
|
||||||
sentryUrl: process.env.SENTRY_URL,
|
sentryUrl: process.env.SENTRY_URL,
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
// Only print logs for uploading source maps in CI
|
// Only print logs for uploading source maps in CI
|
||||||
silent: !process.env.CI,
|
silent: !process.env.CI,
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
widenClientFileUpload: true,
|
widenClientFileUpload: true,
|
||||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
// 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.
|
// 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-
|
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||||
// side errors will fail.
|
// side errors will fail.
|
||||||
tunnelRoute: '/monitoring',
|
tunnelRoute: '/monitoring',
|
||||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||||
disableLogger: true,
|
disableLogger: true,
|
||||||
// Capture React Component Names
|
// Capture React Component Names
|
||||||
reactComponentAnnotation: {
|
reactComponentAnnotation: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withSentryConfig(config, sentryConfig);
|
export default withSentryConfig(config, sentryConfig);
|
||||||
|
@ -5,11 +5,11 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
Sentry.init({
|
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.
|
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||||
tracesSampleRate: 1,
|
tracesSampleRate: 1,
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
debug: false,
|
debug: false,
|
||||||
});
|
});
|
||||||
|
@ -6,38 +6,40 @@ import { type NextRequest } from 'next/server';
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export const GET = async (request: NextRequest) => {
|
export const GET = async (request: NextRequest) => {
|
||||||
const { searchParams, origin } = new URL(request.url);
|
const { searchParams, origin } = new URL(request.url);
|
||||||
const code = searchParams.get('code');
|
const code = searchParams.get('code');
|
||||||
const token_hash = searchParams.get('token');
|
const token_hash = searchParams.get('token');
|
||||||
const type = searchParams.get('type') as EmailOtpType | null;
|
const type = searchParams.get('type') as EmailOtpType | null;
|
||||||
const redirectTo = searchParams.get('redirect_to') ?? '/';
|
const redirectTo = searchParams.get('redirect_to') ?? '/';
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('OAuth error:', error);
|
console.error('OAuth error:', error);
|
||||||
return redirect(`/sign-in?error=${encodeURIComponent(error.message)}`);
|
return redirect(
|
||||||
}
|
`/sign-in?error=${encodeURIComponent(error.message)}`,
|
||||||
return redirect(redirectTo);
|
);
|
||||||
}
|
}
|
||||||
|
return redirect(redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
if (token_hash && type) {
|
if (token_hash && type) {
|
||||||
const { error } = await supabase.auth.verifyOtp({
|
const { error } = await supabase.auth.verifyOtp({
|
||||||
type,
|
type,
|
||||||
token_hash,
|
token_hash,
|
||||||
});
|
});
|
||||||
if (!error) {
|
if (!error) {
|
||||||
if (type === 'signup' || type === 'magiclink' || type === 'email')
|
if (type === 'signup' || type === 'magiclink' || type === 'email')
|
||||||
return redirect('/');
|
return redirect('/');
|
||||||
if (type === 'recovery' || type === 'email_change')
|
if (type === 'recovery' || type === 'email_change')
|
||||||
return redirect('/profile');
|
return redirect('/profile');
|
||||||
if (type === 'invite') return redirect('/sign-up');
|
if (type === 'invite') return redirect('/sign-up');
|
||||||
}
|
}
|
||||||
return redirect(
|
return redirect(
|
||||||
`/?error=${encodeURIComponent(error?.message ?? 'Unknown error')}`,
|
`/?error=${encodeURIComponent(error?.message ?? 'Unknown error')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect('/');
|
return redirect('/');
|
||||||
};
|
};
|
||||||
|
@ -6,34 +6,36 @@ import { useEffect } from 'react';
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const AuthSuccessPage = () => {
|
const AuthSuccessPage = () => {
|
||||||
const { refreshUserData } = useAuth();
|
const { refreshUserData } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleAuthSuccess = async () => {
|
const handleAuthSuccess = async () => {
|
||||||
// Refresh the auth context to pick up the new session
|
// Refresh the auth context to pick up the new session
|
||||||
await refreshUserData();
|
await refreshUserData();
|
||||||
|
|
||||||
// Small delay to ensure state is updated
|
// Small delay to ensure state is updated
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleAuthSuccess().catch((error) => {
|
handleAuthSuccess().catch((error) => {
|
||||||
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
console.error(
|
||||||
});
|
`Error: ${error instanceof Error ? error.message : error}`,
|
||||||
}, [refreshUserData, router]);
|
);
|
||||||
|
});
|
||||||
|
}, [refreshUserData, router]);
|
||||||
|
|
||||||
// Show loading while processing
|
// Show loading while processing
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center justify-center min-h-screen'>
|
<div className='flex items-center justify-center min-h-screen'>
|
||||||
<div className='flex flex-col items-center space-y-4'>
|
<div className='flex flex-col items-center space-y-4'>
|
||||||
<Loader2 className='h-8 w-8 animate-spin' />
|
<Loader2 className='h-8 w-8 animate-spin' />
|
||||||
<p>Completing sign in...</p>
|
<p>Completing sign in...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AuthSuccessPage;
|
export default AuthSuccessPage;
|
||||||
|
@ -3,18 +3,18 @@ import { z } from 'zod';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { forgotPassword } from '@/lib/actions';
|
import { forgotPassword } from '@/lib/actions';
|
||||||
@ -24,106 +24,113 @@ import { useEffect, useState } from 'react';
|
|||||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().email({
|
email: z.string().email({
|
||||||
message: 'Please enter a valid email address.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ForgotPassword = () => {
|
const ForgotPassword = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
|
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
const handleForgotPassword = async (values: z.infer<typeof formSchema>) => {
|
const handleForgotPassword = async (values: z.infer<typeof formSchema>) => {
|
||||||
try {
|
try {
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('email', values.email);
|
formData.append('email', values.email);
|
||||||
const result = await forgotPassword(formData);
|
const result = await forgotPassword(formData);
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
await refreshUserData();
|
await refreshUserData();
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
result?.data ?? 'Check your email for a link to reset your password.',
|
result?.data ??
|
||||||
);
|
'Check your email for a link to reset your password.',
|
||||||
form.reset();
|
);
|
||||||
router.push('');
|
form.reset();
|
||||||
} else {
|
router.push('');
|
||||||
setStatusMessage(`Error: ${result.error}`);
|
} else {
|
||||||
}
|
setStatusMessage(`Error: ${result.error}`);
|
||||||
} catch (error) {
|
}
|
||||||
setStatusMessage(
|
} catch (error) {
|
||||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
setStatusMessage(
|
||||||
);
|
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||||
}
|
);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='min-w-xs md:min-w-sm'>
|
<Card className='min-w-xs md:min-w-sm'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
|
<CardTitle className='text-2xl font-medium'>
|
||||||
<CardDescription className='text-sm text-foreground'>
|
Reset Password
|
||||||
Don't have an account?{' '}
|
</CardTitle>
|
||||||
<Link className='font-medium underline' href='/sign-up'>
|
<CardDescription className='text-sm text-foreground'>
|
||||||
Sign up
|
Don't have an account?{' '}
|
||||||
</Link>
|
<Link className='font-medium underline' href='/sign-up'>
|
||||||
</CardDescription>
|
Sign up
|
||||||
</CardHeader>
|
</Link>
|
||||||
<CardContent>
|
</CardDescription>
|
||||||
<Form {...form}>
|
</CardHeader>
|
||||||
<form
|
<CardContent>
|
||||||
onSubmit={form.handleSubmit(handleForgotPassword)}
|
<Form {...form}>
|
||||||
className='flex flex-col min-w-64 space-y-6'
|
<form
|
||||||
>
|
onSubmit={form.handleSubmit(handleForgotPassword)}
|
||||||
<FormField
|
className='flex flex-col min-w-64 space-y-6'
|
||||||
control={form.control}
|
>
|
||||||
name='email'
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name='email'
|
||||||
<FormLabel>Email</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input
|
<FormLabel>Email</FormLabel>
|
||||||
type='email'
|
<FormControl>
|
||||||
placeholder='you@example.com'
|
<Input
|
||||||
{...field}
|
type='email'
|
||||||
/>
|
placeholder='you@example.com'
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
<SubmitButton
|
)}
|
||||||
disabled={isLoading}
|
/>
|
||||||
pendingText='Resetting Password...'
|
<SubmitButton
|
||||||
>
|
disabled={isLoading}
|
||||||
Reset Password
|
pendingText='Resetting Password...'
|
||||||
</SubmitButton>
|
>
|
||||||
{statusMessage &&
|
Reset Password
|
||||||
(statusMessage.includes('Error') ||
|
</SubmitButton>
|
||||||
statusMessage.includes('error') ||
|
{statusMessage &&
|
||||||
statusMessage.includes('failed') ||
|
(statusMessage.includes('Error') ||
|
||||||
statusMessage.includes('invalid') ? (
|
statusMessage.includes('error') ||
|
||||||
<StatusMessage message={{ error: statusMessage }} />
|
statusMessage.includes('failed') ||
|
||||||
) : (
|
statusMessage.includes('invalid') ? (
|
||||||
<StatusMessage message={{ success: statusMessage }} />
|
<StatusMessage
|
||||||
))}
|
message={{ error: statusMessage }}
|
||||||
</form>
|
/>
|
||||||
</Form>
|
) : (
|
||||||
</CardContent>
|
<StatusMessage
|
||||||
</Card>
|
message={{ success: statusMessage }}
|
||||||
);
|
/>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
export default ForgotPassword;
|
export default ForgotPassword;
|
||||||
|
@ -3,17 +3,17 @@ import { useAuth } from '@/components/context';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
AvatarUpload,
|
AvatarUpload,
|
||||||
ProfileForm,
|
ProfileForm,
|
||||||
ResetPasswordForm,
|
ResetPasswordForm,
|
||||||
SignOut,
|
SignOut,
|
||||||
} from '@/components/default/profile';
|
} from '@/components/default/profile';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
Separator,
|
Separator,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { resetPassword } from '@/lib/actions';
|
import { resetPassword } from '@/lib/actions';
|
||||||
@ -21,103 +21,106 @@ import { toast } from 'sonner';
|
|||||||
import { type Result } from '@/lib/actions';
|
import { type Result } from '@/lib/actions';
|
||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const {
|
const {
|
||||||
profile,
|
profile,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
refreshUserData,
|
refreshUserData,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated) {
|
if (!isLoading && !isAuthenticated) {
|
||||||
router.push('/sign-in');
|
router.push('/sign-in');
|
||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, router]);
|
}, [isLoading, isAuthenticated, router]);
|
||||||
|
|
||||||
const handleAvatarUploaded = async (path: string) => {
|
const handleAvatarUploaded = async (path: string) => {
|
||||||
await updateProfile({ avatar_url: path });
|
await updateProfile({ avatar_url: path });
|
||||||
await refreshUserData();
|
await refreshUserData();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProfileSubmit = async (values: {
|
const handleProfileSubmit = async (values: {
|
||||||
full_name: string;
|
full_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
await updateProfile({
|
await updateProfile({
|
||||||
full_name: values.full_name,
|
full_name: values.full_name,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Error updating profile!: ');
|
toast.error('Error updating profile!: ');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetPasswordSubmit = async (
|
const handleResetPasswordSubmit = async (
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<Result<null>> => {
|
): Promise<Result<null>> => {
|
||||||
try {
|
try {
|
||||||
const result = await resetPassword(formData);
|
const result = await resetPassword(formData);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(`Error resetting password: ${result.error}`);
|
toast.error(`Error resetting password: ${result.error}`);
|
||||||
return { success: false, error: result.error };
|
return { success: false, error: result.error };
|
||||||
}
|
}
|
||||||
return { success: true, data: null };
|
return { success: true, data: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Error resetting password!: ${(error as string) ?? 'Unknown error'}`,
|
`Error resetting password!: ${(error as string) ?? 'Unknown error'}`,
|
||||||
);
|
);
|
||||||
return { success: false, error: 'Unknown error' };
|
return { success: false, error: 'Unknown error' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading state while checking authentication
|
// Show loading state while checking authentication
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className='flex justify-center items-center min-h-[50vh]'>
|
<div className='flex justify-center items-center min-h-[50vh]'>
|
||||||
<Loader2 className='h-8 w-8 animate-spin text-gray-500' />
|
<Loader2 className='h-8 w-8 animate-spin text-gray-500' />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not authenticated and not loading, this will show briefly before redirect
|
// If not authenticated and not loading, this will show briefly before redirect
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className='flex p-5 items-center justify-center'>
|
<div className='flex p-5 items-center justify-center'>
|
||||||
<h1>Unauthorized - Redirecting...</h1>
|
<h1>Unauthorized - Redirecting...</h1>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='max-w-2xl min-w-sm mx-auto p-4'>
|
<div className='max-w-2xl min-w-sm mx-auto p-4'>
|
||||||
<Card className='mb-8'>
|
<Card className='mb-8'>
|
||||||
<CardHeader className='pb-2'>
|
<CardHeader className='pb-2'>
|
||||||
<CardTitle className='text-2xl'>Your Profile</CardTitle>
|
<CardTitle className='text-2xl'>Your Profile</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage your personal information and how it appears to others
|
Manage your personal information and how it appears to
|
||||||
</CardDescription>
|
others
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
{isLoading && !profile ? (
|
</CardHeader>
|
||||||
<div className='flex justify-center py-8'>
|
{isLoading && !profile ? (
|
||||||
<Loader2 className='h-8 w-8 animate-spin text-gray-500' />
|
<div className='flex justify-center py-8'>
|
||||||
</div>
|
<Loader2 className='h-8 w-8 animate-spin text-gray-500' />
|
||||||
) : (
|
</div>
|
||||||
<div className='space-y-8'>
|
) : (
|
||||||
<AvatarUpload onAvatarUploaded={handleAvatarUploaded} />
|
<div className='space-y-8'>
|
||||||
<Separator />
|
<AvatarUpload onAvatarUploaded={handleAvatarUploaded} />
|
||||||
<ProfileForm onSubmit={handleProfileSubmit} />
|
<Separator />
|
||||||
<Separator />
|
<ProfileForm onSubmit={handleProfileSubmit} />
|
||||||
<ResetPasswordForm onSubmit={handleResetPasswordSubmit} />
|
<Separator />
|
||||||
<Separator />
|
<ResetPasswordForm
|
||||||
<SignOut />
|
onSubmit={handleResetPasswordSubmit}
|
||||||
</div>
|
/>
|
||||||
)}
|
<Separator />
|
||||||
</Card>
|
<SignOut />
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfilePage;
|
export default ProfilePage;
|
||||||
|
@ -4,18 +4,18 @@ import { z } from 'zod';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { signIn } from '@/lib/actions';
|
import { signIn } from '@/lib/actions';
|
||||||
@ -28,144 +28,152 @@ import { SignInWithMicrosoft } from '@/components/default/auth/SignInWithMicroso
|
|||||||
import { SignInWithApple } from '@/components/default/auth/SignInWithApple';
|
import { SignInWithApple } from '@/components/default/auth/SignInWithApple';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().email({
|
email: z.string().email({
|
||||||
message: 'Please enter a valid email address.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
password: z.string().min(8, {
|
password: z.string().min(8, {
|
||||||
message: 'Password must be at least 8 characters.',
|
message: 'Password must be at least 8 characters.',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
|
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
const handleSignIn = async (values: z.infer<typeof formSchema>) => {
|
const handleSignIn = async (values: z.infer<typeof formSchema>) => {
|
||||||
try {
|
try {
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('email', values.email);
|
formData.append('email', values.email);
|
||||||
formData.append('password', values.password);
|
formData.append('password', values.password);
|
||||||
const result = await signIn(formData);
|
const result = await signIn(formData);
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
await refreshUserData();
|
await refreshUserData();
|
||||||
form.reset();
|
form.reset();
|
||||||
router.push('');
|
router.push('');
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage(`Error: ${result.error}`);
|
setStatusMessage(`Error: ${result.error}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='min-w-xs md:min-w-sm'>
|
<Card className='min-w-xs md:min-w-sm'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
|
<CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
|
||||||
<CardDescription className='text-foreground'>
|
<CardDescription className='text-foreground'>
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link className='font-medium underline' href='/sign-up'>
|
<Link className='font-medium underline' href='/sign-up'>
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(handleSignIn)}
|
onSubmit={form.handleSubmit(handleSignIn)}
|
||||||
className='flex flex-col min-w-64 space-y-6 pb-4'
|
className='flex flex-col min-w-64 space-y-6 pb-4'
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='email'
|
name='email'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className='text-lg'>Email</FormLabel>
|
<FormLabel className='text-lg'>
|
||||||
<FormControl>
|
Email
|
||||||
<Input
|
</FormLabel>
|
||||||
type='email'
|
<FormControl>
|
||||||
placeholder='you@example.com'
|
<Input
|
||||||
{...field}
|
type='email'
|
||||||
/>
|
placeholder='you@example.com'
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='password'
|
name='password'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className='flex justify-between'>
|
<div className='flex justify-between'>
|
||||||
<FormLabel className='text-lg'>Password</FormLabel>
|
<FormLabel className='text-lg'>
|
||||||
<Link
|
Password
|
||||||
className='text-xs text-foreground underline text-right'
|
</FormLabel>
|
||||||
href='/forgot-password'
|
<Link
|
||||||
>
|
className='text-xs text-foreground underline text-right'
|
||||||
Forgot Password?
|
href='/forgot-password'
|
||||||
</Link>
|
>
|
||||||
</div>
|
Forgot Password?
|
||||||
<FormControl>
|
</Link>
|
||||||
<Input
|
</div>
|
||||||
type='password'
|
<FormControl>
|
||||||
placeholder='Your password'
|
<Input
|
||||||
{...field}
|
type='password'
|
||||||
/>
|
placeholder='Your password'
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
{statusMessage &&
|
)}
|
||||||
(statusMessage.includes('Error') ||
|
/>
|
||||||
statusMessage.includes('error') ||
|
{statusMessage &&
|
||||||
statusMessage.includes('failed') ||
|
(statusMessage.includes('Error') ||
|
||||||
statusMessage.includes('invalid') ? (
|
statusMessage.includes('error') ||
|
||||||
<StatusMessage message={{ error: statusMessage }} />
|
statusMessage.includes('failed') ||
|
||||||
) : (
|
statusMessage.includes('invalid') ? (
|
||||||
<StatusMessage message={{ message: statusMessage }} />
|
<StatusMessage
|
||||||
))}
|
message={{ error: statusMessage }}
|
||||||
<SubmitButton
|
/>
|
||||||
disabled={isLoading}
|
) : (
|
||||||
pendingText='Signing In...'
|
<StatusMessage
|
||||||
className='text-[1.0rem] cursor-pointer'
|
message={{ message: statusMessage }}
|
||||||
>
|
/>
|
||||||
Sign in
|
))}
|
||||||
</SubmitButton>
|
<SubmitButton
|
||||||
</form>
|
disabled={isLoading}
|
||||||
</Form>
|
pendingText='Signing In...'
|
||||||
|
className='text-[1.0rem] cursor-pointer'
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
<div className='flex items-center w-full gap-4'>
|
<div className='flex items-center w-full gap-4'>
|
||||||
<Separator className='flex-1 bg-accent py-0.5' />
|
<Separator className='flex-1 bg-accent py-0.5' />
|
||||||
<span className='text-sm text-muted-foreground'>or</span>
|
<span className='text-sm text-muted-foreground'>or</span>
|
||||||
<Separator className='flex-1 bg-accent py-0.5' />
|
<Separator className='flex-1 bg-accent py-0.5' />
|
||||||
</div>
|
</div>
|
||||||
<SignInWithMicrosoft />
|
<SignInWithMicrosoft />
|
||||||
<SignInWithApple />
|
<SignInWithApple />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Login;
|
export default Login;
|
||||||
|
@ -9,202 +9,221 @@ import { StatusMessage, SubmitButton } from '@/components/default';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/components/context';
|
import { useAuth } from '@/components/context';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
Separator,
|
Separator,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
SignInWithApple,
|
SignInWithApple,
|
||||||
SignInWithMicrosoft,
|
SignInWithMicrosoft,
|
||||||
} from '@/components/default/auth';
|
} from '@/components/default/auth';
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(2, {
|
name: z.string().min(2, {
|
||||||
message: 'Name must be at least 2 characters.',
|
message: 'Name must be at least 2 characters.',
|
||||||
}),
|
}),
|
||||||
email: z.string().email({
|
email: z.string().email({
|
||||||
message: 'Please enter a valid email address.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
password: z.string().min(8, {
|
password: z.string().min(8, {
|
||||||
message: 'Password must be at least 8 characters.',
|
message: 'Password must be at least 8 characters.',
|
||||||
}),
|
}),
|
||||||
confirmPassword: z.string().min(8, {
|
confirmPassword: z.string().min(8, {
|
||||||
message: 'Password must be at least 8 characters.',
|
message: 'Password must be at least 8 characters.',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: 'Passwords do not match!',
|
message: 'Passwords do not match!',
|
||||||
path: ['confirmPassword'],
|
path: ['confirmPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const SignUp = () => {
|
const SignUp = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
|
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
},
|
},
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
const handleSignUp = async (values: z.infer<typeof formSchema>) => {
|
const handleSignUp = async (values: z.infer<typeof formSchema>) => {
|
||||||
try {
|
try {
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('name', values.name);
|
formData.append('name', values.name);
|
||||||
formData.append('email', values.email);
|
formData.append('email', values.email);
|
||||||
formData.append('password', values.password);
|
formData.append('password', values.password);
|
||||||
const result = await signUp(formData);
|
const result = await signUp(formData);
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
await refreshUserData();
|
await refreshUserData();
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
result.data ??
|
result.data ??
|
||||||
'Thanks for signing up! Please check your email for a verification link.',
|
'Thanks for signing up! Please check your email for a verification link.',
|
||||||
);
|
);
|
||||||
form.reset();
|
form.reset();
|
||||||
router.push('');
|
router.push('');
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage(`Error: ${result.error}`);
|
setStatusMessage(`Error: ${result.error}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='min-w-xs md:min-w-sm'>
|
<Card className='min-w-xs md:min-w-sm'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
|
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
|
||||||
<CardDescription className='text-foreground'>
|
<CardDescription className='text-foreground'>
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link className='text-primary font-medium underline' href='/sign-in'>
|
<Link
|
||||||
Sign in
|
className='text-primary font-medium underline'
|
||||||
</Link>
|
href='/sign-in'
|
||||||
</CardDescription>
|
>
|
||||||
</CardHeader>
|
Sign in
|
||||||
<CardContent>
|
</Link>
|
||||||
<Form {...form}>
|
</CardDescription>
|
||||||
<form
|
</CardHeader>
|
||||||
onSubmit={form.handleSubmit(handleSignUp)}
|
<CardContent>
|
||||||
className='flex flex-col mx-auto space-y-4 mb-4'
|
<Form {...form}>
|
||||||
>
|
<form
|
||||||
<FormField
|
onSubmit={form.handleSubmit(handleSignUp)}
|
||||||
control={form.control}
|
className='flex flex-col mx-auto space-y-4 mb-4'
|
||||||
name='name'
|
>
|
||||||
render={({ field }) => (
|
<FormField
|
||||||
<FormItem>
|
control={form.control}
|
||||||
<FormLabel className='text-lg'>Name</FormLabel>
|
name='name'
|
||||||
<FormControl>
|
render={({ field }) => (
|
||||||
<Input type='text' placeholder='Full Name' {...field} />
|
<FormItem>
|
||||||
</FormControl>
|
<FormLabel className='text-lg'>
|
||||||
</FormItem>
|
Name
|
||||||
)}
|
</FormLabel>
|
||||||
/>
|
<FormControl>
|
||||||
<FormField
|
<Input
|
||||||
control={form.control}
|
type='text'
|
||||||
name='email'
|
placeholder='Full Name'
|
||||||
render={({ field }) => (
|
{...field}
|
||||||
<FormItem>
|
/>
|
||||||
<FormLabel className='text-lg'>Email</FormLabel>
|
</FormControl>
|
||||||
<FormControl>
|
</FormItem>
|
||||||
<Input
|
)}
|
||||||
type='email'
|
/>
|
||||||
placeholder='you@example.com'
|
<FormField
|
||||||
{...field}
|
control={form.control}
|
||||||
/>
|
name='email'
|
||||||
</FormControl>
|
render={({ field }) => (
|
||||||
<FormMessage />
|
<FormItem>
|
||||||
</FormItem>
|
<FormLabel className='text-lg'>
|
||||||
)}
|
Email
|
||||||
/>
|
</FormLabel>
|
||||||
<FormField
|
<FormControl>
|
||||||
control={form.control}
|
<Input
|
||||||
name='password'
|
type='email'
|
||||||
render={({ field }) => (
|
placeholder='you@example.com'
|
||||||
<FormItem>
|
{...field}
|
||||||
<FormLabel className='text-lg'>Password</FormLabel>
|
/>
|
||||||
<FormControl>
|
</FormControl>
|
||||||
<Input
|
<FormMessage />
|
||||||
type='password'
|
</FormItem>
|
||||||
placeholder='Your password'
|
)}
|
||||||
{...field}
|
/>
|
||||||
/>
|
<FormField
|
||||||
</FormControl>
|
control={form.control}
|
||||||
<FormMessage />
|
name='password'
|
||||||
</FormItem>
|
render={({ field }) => (
|
||||||
)}
|
<FormItem>
|
||||||
/>
|
<FormLabel className='text-lg'>
|
||||||
<FormField
|
Password
|
||||||
control={form.control}
|
</FormLabel>
|
||||||
name='confirmPassword'
|
<FormControl>
|
||||||
render={({ field }) => (
|
<Input
|
||||||
<FormItem>
|
type='password'
|
||||||
<FormLabel className='text-lg'>Confirm Password</FormLabel>
|
placeholder='Your password'
|
||||||
<FormControl>
|
{...field}
|
||||||
<Input
|
/>
|
||||||
type='password'
|
</FormControl>
|
||||||
placeholder='Confirm password'
|
<FormMessage />
|
||||||
{...field}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
</FormControl>
|
/>
|
||||||
<FormMessage />
|
<FormField
|
||||||
</FormItem>
|
control={form.control}
|
||||||
)}
|
name='confirmPassword'
|
||||||
/>
|
render={({ field }) => (
|
||||||
{statusMessage &&
|
<FormItem>
|
||||||
(statusMessage.includes('Error') ||
|
<FormLabel className='text-lg'>
|
||||||
statusMessage.includes('error') ||
|
Confirm Password
|
||||||
statusMessage.includes('failed') ||
|
</FormLabel>
|
||||||
statusMessage.includes('invalid') ? (
|
<FormControl>
|
||||||
<StatusMessage message={{ error: statusMessage }} />
|
<Input
|
||||||
) : (
|
type='password'
|
||||||
<StatusMessage message={{ success: statusMessage }} />
|
placeholder='Confirm password'
|
||||||
))}
|
{...field}
|
||||||
<SubmitButton
|
/>
|
||||||
className='text-[1.0rem] cursor-pointer'
|
</FormControl>
|
||||||
disabled={isLoading}
|
<FormMessage />
|
||||||
pendingText='Signing Up...'
|
</FormItem>
|
||||||
>
|
)}
|
||||||
Sign Up
|
/>
|
||||||
</SubmitButton>
|
{statusMessage &&
|
||||||
</form>
|
(statusMessage.includes('Error') ||
|
||||||
</Form>
|
statusMessage.includes('error') ||
|
||||||
<div className='flex items-center w-full gap-4'>
|
statusMessage.includes('failed') ||
|
||||||
<Separator className='flex-1 bg-accent py-0.5' />
|
statusMessage.includes('invalid') ? (
|
||||||
<span className='text-sm text-muted-foreground'>or</span>
|
<StatusMessage
|
||||||
<Separator className='flex-1 bg-accent py-0.5' />
|
message={{ error: statusMessage }}
|
||||||
</div>
|
/>
|
||||||
<SignInWithMicrosoft />
|
) : (
|
||||||
<SignInWithApple />
|
<StatusMessage
|
||||||
</CardContent>
|
message={{ success: statusMessage }}
|
||||||
</Card>
|
/>
|
||||||
);
|
))}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
export default SignUp;
|
export default SignUp;
|
||||||
|
@ -2,15 +2,15 @@ import { NextResponse } from 'next/server';
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
class SentryExampleAPIError extends Error {
|
class SentryExampleAPIError extends Error {
|
||||||
constructor(message: string | undefined) {
|
constructor(message: string | undefined) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'SentryExampleAPIError';
|
this.name = 'SentryExampleAPIError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A faulty API route to test Sentry's error monitoring
|
// A faulty API route to test Sentry's error monitoring
|
||||||
export function GET() {
|
export function GET() {
|
||||||
throw new SentryExampleAPIError(
|
throw new SentryExampleAPIError(
|
||||||
'This error is raised on the backend called by the example page.',
|
'This error is raised on the backend called by the example page.',
|
||||||
);
|
);
|
||||||
return NextResponse.json({ data: 'Testing Sentry Error...' });
|
return NextResponse.json({ data: 'Testing Sentry Error...' });
|
||||||
}
|
}
|
||||||
|
@ -12,53 +12,61 @@ import { useEffect } from 'react';
|
|||||||
import { Geist } from 'next/font/google';
|
import { Geist } from 'next/font/google';
|
||||||
|
|
||||||
const geist = Geist({
|
const geist = Geist({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
variable: '--font-geist-sans',
|
variable: '--font-geist-sans',
|
||||||
});
|
});
|
||||||
|
|
||||||
type GlobalErrorProps = {
|
type GlobalErrorProps = {
|
||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset?: () => void;
|
reset?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
|
<html
|
||||||
<body
|
lang='en'
|
||||||
className={cn('bg-background text-foreground font-sans antialiased')}
|
className={`${geist.variable}`}
|
||||||
>
|
suppressHydrationWarning
|
||||||
<ThemeProvider
|
>
|
||||||
attribute='class'
|
<body
|
||||||
defaultTheme='system'
|
className={cn(
|
||||||
enableSystem
|
'bg-background text-foreground font-sans antialiased',
|
||||||
disableTransitionOnChange
|
)}
|
||||||
>
|
>
|
||||||
<AuthProvider>
|
<ThemeProvider
|
||||||
<main className='min-h-screen flex flex-col items-center'>
|
attribute='class'
|
||||||
<div className='flex-1 w-full flex flex-col gap-20 items-center'>
|
defaultTheme='system'
|
||||||
<Navigation />
|
enableSystem
|
||||||
<div
|
disableTransitionOnChange
|
||||||
className='flex flex-col gap-20 max-w-5xl
|
>
|
||||||
|
<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'
|
p-5 w-full items-center'
|
||||||
>
|
>
|
||||||
<NextError statusCode={0} />
|
<NextError statusCode={0} />
|
||||||
{reset !== undefined && (
|
{reset !== undefined && (
|
||||||
<Button onClick={() => reset()}>Try again</Button>
|
<Button onClick={() => reset()}>
|
||||||
)}
|
Try again
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
<Footer />
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<Toaster />
|
<Footer />
|
||||||
</AuthProvider>
|
</main>
|
||||||
</ThemeProvider>
|
<Toaster />
|
||||||
</body>
|
</AuthProvider>
|
||||||
</html>
|
</ThemeProvider>
|
||||||
);
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GlobalError;
|
export default GlobalError;
|
||||||
|
@ -3,9 +3,9 @@ import '@/styles/globals.css';
|
|||||||
import { Geist } from 'next/font/google';
|
import { Geist } from 'next/font/google';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
AuthProvider,
|
AuthProvider,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
TVModeProvider,
|
TVModeProvider,
|
||||||
} from '@/components/context';
|
} from '@/components/context';
|
||||||
import Navigation from '@/components/default/navigation';
|
import Navigation from '@/components/default/navigation';
|
||||||
import Footer from '@/components/default/footer';
|
import Footer from '@/components/default/footer';
|
||||||
@ -13,372 +13,446 @@ import { Toaster } from '@/components/ui';
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
export const generateMetadata = (): Metadata => {
|
export const generateMetadata = (): Metadata => {
|
||||||
return {
|
return {
|
||||||
title: {
|
title: {
|
||||||
template: '%s | T3 Template',
|
template: '%s | T3 Template',
|
||||||
default: 'T3 Template with Supabase',
|
default: 'T3 Template with Supabase',
|
||||||
},
|
},
|
||||||
description: 'Created by Gib with T3!',
|
description: 'Created by Gib with T3!',
|
||||||
applicationName: 'T3 Template',
|
applicationName: 'T3 Template',
|
||||||
keywords:
|
keywords:
|
||||||
'T3 Template, Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib, Theo',
|
'T3 Template, Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib, Theo',
|
||||||
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
|
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
|
||||||
creator: 'Gib Brown',
|
creator: 'Gib Brown',
|
||||||
publisher: 'Gib Brown',
|
publisher: 'Gib Brown',
|
||||||
formatDetection: {
|
formatDetection: {
|
||||||
email: false,
|
email: false,
|
||||||
address: false,
|
address: false,
|
||||||
telephone: false,
|
telephone: false,
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
nocache: false,
|
nocache: false,
|
||||||
googleBot: {
|
googleBot: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
noimageindex: false,
|
noimageindex: false,
|
||||||
'max-video-preview': -1,
|
'max-video-preview': -1,
|
||||||
'max-image-preview': 'large',
|
'max-image-preview': 'large',
|
||||||
'max-snippet': -1,
|
'max-snippet': -1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
|
{ 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-16x16.png',
|
||||||
{ url: '/favicon.png', type: 'image/png', sizes: '96x96' },
|
type: 'image/png',
|
||||||
{
|
sizes: '16x16',
|
||||||
url: '/favicon.ico',
|
},
|
||||||
type: 'image/x-icon',
|
{
|
||||||
sizes: 'any',
|
url: '/favicon-32x32.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '32x32',
|
||||||
{
|
},
|
||||||
url: '/favicon-16x16.png',
|
{ url: '/favicon.png', type: 'image/png', sizes: '96x96' },
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '16x16',
|
url: '/favicon.ico',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/x-icon',
|
||||||
},
|
sizes: 'any',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: '/favicon-32x32.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '32x32',
|
url: '/favicon-16x16.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '16x16',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: '/favicon-96x96.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '96x96',
|
url: '/favicon-32x32.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
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-36x36.png',
|
||||||
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
|
type: 'image/png',
|
||||||
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' },
|
sizes: '36x36',
|
||||||
{
|
},
|
||||||
url: '/appicon/icon-144x144.png',
|
{
|
||||||
type: 'image/png',
|
url: '/appicon/icon-48x48.png',
|
||||||
sizes: '144x144',
|
type: 'image/png',
|
||||||
},
|
sizes: '48x48',
|
||||||
{ url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' },
|
},
|
||||||
{
|
{
|
||||||
url: '/appicon/icon-36x36.png',
|
url: '/appicon/icon-72x72.png',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
sizes: '36x36',
|
sizes: '72x72',
|
||||||
media: '(prefers-color-scheme: dark)',
|
},
|
||||||
},
|
{
|
||||||
{
|
url: '/appicon/icon-96x96.png',
|
||||||
url: '/appicon/icon-48x48.png',
|
type: 'image/png',
|
||||||
type: 'image/png',
|
sizes: '96x96',
|
||||||
sizes: '48x48',
|
},
|
||||||
media: '(prefers-color-scheme: dark)',
|
{
|
||||||
},
|
url: '/appicon/icon-144x144.png',
|
||||||
{
|
type: 'image/png',
|
||||||
url: '/appicon/icon-72x72.png',
|
sizes: '144x144',
|
||||||
type: 'image/png',
|
},
|
||||||
sizes: '72x72',
|
{
|
||||||
media: '(prefers-color-scheme: dark)',
|
url: '/appicon/icon.png',
|
||||||
},
|
type: 'image/png',
|
||||||
{
|
sizes: '192x192',
|
||||||
url: '/appicon/icon-96x96.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '96x96',
|
url: '/appicon/icon-36x36.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '36x36',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: '/appicon/icon-144x144.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '144x144',
|
url: '/appicon/icon-48x48.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '48x48',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: '/appicon/icon.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '192x192',
|
url: '/appicon/icon-72x72.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '72x72',
|
||||||
],
|
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-96x96.png',
|
||||||
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
|
type: 'image/png',
|
||||||
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' },
|
sizes: '96x96',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: '/appicon/icon-144x144.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '144x144',
|
url: '/appicon/icon-144x144.png',
|
||||||
},
|
type: 'image/png',
|
||||||
{ url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' },
|
sizes: '144x144',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: '/appicon/icon-36x36.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '36x36',
|
url: '/appicon/icon.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '192x192',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: '/appicon/icon-48x48.png',
|
},
|
||||||
type: 'image/png',
|
],
|
||||||
sizes: '48x48',
|
shortcut: [
|
||||||
media: '(prefers-color-scheme: dark)',
|
{
|
||||||
},
|
url: '/appicon/icon-36x36.png',
|
||||||
{
|
type: 'image/png',
|
||||||
url: '/appicon/icon-72x72.png',
|
sizes: '36x36',
|
||||||
type: 'image/png',
|
},
|
||||||
sizes: '72x72',
|
{
|
||||||
media: '(prefers-color-scheme: dark)',
|
url: '/appicon/icon-48x48.png',
|
||||||
},
|
type: 'image/png',
|
||||||
{
|
sizes: '48x48',
|
||||||
url: '/appicon/icon-96x96.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '96x96',
|
url: '/appicon/icon-72x72.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '72x72',
|
||||||
{
|
},
|
||||||
url: '/appicon/icon-144x144.png',
|
{
|
||||||
type: 'image/png',
|
url: '/appicon/icon-96x96.png',
|
||||||
sizes: '144x144',
|
type: 'image/png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
sizes: '96x96',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: '/appicon/icon.png',
|
url: '/appicon/icon-144x144.png',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
sizes: '192x192',
|
sizes: '144x144',
|
||||||
media: '(prefers-color-scheme: dark)',
|
},
|
||||||
},
|
{
|
||||||
],
|
url: '/appicon/icon.png',
|
||||||
apple: [
|
type: 'image/png',
|
||||||
{ url: 'appicon/icon-57x57.png', type: 'image/png', sizes: '57x57' },
|
sizes: '192x192',
|
||||||
{ 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-36x36.png',
|
||||||
{
|
type: 'image/png',
|
||||||
url: 'appicon/icon-114x114.png',
|
sizes: '36x36',
|
||||||
type: 'image/png',
|
media: '(prefers-color-scheme: dark)',
|
||||||
sizes: '114x114',
|
},
|
||||||
},
|
{
|
||||||
{
|
url: '/appicon/icon-48x48.png',
|
||||||
url: 'appicon/icon-120x120.png',
|
type: 'image/png',
|
||||||
type: 'image/png',
|
sizes: '48x48',
|
||||||
sizes: '120x120',
|
media: '(prefers-color-scheme: dark)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'appicon/icon-144x144.png',
|
url: '/appicon/icon-72x72.png',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
sizes: '144x144',
|
sizes: '72x72',
|
||||||
},
|
media: '(prefers-color-scheme: dark)',
|
||||||
{
|
},
|
||||||
url: 'appicon/icon-152x152.png',
|
{
|
||||||
type: 'image/png',
|
url: '/appicon/icon-96x96.png',
|
||||||
sizes: '152x152',
|
type: 'image/png',
|
||||||
},
|
sizes: '96x96',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: 'appicon/icon-180x180.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '180x180',
|
url: '/appicon/icon-144x144.png',
|
||||||
},
|
type: 'image/png',
|
||||||
{ url: 'appicon/icon.png', type: 'image/png', sizes: '192x192' },
|
sizes: '144x144',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: 'appicon/icon-57x57.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '57x57',
|
url: '/appicon/icon.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '192x192',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: 'appicon/icon-60x60.png',
|
},
|
||||||
type: 'image/png',
|
],
|
||||||
sizes: '60x60',
|
apple: [
|
||||||
media: '(prefers-color-scheme: dark)',
|
{
|
||||||
},
|
url: 'appicon/icon-57x57.png',
|
||||||
{
|
type: 'image/png',
|
||||||
url: 'appicon/icon-72x72.png',
|
sizes: '57x57',
|
||||||
type: 'image/png',
|
},
|
||||||
sizes: '72x72',
|
{
|
||||||
media: '(prefers-color-scheme: dark)',
|
url: 'appicon/icon-60x60.png',
|
||||||
},
|
type: 'image/png',
|
||||||
{
|
sizes: '60x60',
|
||||||
url: 'appicon/icon-76x76.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '76x76',
|
url: 'appicon/icon-72x72.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '72x72',
|
||||||
{
|
},
|
||||||
url: 'appicon/icon-114x114.png',
|
{
|
||||||
type: 'image/png',
|
url: 'appicon/icon-76x76.png',
|
||||||
sizes: '114x114',
|
type: 'image/png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
sizes: '76x76',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'appicon/icon-120x120.png',
|
url: 'appicon/icon-114x114.png',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
sizes: '120x120',
|
sizes: '114x114',
|
||||||
media: '(prefers-color-scheme: dark)',
|
},
|
||||||
},
|
{
|
||||||
{
|
url: 'appicon/icon-120x120.png',
|
||||||
url: 'appicon/icon-144x144.png',
|
type: 'image/png',
|
||||||
type: 'image/png',
|
sizes: '120x120',
|
||||||
sizes: '144x144',
|
},
|
||||||
media: '(prefers-color-scheme: dark)',
|
{
|
||||||
},
|
url: 'appicon/icon-144x144.png',
|
||||||
{
|
type: 'image/png',
|
||||||
url: 'appicon/icon-152x152.png',
|
sizes: '144x144',
|
||||||
type: 'image/png',
|
},
|
||||||
sizes: '152x152',
|
{
|
||||||
media: '(prefers-color-scheme: dark)',
|
url: 'appicon/icon-152x152.png',
|
||||||
},
|
type: 'image/png',
|
||||||
{
|
sizes: '152x152',
|
||||||
url: 'appicon/icon-180x180.png',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
sizes: '180x180',
|
url: 'appicon/icon-180x180.png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
type: 'image/png',
|
||||||
},
|
sizes: '180x180',
|
||||||
{
|
},
|
||||||
url: 'appicon/icon.png',
|
{
|
||||||
type: 'image/png',
|
url: 'appicon/icon.png',
|
||||||
sizes: '192x192',
|
type: 'image/png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
sizes: '192x192',
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
other: [
|
url: 'appicon/icon-57x57.png',
|
||||||
{
|
type: 'image/png',
|
||||||
rel: 'apple-touch-icon-precomposed',
|
sizes: '57x57',
|
||||||
url: '/appicon/icon-precomposed.png',
|
media: '(prefers-color-scheme: dark)',
|
||||||
type: 'image/png',
|
},
|
||||||
sizes: '180x180',
|
{
|
||||||
},
|
url: 'appicon/icon-60x60.png',
|
||||||
],
|
type: 'image/png',
|
||||||
},
|
sizes: '60x60',
|
||||||
other: {
|
media: '(prefers-color-scheme: dark)',
|
||||||
...Sentry.getTraceData(),
|
},
|
||||||
},
|
{
|
||||||
twitter: {
|
url: 'appicon/icon-72x72.png',
|
||||||
card: 'app',
|
type: 'image/png',
|
||||||
title: 'T3 Template',
|
sizes: '72x72',
|
||||||
description: 'Created by Gib with T3!',
|
media: '(prefers-color-scheme: dark)',
|
||||||
siteId: '',
|
},
|
||||||
creator: '@cs_gib',
|
{
|
||||||
creatorId: '',
|
url: 'appicon/icon-76x76.png',
|
||||||
images: {
|
type: 'image/png',
|
||||||
url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
|
sizes: '76x76',
|
||||||
alt: 'T3 Template',
|
media: '(prefers-color-scheme: dark)',
|
||||||
},
|
},
|
||||||
app: {
|
{
|
||||||
name: 'T3 Template',
|
url: 'appicon/icon-114x114.png',
|
||||||
id: {
|
type: 'image/png',
|
||||||
iphone: '',
|
sizes: '114x114',
|
||||||
ipad: '',
|
media: '(prefers-color-scheme: dark)',
|
||||||
googleplay: '',
|
},
|
||||||
},
|
{
|
||||||
url: {
|
url: 'appicon/icon-120x120.png',
|
||||||
iphone: '',
|
type: 'image/png',
|
||||||
ipad: '',
|
sizes: '120x120',
|
||||||
googleplay: '',
|
media: '(prefers-color-scheme: dark)',
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
},
|
url: 'appicon/icon-144x144.png',
|
||||||
verification: {
|
type: 'image/png',
|
||||||
google: 'google',
|
sizes: '144x144',
|
||||||
yandex: 'yandex',
|
media: '(prefers-color-scheme: dark)',
|
||||||
yahoo: 'yahoo',
|
},
|
||||||
},
|
{
|
||||||
itunes: {
|
url: 'appicon/icon-152x152.png',
|
||||||
appId: '',
|
type: 'image/png',
|
||||||
appArgument: '',
|
sizes: '152x152',
|
||||||
},
|
media: '(prefers-color-scheme: dark)',
|
||||||
appleWebApp: {
|
},
|
||||||
title: 'T3 Template',
|
{
|
||||||
statusBarStyle: 'black-translucent',
|
url: 'appicon/icon-180x180.png',
|
||||||
startupImage: [
|
type: 'image/png',
|
||||||
'/icons/apple/splash-768x1004.png',
|
sizes: '180x180',
|
||||||
{
|
media: '(prefers-color-scheme: dark)',
|
||||||
url: '/icons/apple/splash-1536x2008.png',
|
},
|
||||||
media: '(device-width: 768px) and (device-height: 1024px)',
|
{
|
||||||
},
|
url: 'appicon/icon.png',
|
||||||
],
|
type: 'image/png',
|
||||||
},
|
sizes: '192x192',
|
||||||
appLinks: {
|
media: '(prefers-color-scheme: dark)',
|
||||||
ios: {
|
},
|
||||||
url: 'https://t3-template.gbrown.org/ios',
|
],
|
||||||
app_store_id: 't3_template',
|
other: [
|
||||||
},
|
{
|
||||||
android: {
|
rel: 'apple-touch-icon-precomposed',
|
||||||
package: 'org.gbrown.android/t3-template',
|
url: '/appicon/icon-precomposed.png',
|
||||||
app_name: 'app_t3_template',
|
type: 'image/png',
|
||||||
},
|
sizes: '180x180',
|
||||||
web: {
|
},
|
||||||
url: 'https://t3-template.gbrown.org/web',
|
],
|
||||||
should_fallback: true,
|
},
|
||||||
},
|
other: {
|
||||||
},
|
...Sentry.getTraceData(),
|
||||||
facebook: {
|
},
|
||||||
appId: '',
|
twitter: {
|
||||||
},
|
card: 'app',
|
||||||
pinterest: {
|
title: 'T3 Template',
|
||||||
richPin: true,
|
description: 'Created by Gib with T3!',
|
||||||
},
|
siteId: '',
|
||||||
category: 'technology',
|
creator: '@cs_gib',
|
||||||
};
|
creatorId: '',
|
||||||
|
images: {
|
||||||
|
url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
|
||||||
|
alt: 'T3 Template',
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
name: 'T3 Template',
|
||||||
|
id: {
|
||||||
|
iphone: '',
|
||||||
|
ipad: '',
|
||||||
|
googleplay: '',
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
iphone: '',
|
||||||
|
ipad: '',
|
||||||
|
googleplay: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verification: {
|
||||||
|
google: 'google',
|
||||||
|
yandex: 'yandex',
|
||||||
|
yahoo: 'yahoo',
|
||||||
|
},
|
||||||
|
itunes: {
|
||||||
|
appId: '',
|
||||||
|
appArgument: '',
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
title: 'T3 Template',
|
||||||
|
statusBarStyle: 'black-translucent',
|
||||||
|
startupImage: [
|
||||||
|
'/icons/apple/splash-768x1004.png',
|
||||||
|
{
|
||||||
|
url: '/icons/apple/splash-1536x2008.png',
|
||||||
|
media: '(device-width: 768px) and (device-height: 1024px)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
appLinks: {
|
||||||
|
ios: {
|
||||||
|
url: 'https://t3-template.gbrown.org/ios',
|
||||||
|
app_store_id: 't3_template',
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
package: 'org.gbrown.android/t3-template',
|
||||||
|
app_name: 'app_t3_template',
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
url: 'https://t3-template.gbrown.org/web',
|
||||||
|
should_fallback: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
appId: '',
|
||||||
|
},
|
||||||
|
pinterest: {
|
||||||
|
richPin: true,
|
||||||
|
},
|
||||||
|
category: 'technology',
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const geist = Geist({
|
const geist = Geist({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
variable: '--font-geist-sans',
|
variable: '--font-geist-sans',
|
||||||
});
|
});
|
||||||
|
|
||||||
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||||
return (
|
return (
|
||||||
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
|
<html
|
||||||
<body
|
lang='en'
|
||||||
className={cn('bg-background text-foreground font-sans antialiased')}
|
className={`${geist.variable}`}
|
||||||
>
|
suppressHydrationWarning
|
||||||
<ThemeProvider
|
>
|
||||||
attribute='class'
|
<body
|
||||||
defaultTheme='system'
|
className={cn(
|
||||||
enableSystem
|
'bg-background text-foreground font-sans antialiased',
|
||||||
disableTransitionOnChange
|
)}
|
||||||
>
|
>
|
||||||
<AuthProvider>
|
<ThemeProvider
|
||||||
<TVModeProvider>
|
attribute='class'
|
||||||
<main className='min-h-screen flex flex-col items-center'>
|
defaultTheme='system'
|
||||||
<div className='flex-1 w-full flex flex-col gap-20 items-center'>
|
enableSystem
|
||||||
<Navigation />
|
disableTransitionOnChange
|
||||||
<div
|
>
|
||||||
className='flex flex-col gap-20 max-w-5xl
|
<AuthProvider>
|
||||||
|
<TVModeProvider>
|
||||||
|
<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'
|
p-5 w-full items-center'
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</TVModeProvider>
|
</TVModeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default RootLayout;
|
export default RootLayout;
|
||||||
|
164
src/app/page.tsx
164
src/app/page.tsx
@ -6,91 +6,93 @@ import { getUser } from '@/lib/actions';
|
|||||||
import type { User } from '@/utils/supabase';
|
import type { User } from '@/utils/supabase';
|
||||||
import { TestSentryCard } from '@/components/default/sentry';
|
import { TestSentryCard } from '@/components/default/sentry';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Separator,
|
Separator,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
SignInSignUp,
|
SignInSignUp,
|
||||||
SignInWithApple,
|
SignInWithApple,
|
||||||
SignInWithMicrosoft,
|
SignInWithMicrosoft,
|
||||||
} from '@/components/default/auth';
|
} from '@/components/default/auth';
|
||||||
|
|
||||||
const HomePage = async () => {
|
const HomePage = async () => {
|
||||||
const response = await getUser();
|
const response = await getUser();
|
||||||
if (!response.success || !response.data) {
|
if (!response.success || !response.data) {
|
||||||
return (
|
return (
|
||||||
<main className='w-full items-center justify-center'>
|
<main className='w-full items-center justify-center'>
|
||||||
<div className='flex flex-col p-5 items-center justify-center space-y-6'>
|
<div className='flex flex-col p-5 items-center justify-center space-y-6'>
|
||||||
<Card className='md:min-w-2xl'>
|
<Card className='md:min-w-2xl'>
|
||||||
<CardHeader className='flex flex-col items-center'>
|
<CardHeader className='flex flex-col items-center'>
|
||||||
<CardTitle className='text-3xl'>
|
<CardTitle className='text-3xl'>
|
||||||
Welcome to the T3 Supabase Template!
|
Welcome to the T3 Supabase Template!
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className='text-[1.0rem] mb-2'>
|
<CardDescription className='text-[1.0rem] mb-2'>
|
||||||
A great place to start is by creating a new user account &
|
A great place to start is by creating a new user
|
||||||
ensuring you can sign up! If you already have an account, go
|
account & ensuring you can sign up! If you
|
||||||
ahead and sign in!
|
already have an account, go ahead and sign in!
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<SignInSignUp
|
<SignInSignUp
|
||||||
className='flex gap-4 w-full justify-center'
|
className='flex gap-4 w-full justify-center'
|
||||||
signInSize='xl'
|
signInSize='xl'
|
||||||
signUpSize='xl'
|
signUpSize='xl'
|
||||||
/>
|
/>
|
||||||
<div className='flex items-center w-full gap-4'>
|
<div className='flex items-center w-full gap-4'>
|
||||||
<Separator className='flex-1 bg-accent py-0.5' />
|
<Separator className='flex-1 bg-accent py-0.5' />
|
||||||
<span className='text-sm text-muted-foreground'>or</span>
|
<span className='text-sm text-muted-foreground'>
|
||||||
<Separator className='flex-1 bg-accent py-0.5' />
|
or
|
||||||
</div>
|
</span>
|
||||||
<div className='flex gap-4'>
|
<Separator className='flex-1 bg-accent py-0.5' />
|
||||||
<SignInWithMicrosoft buttonSize='lg' />
|
</div>
|
||||||
<SignInWithApple buttonSize='lg' />
|
<div className='flex gap-4'>
|
||||||
</div>
|
<SignInWithMicrosoft buttonSize='lg' />
|
||||||
</CardHeader>
|
<SignInWithApple buttonSize='lg' />
|
||||||
<Separator className='bg-accent' />
|
</div>
|
||||||
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'>
|
</CardHeader>
|
||||||
<CardTitle className='text-lg mb-6 w-2/3 text-center'>
|
<Separator className='bg-accent' />
|
||||||
You can also test out your connection to Sentry if you want to
|
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'>
|
||||||
start there!
|
<CardTitle className='text-lg mb-6 w-2/3 text-center'>
|
||||||
</CardTitle>
|
You can also test out your connection to Sentry
|
||||||
<TestSentryCard />
|
if you want to start there!
|
||||||
</CardContent>
|
</CardTitle>
|
||||||
</Card>
|
<TestSentryCard />
|
||||||
</div>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
);
|
</div>
|
||||||
}
|
</main>
|
||||||
const user: User = response.data;
|
);
|
||||||
return (
|
}
|
||||||
<div className='flex-1 w-full flex flex-col gap-12'>
|
const user: User = response.data;
|
||||||
<div className='w-full'>
|
return (
|
||||||
<div
|
<div className='flex-1 w-full flex flex-col gap-12'>
|
||||||
className='bg-accent text-sm p-3 px-5
|
<div className='w-full'>
|
||||||
rounded-md text-foreground flex gap-3 items-center'
|
<div
|
||||||
>
|
className='bg-accent text-sm p-3 px-5
|
||||||
<InfoIcon size='16' strokeWidth={2} />
|
rounded-md text-foreground flex gap-3 items-center'
|
||||||
This is a protected component that you can only see as an
|
>
|
||||||
authenticated user
|
<InfoIcon size='16' strokeWidth={2} />
|
||||||
</div>
|
This is a protected component that you can only see as an
|
||||||
</div>
|
authenticated user
|
||||||
<div className='flex flex-col gap-2 items-start'>
|
</div>
|
||||||
<h2 className='font-bold text-3xl mb-4'>Your user details</h2>
|
</div>
|
||||||
<pre
|
<div className='flex flex-col gap-2 items-start'>
|
||||||
className='text-sm font-mono p-3 rounded
|
<h2 className='font-bold text-3xl mb-4'>Your user details</h2>
|
||||||
border max-h-50 overflow-auto'
|
<pre
|
||||||
>
|
className='text-sm font-mono p-3 rounded
|
||||||
{JSON.stringify(user, null, 2)}
|
border max-h-50 overflow-auto'
|
||||||
</pre>
|
>
|
||||||
</div>
|
{JSON.stringify(user, null, 2)}
|
||||||
<TestSentryCard />
|
</pre>
|
||||||
<div>
|
</div>
|
||||||
<h2 className='font-bold text-2xl mb-4'>Next steps</h2>
|
<TestSentryCard />
|
||||||
<FetchDataSteps />
|
<div>
|
||||||
</div>
|
<h2 className='font-bold text-2xl mb-4'>Next steps</h2>
|
||||||
</div>
|
<FetchDataSteps />
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
export default HomePage;
|
export default HomePage;
|
||||||
|
@ -1,195 +1,199 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, {
|
import React, {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
getProfile,
|
getProfile,
|
||||||
getSignedUrl,
|
getSignedUrl,
|
||||||
getUser,
|
getUser,
|
||||||
updateProfile as updateProfileAction,
|
updateProfile as updateProfileAction,
|
||||||
} from '@/lib/hooks';
|
} from '@/lib/hooks';
|
||||||
import { type User, type Profile, createClient } from '@/utils/supabase';
|
import { type User, type Profile, createClient } from '@/utils/supabase';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
type AuthContextType = {
|
type AuthContextType = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
updateProfile: (data: {
|
updateProfile: (data: {
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
|
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
|
||||||
refreshUserData: () => Promise<void>;
|
refreshUserData: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
const fetchUserData = useCallback(
|
const fetchUserData = useCallback(
|
||||||
async (showLoading = true) => {
|
async (showLoading = true) => {
|
||||||
if (fetchingRef.current) return;
|
if (fetchingRef.current) return;
|
||||||
fetchingRef.current = true;
|
fetchingRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Only show loading for initial load or manual refresh
|
// Only show loading for initial load or manual refresh
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userResponse = await getUser();
|
const userResponse = await getUser();
|
||||||
const profileResponse = await getProfile();
|
const profileResponse = await getProfile();
|
||||||
|
|
||||||
if (!userResponse.success || !profileResponse.success) {
|
if (!userResponse.success || !profileResponse.success) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setAvatarUrl(null);
|
setAvatarUrl(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(userResponse.data);
|
setUser(userResponse.data);
|
||||||
setProfile(profileResponse.data);
|
setProfile(profileResponse.data);
|
||||||
|
|
||||||
// Get avatar URL if available
|
// Get avatar URL if available
|
||||||
if (profileResponse.data.avatar_url) {
|
if (profileResponse.data.avatar_url) {
|
||||||
const avatarResponse = await getSignedUrl({
|
const avatarResponse = await getSignedUrl({
|
||||||
bucket: 'avatars',
|
bucket: 'avatars',
|
||||||
url: profileResponse.data.avatar_url,
|
url: profileResponse.data.avatar_url,
|
||||||
});
|
});
|
||||||
if (avatarResponse.success) {
|
if (avatarResponse.success) {
|
||||||
setAvatarUrl(avatarResponse.data);
|
setAvatarUrl(avatarResponse.data);
|
||||||
} else {
|
} else {
|
||||||
setAvatarUrl(null);
|
setAvatarUrl(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setAvatarUrl(null);
|
setAvatarUrl(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'Auth fetch error: ',
|
'Auth fetch error: ',
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? `${error.message}`
|
? `${error.message}`
|
||||||
: 'Failed to load user data!',
|
: 'Failed to load user data!',
|
||||||
);
|
);
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
toast.error('Failed to load user data!');
|
toast.error('Failed to load user data!');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
fetchingRef.current = false;
|
fetchingRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isInitialized],
|
[isInitialized],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|
||||||
// Initial fetch with loading
|
// Initial fetch with loading
|
||||||
fetchUserData(true).catch((error) => {
|
fetchUserData(true).catch((error) => {
|
||||||
console.error('💥 Initial fetch error:', error);
|
console.error('💥 Initial fetch error:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { subscription },
|
data: { subscription },
|
||||||
} = supabase.auth.onAuthStateChange(async (event, _session) => {
|
} = supabase.auth.onAuthStateChange(async (event, _session) => {
|
||||||
console.log('Auth state change:', event); // Debug log
|
console.log('Auth state change:', event); // Debug log
|
||||||
|
|
||||||
if (event === 'SIGNED_IN') {
|
if (event === 'SIGNED_IN') {
|
||||||
// Background refresh without loading state
|
// Background refresh without loading state
|
||||||
await fetchUserData(false);
|
await fetchUserData(false);
|
||||||
} else if (event === 'SIGNED_OUT') {
|
} else if (event === 'SIGNED_OUT') {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setAvatarUrl(null);
|
setAvatarUrl(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} else if (event === 'TOKEN_REFRESHED') {
|
} else if (event === 'TOKEN_REFRESHED') {
|
||||||
// Silent refresh - don't show loading
|
// Silent refresh - don't show loading
|
||||||
await fetchUserData(false);
|
await fetchUserData(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [fetchUserData]);
|
}, [fetchUserData]);
|
||||||
|
|
||||||
const updateProfile = useCallback(
|
const updateProfile = useCallback(
|
||||||
async (data: {
|
async (data: {
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const result = await updateProfileAction(data);
|
const result = await updateProfileAction(data);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error ?? 'Failed to update profile');
|
throw new Error(result.error ?? 'Failed to update profile');
|
||||||
}
|
}
|
||||||
setProfile(result.data);
|
setProfile(result.data);
|
||||||
|
|
||||||
// If avatar was updated, refresh the avatar URL
|
// If avatar was updated, refresh the avatar URL
|
||||||
if (data.avatar_url && result.data.avatar_url) {
|
if (data.avatar_url && result.data.avatar_url) {
|
||||||
const avatarResponse = await getSignedUrl({
|
const avatarResponse = await getSignedUrl({
|
||||||
bucket: 'avatars',
|
bucket: 'avatars',
|
||||||
url: result.data.avatar_url,
|
url: result.data.avatar_url,
|
||||||
transform: { width: 128, height: 128 },
|
transform: { width: 128, height: 128 },
|
||||||
});
|
});
|
||||||
if (avatarResponse.success) {
|
if (avatarResponse.success) {
|
||||||
setAvatarUrl(avatarResponse.data);
|
setAvatarUrl(avatarResponse.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success('Profile updated successfully!');
|
toast.success('Profile updated successfully!');
|
||||||
return { success: true, data: result.data };
|
return { success: true, data: result.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating profile:', error);
|
console.error('Error updating profile:', error);
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error ? error.message : 'Failed to update profile',
|
error instanceof Error
|
||||||
);
|
? error.message
|
||||||
return { success: false, error };
|
: 'Failed to update profile',
|
||||||
}
|
);
|
||||||
},
|
return { success: false, error };
|
||||||
[],
|
}
|
||||||
);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const refreshUserData = useCallback(async () => {
|
const refreshUserData = useCallback(async () => {
|
||||||
await fetchUserData(true); // Manual refresh shows loading
|
await fetchUserData(true); // Manual refresh shows loading
|
||||||
}, [fetchUserData]);
|
}, [fetchUserData]);
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
refreshUserData,
|
refreshUserData,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return (
|
||||||
|
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
@ -8,72 +8,72 @@ import { type ComponentProps } from 'react';
|
|||||||
import { type VariantProps } from 'class-variance-authority';
|
import { type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
type TVModeContextProps = {
|
type TVModeContextProps = {
|
||||||
tvMode: boolean;
|
tvMode: boolean;
|
||||||
toggleTVMode: () => void;
|
toggleTVMode: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TVToggleProps = {
|
type TVToggleProps = {
|
||||||
className?: ComponentProps<'button'>['className'];
|
className?: ComponentProps<'button'>['className'];
|
||||||
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
||||||
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||||
imageWidth?: number;
|
imageWidth?: number;
|
||||||
imageHeight?: number;
|
imageHeight?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TVModeContext = createContext<TVModeContextProps | undefined>(undefined);
|
const TVModeContext = createContext<TVModeContextProps | undefined>(undefined);
|
||||||
|
|
||||||
export const TVModeProvider = ({ children }: { children: ReactNode }) => {
|
export const TVModeProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [tvMode, setTVMode] = useState(false);
|
const [tvMode, setTVMode] = useState(false);
|
||||||
const toggleTVMode = () => {
|
const toggleTVMode = () => {
|
||||||
setTVMode((prev) => !prev);
|
setTVMode((prev) => !prev);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<TVModeContext.Provider value={{ tvMode, toggleTVMode }}>
|
<TVModeContext.Provider value={{ tvMode, toggleTVMode }}>
|
||||||
{children}
|
{children}
|
||||||
</TVModeContext.Provider>
|
</TVModeContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTVMode = () => {
|
export const useTVMode = () => {
|
||||||
const context = useContext(TVModeContext);
|
const context = useContext(TVModeContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useTVMode must be used within a TVModeProvider');
|
throw new Error('useTVMode must be used within a TVModeProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TVToggle = ({
|
export const TVToggle = ({
|
||||||
className = 'my-auto cursor-pointer',
|
className = 'my-auto cursor-pointer',
|
||||||
buttonSize = 'default',
|
buttonSize = 'default',
|
||||||
buttonVariant = 'link',
|
buttonVariant = 'link',
|
||||||
imageWidth = 25,
|
imageWidth = 25,
|
||||||
imageHeight = 25,
|
imageHeight = 25,
|
||||||
}: TVToggleProps) => {
|
}: TVToggleProps) => {
|
||||||
const { tvMode, toggleTVMode } = useTVMode();
|
const { tvMode, toggleTVMode } = useTVMode();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
if (!isAuthenticated) return <div />;
|
if (!isAuthenticated) return <div />;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={toggleTVMode}
|
onClick={toggleTVMode}
|
||||||
className={className}
|
className={className}
|
||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
variant={buttonVariant}
|
variant={buttonVariant}
|
||||||
>
|
>
|
||||||
{tvMode ? (
|
{tvMode ? (
|
||||||
<Image
|
<Image
|
||||||
src='/icons/tv/exit.svg'
|
src='/icons/tv/exit.svg'
|
||||||
alt='Exit TV Mode'
|
alt='Exit TV Mode'
|
||||||
width={imageWidth}
|
width={imageWidth}
|
||||||
height={imageHeight}
|
height={imageHeight}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
src='/icons/tv/enter.svg'
|
src='/icons/tv/enter.svg'
|
||||||
alt='Exit TV Mode'
|
alt='Exit TV Mode'
|
||||||
width={imageWidth}
|
width={imageWidth}
|
||||||
height={imageHeight}
|
height={imageHeight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -6,62 +6,62 @@ import { useTheme } from 'next-themes';
|
|||||||
import { Button } from '@/components/ui';
|
import { Button } from '@/components/ui';
|
||||||
|
|
||||||
export const ThemeProvider = ({
|
export const ThemeProvider = ({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) => {
|
}: React.ComponentProps<typeof NextThemesProvider>) => {
|
||||||
const [mounted, setMounted] = React.useState(false);
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThemeToggleProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
type ThemeToggleProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => {
|
export const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => {
|
||||||
const { setTheme, resolvedTheme } = useTheme();
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
const [mounted, setMounted] = React.useState(false);
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
<Button variant='outline' size='icon' {...props}>
|
<Button variant='outline' size='icon' {...props}>
|
||||||
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
|
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
if (resolvedTheme === 'dark') setTheme('light');
|
if (resolvedTheme === 'dark') setTheme('light');
|
||||||
else setTheme('dark');
|
else setTheme('dark');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='icon'
|
size='icon'
|
||||||
className='cursor-pointer'
|
className='cursor-pointer'
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Sun
|
<Sun
|
||||||
style={{ height: `${size}rem`, width: `${size}rem` }}
|
style={{ height: `${size}rem`, width: `${size}rem` }}
|
||||||
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
|
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
|
||||||
/>
|
/>
|
||||||
<Moon
|
<Moon
|
||||||
style={{ height: `${size}rem`, width: `${size}rem` }}
|
style={{ height: `${size}rem`, width: `${size}rem` }}
|
||||||
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
|
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
|
||||||
/>
|
/>
|
||||||
<span className='sr-only'>Toggle theme</span>
|
<span className='sr-only'>Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,25 +1,25 @@
|
|||||||
export type Message =
|
export type Message =
|
||||||
| { success: string }
|
| { success: string }
|
||||||
| { error: string }
|
| { error: string }
|
||||||
| { message: string };
|
| { message: string };
|
||||||
|
|
||||||
export const StatusMessage = ({ message }: { message: Message }) => {
|
export const StatusMessage = ({ message }: { message: Message }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='flex flex-col gap-2 w-full max-w-md
|
className='flex flex-col gap-2 w-full max-w-md
|
||||||
text-sm bg-accent rounded-md p-2 px-4'
|
text-sm bg-accent rounded-md p-2 px-4'
|
||||||
>
|
>
|
||||||
{'success' in message && (
|
{'success' in message && (
|
||||||
<div className='dark:text-green-500 text-green-700'>
|
<div className='dark:text-green-500 text-green-700'>
|
||||||
{message.success}
|
{message.success}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{'error' in message && (
|
{'error' in message && (
|
||||||
<div className='text-destructive'>{message.error}</div>
|
<div className='text-destructive'>{message.error}</div>
|
||||||
)}
|
)}
|
||||||
{'message' in message && (
|
{'message' in message && (
|
||||||
<div className='text-foreground'>{message.message}</div>
|
<div className='text-foreground'>{message.message}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -6,34 +6,34 @@ import { useFormStatus } from 'react-dom';
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
type Props = ComponentProps<typeof Button> & {
|
type Props = ComponentProps<typeof Button> & {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
pendingText?: string;
|
pendingText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SubmitButton = ({
|
export const SubmitButton = ({
|
||||||
children,
|
children,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
pendingText = 'Submitting...',
|
pendingText = 'Submitting...',
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { pending } = useFormStatus();
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className='cursor-pointer'
|
className='cursor-pointer'
|
||||||
type='submit'
|
type='submit'
|
||||||
aria-disabled={pending}
|
aria-disabled={pending}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{pending || disabled ? (
|
{pending || disabled ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
{pendingText}
|
{pendingText}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -6,28 +6,28 @@ import { type ComponentProps } from 'react';
|
|||||||
import { type VariantProps } from 'class-variance-authority';
|
import { type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
type SignInSignUpProps = {
|
type SignInSignUpProps = {
|
||||||
className?: ComponentProps<'div'>['className'];
|
className?: ComponentProps<'div'>['className'];
|
||||||
signInSize?: VariantProps<typeof buttonVariants>['size'];
|
signInSize?: VariantProps<typeof buttonVariants>['size'];
|
||||||
signUpSize?: VariantProps<typeof buttonVariants>['size'];
|
signUpSize?: VariantProps<typeof buttonVariants>['size'];
|
||||||
signInVariant?: VariantProps<typeof buttonVariants>['variant'];
|
signInVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||||
signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
|
signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignInSignUp = async ({
|
export const SignInSignUp = async ({
|
||||||
className = 'flex gap-2',
|
className = 'flex gap-2',
|
||||||
signInSize = 'default',
|
signInSize = 'default',
|
||||||
signUpSize = 'sm',
|
signUpSize = 'sm',
|
||||||
signInVariant = 'outline',
|
signInVariant = 'outline',
|
||||||
signUpVariant = 'default',
|
signUpVariant = 'default',
|
||||||
}: SignInSignUpProps) => {
|
}: SignInSignUpProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<Button asChild size={signInSize} variant={signInVariant}>
|
<Button asChild size={signInSize} variant={signInVariant}>
|
||||||
<Link href='/sign-in'>Sign In</Link>
|
<Link href='/sign-in'>Sign In</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size={signUpSize} variant={signUpVariant}>
|
<Button asChild size={signUpSize} variant={signUpVariant}>
|
||||||
<Link href='/sign-up'>Sign Up</Link>
|
<Link href='/sign-up'>Sign Up</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -10,68 +10,70 @@ import { type ComponentProps } from 'react';
|
|||||||
import { type VariantProps } from 'class-variance-authority';
|
import { type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
type SignInWithAppleProps = {
|
type SignInWithAppleProps = {
|
||||||
className?: ComponentProps<'div'>['className'];
|
className?: ComponentProps<'div'>['className'];
|
||||||
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
||||||
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignInWithApple = ({
|
export const SignInWithApple = ({
|
||||||
className = 'my-4',
|
className = 'my-4',
|
||||||
buttonSize = 'default',
|
buttonSize = 'default',
|
||||||
buttonVariant = 'default',
|
buttonVariant = 'default',
|
||||||
}: SignInWithAppleProps) => {
|
}: SignInWithAppleProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isLoading, refreshUserData } = useAuth();
|
const { isLoading, refreshUserData } = useAuth();
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||||
|
|
||||||
const handleSignInWithApple = async (e: React.FormEvent) => {
|
const handleSignInWithApple = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
setIsSigningIn(true);
|
setIsSigningIn(true);
|
||||||
|
|
||||||
const result = await signInWithApple();
|
const result = await signInWithApple();
|
||||||
|
|
||||||
if (result?.success && result.data) {
|
if (result?.success && result.data) {
|
||||||
// Redirect to Apple OAuth page
|
// Redirect to Apple OAuth page
|
||||||
window.location.href = result.data;
|
window.location.href = result.data;
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage(`Error signing in with Apple!`);
|
setStatusMessage(`Error signing in with Apple!`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSigningIn(false);
|
setIsSigningIn(false);
|
||||||
await refreshUserData();
|
await refreshUserData();
|
||||||
router.push('');
|
router.push('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSignInWithApple} className={className}>
|
<form onSubmit={handleSignInWithApple} className={className}>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
variant={buttonVariant}
|
variant={buttonVariant}
|
||||||
className='w-full cursor-pointer'
|
className='w-full cursor-pointer'
|
||||||
disabled={isLoading || isSigningIn}
|
disabled={isLoading || isSigningIn}
|
||||||
pendingText='Redirecting...'
|
pendingText='Redirecting...'
|
||||||
type='submit'
|
type='submit'
|
||||||
>
|
>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Image
|
<Image
|
||||||
src='/icons/apple.svg'
|
src='/icons/apple.svg'
|
||||||
alt='Apple logo'
|
alt='Apple logo'
|
||||||
className='invert-75 dark:invert-25'
|
className='invert-75 dark:invert-25'
|
||||||
width={22}
|
width={22}
|
||||||
height={22}
|
height={22}
|
||||||
/>
|
/>
|
||||||
<p className='text-[1.0rem]'>Sign In with Apple</p>
|
<p className='text-[1.0rem]'>Sign In with Apple</p>
|
||||||
</div>
|
</div>
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
{statusMessage && (
|
||||||
</form>
|
<StatusMessage message={{ error: statusMessage }} />
|
||||||
);
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -9,62 +9,64 @@ import { type ComponentProps } from 'react';
|
|||||||
import { type VariantProps } from 'class-variance-authority';
|
import { type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
type SignInWithMicrosoftProps = {
|
type SignInWithMicrosoftProps = {
|
||||||
className?: ComponentProps<'div'>['className'];
|
className?: ComponentProps<'div'>['className'];
|
||||||
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
||||||
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignInWithMicrosoft = ({
|
export const SignInWithMicrosoft = ({
|
||||||
className = 'my-4',
|
className = 'my-4',
|
||||||
buttonSize = 'default',
|
buttonSize = 'default',
|
||||||
buttonVariant = 'default',
|
buttonVariant = 'default',
|
||||||
}: SignInWithMicrosoftProps) => {
|
}: SignInWithMicrosoftProps) => {
|
||||||
const { isLoading } = useAuth();
|
const { isLoading } = useAuth();
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||||
|
|
||||||
const handleSignInWithMicrosoft = async (e: React.FormEvent) => {
|
const handleSignInWithMicrosoft = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
setIsSigningIn(true);
|
setIsSigningIn(true);
|
||||||
|
|
||||||
const result = await signInWithMicrosoft();
|
const result = await signInWithMicrosoft();
|
||||||
|
|
||||||
if (result?.success && result.data) {
|
if (result?.success && result.data) {
|
||||||
// Redirect to Microsoft OAuth page
|
// Redirect to Microsoft OAuth page
|
||||||
window.location.href = result.data;
|
window.location.href = result.data;
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage(`Error: Could not sign in with Microsoft!`);
|
setStatusMessage(`Error: Could not sign in with Microsoft!`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSignInWithMicrosoft} className={className}>
|
<form onSubmit={handleSignInWithMicrosoft} className={className}>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
variant={buttonVariant}
|
variant={buttonVariant}
|
||||||
className='w-full cursor-pointer'
|
className='w-full cursor-pointer'
|
||||||
disabled={isLoading || isSigningIn}
|
disabled={isLoading || isSigningIn}
|
||||||
pendingText='Redirecting...'
|
pendingText='Redirecting...'
|
||||||
type='submit'
|
type='submit'
|
||||||
>
|
>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Image
|
<Image
|
||||||
src='/icons/microsoft.svg'
|
src='/icons/microsoft.svg'
|
||||||
alt='Microsoft logo'
|
alt='Microsoft logo'
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
/>
|
/>
|
||||||
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
|
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
|
||||||
</div>
|
</div>
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
{statusMessage && (
|
||||||
</form>
|
<StatusMessage message={{ error: statusMessage }} />
|
||||||
);
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
return (
|
return (
|
||||||
<footer className='w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'>
|
<footer className='w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'>
|
||||||
<p>
|
<p>
|
||||||
Powered by{' '}
|
Powered by{' '}
|
||||||
<a
|
<a
|
||||||
href='https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs'
|
href='https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
className='font-bold hover:underline'
|
className='font-bold hover:underline'
|
||||||
rel='noreferrer'
|
rel='noreferrer'
|
||||||
>
|
>
|
||||||
Supabase
|
Supabase
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default Footer;
|
export default Footer;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
StatusMessage,
|
StatusMessage,
|
||||||
type Message,
|
type Message,
|
||||||
} from '@/components/default/StatusMessage';
|
} from '@/components/default/StatusMessage';
|
||||||
export { SubmitButton } from '@/components/default/SubmitButton';
|
export { SubmitButton } from '@/components/default/SubmitButton';
|
||||||
|
@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { useAuth } from '@/components/context';
|
import { useAuth } from '@/components/context';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@ -18,70 +18,70 @@ import { signOut } from '@/lib/actions';
|
|||||||
import { User } from 'lucide-react';
|
import { User } from 'lucide-react';
|
||||||
|
|
||||||
const AvatarDropdown = () => {
|
const AvatarDropdown = () => {
|
||||||
const { profile, avatarUrl, isLoading, refreshUserData } = useAuth();
|
const { profile, avatarUrl, isLoading, refreshUserData } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
const result = await signOut();
|
const result = await signOut();
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
await refreshUserData();
|
await refreshUserData();
|
||||||
router.push('/sign-in');
|
router.push('/sign-in');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getInitials = (name: string | null | undefined): string => {
|
const getInitials = (name: string | null | undefined): string => {
|
||||||
if (!name) return '';
|
if (!name) return '';
|
||||||
return name
|
return name
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((n) => n[0])
|
.map((n) => n[0])
|
||||||
.join('')
|
.join('')
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<Avatar className='cursor-pointer'>
|
<Avatar className='cursor-pointer'>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={getInitials(profile?.full_name)}
|
alt={getInitials(profile?.full_name)}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AvatarFallback className='text-sm'>
|
<AvatarFallback className='text-sm'>
|
||||||
{profile?.full_name ? (
|
{profile?.full_name ? (
|
||||||
getInitials(profile.full_name)
|
getInitials(profile.full_name)
|
||||||
) : (
|
) : (
|
||||||
<User size={32} />
|
<User size={32} />
|
||||||
)}
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuLabel>{profile?.full_name}</DropdownMenuLabel>
|
<DropdownMenuLabel>{profile?.full_name}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link
|
<Link
|
||||||
href='/profile'
|
href='/profile'
|
||||||
className='w-full justify-center cursor-pointer'
|
className='w-full justify-center cursor-pointer'
|
||||||
>
|
>
|
||||||
Edit profile
|
Edit profile
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className='h-[2px]' />
|
<DropdownMenuSeparator className='h-[2px]' />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className='w-full justify-center cursor-pointer'
|
className='w-full justify-center cursor-pointer'
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default AvatarDropdown;
|
export default AvatarDropdown;
|
||||||
|
@ -5,18 +5,18 @@ import AvatarDropdown from './AvatarDropdown';
|
|||||||
import { SignInSignUp } from '@/components/default/auth';
|
import { SignInSignUp } from '@/components/default/auth';
|
||||||
|
|
||||||
const NavigationAuth = async () => {
|
const NavigationAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const profile = await getProfile();
|
const profile = await getProfile();
|
||||||
return profile.success ? (
|
return profile.success ? (
|
||||||
<div className='flex items-center gap-4'>
|
<div className='flex items-center gap-4'>
|
||||||
<AvatarDropdown />
|
<AvatarDropdown />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SignInSignUp />
|
<SignInSignUp />
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error getting profile: ${error as string}`);
|
console.error(`Error getting profile: ${error as string}`);
|
||||||
return <SignInSignUp />;
|
return <SignInSignUp />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export default NavigationAuth;
|
export default NavigationAuth;
|
||||||
|
@ -7,35 +7,42 @@ import { ThemeToggle, TVToggle } from '@/components/context';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
const Navigation = () => {
|
const Navigation = () => {
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className='w-full flex justify-center
|
className='w-full flex justify-center
|
||||||
border-b border-b-foreground/10 h-16'
|
border-b border-b-foreground/10 h-16'
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='w-full max-w-5xl flex justify-between
|
className='w-full max-w-5xl flex justify-between
|
||||||
items-center p-3 px-5 text-sm'
|
items-center p-3 px-5 text-sm'
|
||||||
>
|
>
|
||||||
<div className='flex gap-5 items-center font-semibold'>
|
<div className='flex gap-5 items-center font-semibold'>
|
||||||
<Link className='flex flex-row my-auto gap-2' href='/'>
|
<Link className='flex flex-row my-auto gap-2' href='/'>
|
||||||
<Image src='/favicon.png' alt='T3 Logo' width={50} height={50} />
|
<Image
|
||||||
<h1 className='my-auto text-2xl'>T3 Supabase Template</h1>
|
src='/favicon.png'
|
||||||
</Link>
|
alt='T3 Logo'
|
||||||
<div className='flex items-center gap-2'>
|
width={50}
|
||||||
<Button asChild>
|
height={50}
|
||||||
<Link href='https://git.gbrown.org/gib/T3-Template'>
|
/>
|
||||||
Go to Git Repo
|
<h1 className='my-auto text-2xl'>
|
||||||
</Link>
|
T3 Supabase Template
|
||||||
</Button>
|
</h1>
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
<div className='flex items-center gap-2'>
|
||||||
<div className='flex items-center gap-2'>
|
<Button asChild>
|
||||||
<TVToggle />
|
<Link href='https://git.gbrown.org/gib/T3-Template'>
|
||||||
<ThemeToggle />
|
Go to Git Repo
|
||||||
<NavigationAuth />
|
</Link>
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
);
|
<div className='flex items-center gap-2'>
|
||||||
|
<TVToggle />
|
||||||
|
<ThemeToggle />
|
||||||
|
<NavigationAuth />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
export default Navigation;
|
export default Navigation;
|
||||||
|
@ -1,112 +1,112 @@
|
|||||||
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
||||||
import { useAuth } from '@/components/context';
|
import { useAuth } from '@/components/context';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { Loader2, Pencil, Upload, User } from 'lucide-react';
|
import { Loader2, Pencil, Upload, User } from 'lucide-react';
|
||||||
|
|
||||||
type AvatarUploadProps = {
|
type AvatarUploadProps = {
|
||||||
onAvatarUploaded: (path: string) => Promise<void>;
|
onAvatarUploaded: (path: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
||||||
const { profile, avatarUrl } = useAuth();
|
const { profile, avatarUrl } = useAuth();
|
||||||
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
|
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
|
||||||
|
|
||||||
const handleAvatarClick = () => {
|
const handleAvatarClick = () => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const result = await uploadToStorage({
|
const result = await uploadToStorage({
|
||||||
file,
|
file,
|
||||||
bucket: 'avatars',
|
bucket: 'avatars',
|
||||||
resize: true,
|
resize: true,
|
||||||
options: {
|
options: {
|
||||||
maxWidth: 500,
|
maxWidth: 500,
|
||||||
maxHeight: 500,
|
maxHeight: 500,
|
||||||
quality: 0.8,
|
quality: 0.8,
|
||||||
},
|
},
|
||||||
replace: { replace: true, path: profile?.avatar_url ?? file.name },
|
replace: { replace: true, path: profile?.avatar_url ?? file.name },
|
||||||
});
|
});
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
await onAvatarUploaded(result.data);
|
await onAvatarUploaded(result.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getInitials = (name: string | null | undefined): string => {
|
const getInitials = (name: string | null | undefined): string => {
|
||||||
if (!name) return '';
|
if (!name) return '';
|
||||||
return name
|
return name
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((n) => n[0])
|
.map((n) => n[0])
|
||||||
.join('')
|
.join('')
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className='flex flex-col items-center'>
|
<div className='flex flex-col items-center'>
|
||||||
<div
|
<div
|
||||||
className='relative group cursor-pointer mb-4'
|
className='relative group cursor-pointer mb-4'
|
||||||
onClick={handleAvatarClick}
|
onClick={handleAvatarClick}
|
||||||
>
|
>
|
||||||
<Avatar className='h-32 w-32'>
|
<Avatar className='h-32 w-32'>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={getInitials(profile?.full_name)}
|
alt={getInitials(profile?.full_name)}
|
||||||
width={128}
|
width={128}
|
||||||
height={128}
|
height={128}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AvatarFallback className='text-4xl'>
|
<AvatarFallback className='text-4xl'>
|
||||||
{profile?.full_name ? (
|
{profile?.full_name ? (
|
||||||
getInitials(profile.full_name)
|
getInitials(profile.full_name)
|
||||||
) : (
|
) : (
|
||||||
<User size={32} />
|
<User size={32} />
|
||||||
)}
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div
|
<div
|
||||||
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
|
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
|
||||||
transition-all flex items-center justify-center'
|
transition-all flex items-center justify-center'
|
||||||
>
|
>
|
||||||
<Upload
|
<Upload
|
||||||
className='text-white opacity-0 group-hover:opacity-100
|
className='text-white opacity-0 group-hover:opacity-100
|
||||||
transition-opacity'
|
transition-opacity'
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='absolute inset-1 transition-all flex items-end justify-end'>
|
<div className='absolute inset-1 transition-all flex items-end justify-end'>
|
||||||
<Pencil
|
<Pencil
|
||||||
className='text-white opacity-100 group-hover:opacity-0
|
className='text-white opacity-100 group-hover:opacity-0
|
||||||
transition-opacity'
|
transition-opacity'
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type='file'
|
type='file'
|
||||||
accept='image/*'
|
accept='image/*'
|
||||||
className='hidden'
|
className='hidden'
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
/>
|
/>
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className='flex items-center text-sm text-gray-500 mt-2'>
|
<div className='flex items-center text-sm text-gray-500 mt-2'>
|
||||||
<Loader2 className='h-4 w-4 mr-2 animate-spin' />
|
<Loader2 className='h-4 w-4 mr-2 animate-spin' />
|
||||||
Uploading...
|
Uploading...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -2,99 +2,108 @@ import { z } from 'zod';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import {
|
import {
|
||||||
CardContent,
|
CardContent,
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAuth } from '@/components/context';
|
import { useAuth } from '@/components/context';
|
||||||
import { SubmitButton } from '@/components/default';
|
import { SubmitButton } from '@/components/default';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
full_name: z.string().min(5, {
|
full_name: z.string().min(5, {
|
||||||
message: 'Full name is required & must be at least 5 characters.',
|
message: 'Full name is required & must be at least 5 characters.',
|
||||||
}),
|
}),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ProfileFormProps = {
|
type ProfileFormProps = {
|
||||||
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
|
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
|
export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
|
||||||
const { profile, isLoading } = useAuth();
|
const { profile, isLoading } = useAuth();
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
full_name: profile?.full_name ?? '',
|
full_name: profile?.full_name ?? '',
|
||||||
email: profile?.email ?? '',
|
email: profile?.email ?? '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update form values when profile changes
|
// Update form values when profile changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile) {
|
if (profile) {
|
||||||
form.reset({
|
form.reset({
|
||||||
full_name: profile.full_name ?? '',
|
full_name: profile.full_name ?? '',
|
||||||
email: profile.email ?? '',
|
email: profile.email ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [profile, form]);
|
}, [profile, form]);
|
||||||
|
|
||||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
await onSubmit(values);
|
await onSubmit(values);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
|
<form
|
||||||
<FormField
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
control={form.control}
|
className='space-y-6'
|
||||||
name='full_name'
|
>
|
||||||
render={({ field }) => (
|
<FormField
|
||||||
<FormItem>
|
control={form.control}
|
||||||
<FormLabel>Full Name</FormLabel>
|
name='full_name'
|
||||||
<FormControl>
|
render={({ field }) => (
|
||||||
<Input {...field} />
|
<FormItem>
|
||||||
</FormControl>
|
<FormLabel>Full Name</FormLabel>
|
||||||
<FormDescription>Your public display name.</FormDescription>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input {...field} />
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormDescription>
|
||||||
/>
|
Your public display name.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='email'
|
name='email'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Your email address associated with your account.
|
Your email address associated with your
|
||||||
</FormDescription>
|
account.
|
||||||
<FormMessage />
|
</FormDescription>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className='flex justify-center'>
|
<div className='flex justify-center'>
|
||||||
<SubmitButton disabled={isLoading} pendingText='Saving...'>
|
<SubmitButton
|
||||||
Save Changes
|
disabled={isLoading}
|
||||||
</SubmitButton>
|
pendingText='Saving...'
|
||||||
</div>
|
>
|
||||||
</form>
|
Save Changes
|
||||||
</Form>
|
</SubmitButton>
|
||||||
</CardContent>
|
</div>
|
||||||
);
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -2,18 +2,18 @@ import { z } from 'zod';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import {
|
import {
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { SubmitButton } from '@/components/default';
|
import { SubmitButton } from '@/components/default';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@ -21,127 +21,135 @@ import { type Result } from '@/lib/actions';
|
|||||||
import { StatusMessage } from '@/components/default';
|
import { StatusMessage } from '@/components/default';
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
password: z.string().min(8, {
|
password: z.string().min(8, {
|
||||||
message: 'Password must be at least 8 characters.',
|
message: 'Password must be at least 8 characters.',
|
||||||
}),
|
}),
|
||||||
confirmPassword: z.string(),
|
confirmPassword: z.string(),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: 'Passwords do not match.',
|
message: 'Passwords do not match.',
|
||||||
path: ['confirmPassword'],
|
path: ['confirmPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type ResetPasswordFormProps = {
|
type ResetPasswordFormProps = {
|
||||||
onSubmit: (formData: FormData) => Promise<Result<null>>;
|
onSubmit: (formData: FormData) => Promise<Result<null>>;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ResetPasswordForm = ({
|
export const ResetPasswordForm = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
message,
|
message,
|
||||||
}: ResetPasswordFormProps) => {
|
}: ResetPasswordFormProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [statusMessage, setStatusMessage] = useState(message ?? '');
|
const [statusMessage, setStatusMessage] = useState(message ?? '');
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleUpdatePassword = async (values: z.infer<typeof formSchema>) => {
|
const handleUpdatePassword = async (values: z.infer<typeof formSchema>) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// Convert form values to FormData for your server action
|
// Convert form values to FormData for your server action
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('password', values.password);
|
formData.append('password', values.password);
|
||||||
formData.append('confirmPassword', values.confirmPassword);
|
formData.append('confirmPassword', values.confirmPassword);
|
||||||
|
|
||||||
const result = await onSubmit(formData);
|
const result = await onSubmit(formData);
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
setStatusMessage('Password updated successfully!');
|
setStatusMessage('Password updated successfully!');
|
||||||
form.reset(); // Clear the form on success
|
form.reset(); // Clear the form on success
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage('Error: Unable to update password!');
|
setStatusMessage('Error: Unable to update password!');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
error instanceof Error ? error.message : 'Password was not updated!',
|
error instanceof Error
|
||||||
);
|
? error.message
|
||||||
} finally {
|
: 'Password was not updated!',
|
||||||
setIsLoading(false);
|
);
|
||||||
}
|
} finally {
|
||||||
};
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<CardHeader className='pb-5'>
|
<CardHeader className='pb-5'>
|
||||||
<CardTitle className='text-2xl'>Change Password</CardTitle>
|
<CardTitle className='text-2xl'>Change Password</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Update your password to keep your account secure
|
Update your password to keep your account secure
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(handleUpdatePassword)}
|
onSubmit={form.handleSubmit(handleUpdatePassword)}
|
||||||
className='space-y-6'
|
className='space-y-6'
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='password'
|
name='password'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>New Password</FormLabel>
|
<FormLabel>New Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type='password' {...field} />
|
<Input type='password' {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Enter your new password. Must be at least 8 characters.
|
Enter your new password. Must be at
|
||||||
</FormDescription>
|
least 8 characters.
|
||||||
<FormMessage />
|
</FormDescription>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
<FormField
|
||||||
name='confirmPassword'
|
control={form.control}
|
||||||
render={({ field }) => (
|
name='confirmPassword'
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Confirm Password</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Confirm Password</FormLabel>
|
||||||
<Input type='password' {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<Input type='password' {...field} />
|
||||||
<FormDescription>
|
</FormControl>
|
||||||
Please re-enter your new password to confirm.
|
<FormDescription>
|
||||||
</FormDescription>
|
Please re-enter your new password to
|
||||||
<FormMessage />
|
confirm.
|
||||||
</FormItem>
|
</FormDescription>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
{statusMessage &&
|
)}
|
||||||
(statusMessage.includes('Error') ||
|
/>
|
||||||
statusMessage.includes('error') ||
|
{statusMessage &&
|
||||||
statusMessage.includes('failed') ||
|
(statusMessage.includes('Error') ||
|
||||||
statusMessage.includes('invalid') ? (
|
statusMessage.includes('error') ||
|
||||||
<StatusMessage message={{ error: statusMessage }} />
|
statusMessage.includes('failed') ||
|
||||||
) : (
|
statusMessage.includes('invalid') ? (
|
||||||
<StatusMessage message={{ message: statusMessage }} />
|
<StatusMessage
|
||||||
))}
|
message={{ error: statusMessage }}
|
||||||
<div className='flex justify-center'>
|
/>
|
||||||
<SubmitButton
|
) : (
|
||||||
disabled={isLoading}
|
<StatusMessage
|
||||||
pendingText='Updating Password...'
|
message={{ message: statusMessage }}
|
||||||
>
|
/>
|
||||||
Update Password
|
))}
|
||||||
</SubmitButton>
|
<div className='flex justify-center'>
|
||||||
</div>
|
<SubmitButton
|
||||||
</form>
|
disabled={isLoading}
|
||||||
</Form>
|
pendingText='Updating Password...'
|
||||||
</CardContent>
|
>
|
||||||
</div>
|
Update Password
|
||||||
);
|
</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -7,28 +7,28 @@ import { useAuth } from '@/components/context';
|
|||||||
import { signOut } from '@/lib/actions';
|
import { signOut } from '@/lib/actions';
|
||||||
|
|
||||||
export const SignOut = () => {
|
export const SignOut = () => {
|
||||||
const { isLoading, refreshUserData } = useAuth();
|
const { isLoading, refreshUserData } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
const result = await signOut();
|
const result = await signOut();
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
await refreshUserData();
|
await refreshUserData();
|
||||||
router.push('/sign-in');
|
router.push('/sign-in');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className='flex justify-center'>
|
<div className='flex justify-center'>
|
||||||
<CardHeader className='md:w-5/6 w-full'>
|
<CardHeader className='md:w-5/6 w-full'>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
className='text-[1.0rem] font-semibold cursor-pointer
|
className='text-[1.0rem] font-semibold cursor-pointer
|
||||||
hover:bg-red-700/60 dark:hover:bg-red-300/80'
|
hover:bg-red-700/60 dark:hover:bg-red-300/80'
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,124 +3,132 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Separator,
|
Separator,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { CheckCircle, MessageCircleWarning } from 'lucide-react';
|
import { CheckCircle, MessageCircleWarning } from 'lucide-react';
|
||||||
|
|
||||||
class SentryExampleFrontendError extends Error {
|
class SentryExampleFrontendError extends Error {
|
||||||
constructor(message: string | undefined) {
|
constructor(message: string | undefined) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'SentryExampleFrontendError';
|
this.name = 'SentryExampleFrontendError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TestSentryCard = () => {
|
export const TestSentryCard = () => {
|
||||||
const [hasSentError, setHasSentError] = useState(false);
|
const [hasSentError, setHasSentError] = useState(false);
|
||||||
const [isConnected, setIsConnected] = useState(true);
|
const [isConnected, setIsConnected] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkConnectivity = async () => {
|
const checkConnectivity = async () => {
|
||||||
console.log('Checking Sentry SDK connectivity...');
|
console.log('Checking Sentry SDK connectivity...');
|
||||||
const result = await Sentry.diagnoseSdkConnectivity();
|
const result = await Sentry.diagnoseSdkConnectivity();
|
||||||
setIsConnected(result !== 'sentry-unreachable');
|
setIsConnected(result !== 'sentry-unreachable');
|
||||||
};
|
};
|
||||||
checkConnectivity().catch((error) => {
|
checkConnectivity().catch((error) => {
|
||||||
console.error('Error trying to connect to Sentry: ', error);
|
console.error('Error trying to connect to Sentry: ', error);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createError = async () => {
|
const createError = async () => {
|
||||||
await Sentry.startSpan(
|
await Sentry.startSpan(
|
||||||
{
|
{
|
||||||
name: 'Example Frontend Span',
|
name: 'Example Frontend Span',
|
||||||
op: 'test',
|
op: 'test',
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const res = await fetch('/api/sentry/example');
|
const res = await fetch('/api/sentry/example');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setHasSentError(true);
|
setHasSentError(true);
|
||||||
throw new SentryExampleFrontendError(
|
throw new SentryExampleFrontendError(
|
||||||
'This error is raised in our TestSentry component on the main page.',
|
'This error is raised in our TestSentry component on the main page.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className='flex flex-row my-auto space-x-4'>
|
<div className='flex flex-row my-auto space-x-4'>
|
||||||
<svg
|
<svg
|
||||||
height='40'
|
height='40'
|
||||||
width='40'
|
width='40'
|
||||||
fill='none'
|
fill='none'
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
>
|
>
|
||||||
<path
|
<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'
|
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'
|
fill='currentcolor'
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<CardTitle className='text-3xl my-auto'>Test Sentry</CardTitle>
|
<CardTitle className='text-3xl my-auto'>
|
||||||
</div>
|
Test Sentry
|
||||||
<CardDescription className='text-[1.0rem]'>
|
</CardTitle>
|
||||||
Click the button below & view the sample error on{' '}
|
</div>
|
||||||
<Link
|
<CardDescription className='text-[1.0rem]'>
|
||||||
href={`${process.env.NEXT_PUBLIC_SENTRY_URL}`}
|
Click the button below & view the sample error on{' '}
|
||||||
className='text-accent-foreground underline hover:text-primary'
|
<Link
|
||||||
>
|
href={`${process.env.NEXT_PUBLIC_SENTRY_URL}`}
|
||||||
the Sentry website
|
className='text-accent-foreground underline hover:text-primary'
|
||||||
</Link>
|
>
|
||||||
. Navigate to the {"'"}Issues{"'"} page & you should see the sample
|
the Sentry website
|
||||||
error!
|
</Link>
|
||||||
</CardDescription>
|
. Navigate to the {"'"}Issues{"'"} page & you should see the
|
||||||
</CardHeader>
|
sample error!
|
||||||
<CardContent>
|
</CardDescription>
|
||||||
<div className='flex flex-row gap-4 my-auto'>
|
</CardHeader>
|
||||||
<Button
|
<CardContent>
|
||||||
type='button'
|
<div className='flex flex-row gap-4 my-auto'>
|
||||||
onClick={createError}
|
<Button
|
||||||
className='cursor-pointer text-md my-auto py-6'
|
type='button'
|
||||||
>
|
onClick={createError}
|
||||||
<span>Throw Sample Error</span>
|
className='cursor-pointer text-md my-auto py-6'
|
||||||
</Button>
|
>
|
||||||
{hasSentError ? (
|
<span>Throw Sample Error</span>
|
||||||
<div className='rounded-md bg-green-500/80 dark:bg-green-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
|
</Button>
|
||||||
<CheckCircle size={30} className='my-auto' />
|
{hasSentError ? (
|
||||||
<p className='text-lg'>Sample error was sent to Sentry!</p>
|
<div className='rounded-md bg-green-500/80 dark:bg-green-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
|
||||||
</div>
|
<CheckCircle size={30} className='my-auto' />
|
||||||
) : !isConnected ? (
|
<p className='text-lg'>
|
||||||
<div className='rounded-md bg-red-600/50 dark:bg-red-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
|
Sample error was sent to Sentry!
|
||||||
<MessageCircleWarning size={40} className='my-auto' />
|
</p>
|
||||||
<p>
|
</div>
|
||||||
Wait! The Sentry SDK is not able to reach Sentry right now -
|
) : !isConnected ? (
|
||||||
this may be due to an adblocker. For more information, see{' '}
|
<div className='rounded-md bg-red-600/50 dark:bg-red-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
|
||||||
<Link
|
<MessageCircleWarning
|
||||||
href='https://docs.sentry.io/platforms/javascript/guides/nextjs/troubleshooting/#the-sdk-is-not-sending-any-data'
|
size={40}
|
||||||
className='text-accent-foreground underline hover:text-primary'
|
className='my-auto'
|
||||||
>
|
/>
|
||||||
the troubleshooting guide.
|
<p>
|
||||||
</Link>
|
Wait! The Sentry SDK is not able to reach Sentry
|
||||||
</p>
|
right now - this may be due to an adblocker. For
|
||||||
</div>
|
more information, see{' '}
|
||||||
) : (
|
<Link
|
||||||
<div className='success_placeholder' />
|
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'
|
||||||
</div>
|
>
|
||||||
<Separator className='my-4 bg-accent' />
|
the troubleshooting guide.
|
||||||
<p className='description'>
|
</Link>
|
||||||
Warning! Sometimes Adblockers will prevent errors from being sent to
|
</p>
|
||||||
Sentry.
|
</div>
|
||||||
</p>
|
) : (
|
||||||
</CardContent>
|
<div className='success_placeholder' />
|
||||||
</Card>
|
)}
|
||||||
);
|
</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';
|
import { Button } from '@/components/ui';
|
||||||
|
|
||||||
const CopyIcon = () => (
|
const CopyIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
width='20'
|
width='20'
|
||||||
height='20'
|
height='20'
|
||||||
viewBox='0 0 24 24'
|
viewBox='0 0 24 24'
|
||||||
fill='none'
|
fill='none'
|
||||||
stroke='currentColor'
|
stroke='currentColor'
|
||||||
strokeWidth='2'
|
strokeWidth='2'
|
||||||
strokeLinecap='round'
|
strokeLinecap='round'
|
||||||
strokeLinejoin='round'
|
strokeLinejoin='round'
|
||||||
>
|
>
|
||||||
<rect x='9' y='9' width='13' height='13' rx='2' ry='2'></rect>
|
<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>
|
<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>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
width='20'
|
width='20'
|
||||||
height='20'
|
height='20'
|
||||||
viewBox='0 0 24 24'
|
viewBox='0 0 24 24'
|
||||||
fill='none'
|
fill='none'
|
||||||
stroke='currentColor'
|
stroke='currentColor'
|
||||||
strokeWidth='2'
|
strokeWidth='2'
|
||||||
strokeLinecap='round'
|
strokeLinecap='round'
|
||||||
strokeLinejoin='round'
|
strokeLinejoin='round'
|
||||||
>
|
>
|
||||||
<polyline points='20 6 9 17 4 12'></polyline>
|
<polyline points='20 6 9 17 4 12'></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function CodeBlock({ code }: { code: string }) {
|
export function CodeBlock({ code }: { code: string }) {
|
||||||
const [icon, setIcon] = useState(CopyIcon);
|
const [icon, setIcon] = useState(CopyIcon);
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
await navigator?.clipboard?.writeText(code);
|
await navigator?.clipboard?.writeText(code);
|
||||||
setIcon(CheckIcon);
|
setIcon(CheckIcon);
|
||||||
setTimeout(() => setIcon(CopyIcon), 2000);
|
setTimeout(() => setIcon(CopyIcon), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<pre className='bg-muted rounded-md p-6 my-6 relative'>
|
<pre className='bg-muted rounded-md p-6 my-6 relative'>
|
||||||
<Button
|
<Button
|
||||||
size='icon'
|
size='icon'
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
variant={'outline'}
|
variant={'outline'}
|
||||||
className='absolute right-2 top-2'
|
className='absolute right-2 top-2'
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</Button>
|
</Button>
|
||||||
<code className='text-xs p-3'>{code}</code>
|
<code className='text-xs p-3'>{code}</code>
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -44,52 +44,52 @@ export default function Page() {
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
export const FetchDataSteps = () => {
|
export const FetchDataSteps = () => {
|
||||||
return (
|
return (
|
||||||
<ol className='flex flex-col gap-6'>
|
<ol className='flex flex-col gap-6'>
|
||||||
<TutorialStep title='Create some tables and insert some data'>
|
<TutorialStep title='Create some tables and insert some data'>
|
||||||
<p>
|
<p>
|
||||||
Head over to the{' '}
|
Head over to the{' '}
|
||||||
<a
|
<a
|
||||||
href='https://supabase.com/dashboard/project/_/editor'
|
href='https://supabase.com/dashboard/project/_/editor'
|
||||||
className='font-bold hover:underline text-foreground/80'
|
className='font-bold hover:underline text-foreground/80'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer'
|
rel='noreferrer'
|
||||||
>
|
>
|
||||||
Table Editor
|
Table Editor
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
for your Supabase project to create a table and insert some example
|
for your Supabase project to create a table and insert some
|
||||||
data. If you're stuck for creativity, you can copy and paste the
|
example data. If you're stuck for creativity, you can
|
||||||
following into the{' '}
|
copy and paste the following into the{' '}
|
||||||
<a
|
<a
|
||||||
href='https://supabase.com/dashboard/project/_/sql/new'
|
href='https://supabase.com/dashboard/project/_/sql/new'
|
||||||
className='font-bold hover:underline text-foreground/80'
|
className='font-bold hover:underline text-foreground/80'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer'
|
rel='noreferrer'
|
||||||
>
|
>
|
||||||
SQL Editor
|
SQL Editor
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
and click RUN!
|
and click RUN!
|
||||||
</p>
|
</p>
|
||||||
<CodeBlock code={create} />
|
<CodeBlock code={create} />
|
||||||
</TutorialStep>
|
</TutorialStep>
|
||||||
|
|
||||||
<TutorialStep title='Query Supabase data from Next.js'>
|
<TutorialStep title='Query Supabase data from Next.js'>
|
||||||
<p>
|
<p>
|
||||||
To create a Supabase client and query data from an Async Server
|
To create a Supabase client and query data from an Async
|
||||||
Component, create a new page.tsx file at{' '}
|
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'>
|
<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
|
/app/notes/page.tsx
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
and add the following.
|
and add the following.
|
||||||
</p>
|
</p>
|
||||||
<CodeBlock code={server} />
|
<CodeBlock code={server} />
|
||||||
<p>Alternatively, you can use a Client Component.</p>
|
<p>Alternatively, you can use a Client Component.</p>
|
||||||
<CodeBlock code={client} />
|
<CodeBlock code={client} />
|
||||||
</TutorialStep>
|
</TutorialStep>
|
||||||
|
|
||||||
<TutorialStep title='Build in a weekend and scale to millions!'>
|
<TutorialStep title='Build in a weekend and scale to millions!'>
|
||||||
<p>You're ready to launch your product to the world! 🚀</p>
|
<p>You're ready to launch your product to the world! 🚀</p>
|
||||||
</TutorialStep>
|
</TutorialStep>
|
||||||
</ol>
|
</ol>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,30 +1,30 @@
|
|||||||
import { Checkbox } from '@/components/ui';
|
import { Checkbox } from '@/components/ui';
|
||||||
|
|
||||||
export const TutorialStep = ({
|
export const TutorialStep = ({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<li className='relative'>
|
<li className='relative'>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={title}
|
id={title}
|
||||||
name={title}
|
name={title}
|
||||||
className={`absolute top-[3px] mr-2 peer`}
|
className={`absolute top-[3px] mr-2 peer`}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={title}
|
htmlFor={title}
|
||||||
className={`relative text-base text-foreground peer-checked:line-through font-medium`}
|
className={`relative text-base text-foreground peer-checked:line-through font-medium`}
|
||||||
>
|
>
|
||||||
<span className='ml-8'>{title}</span>
|
<span className='ml-8'>{title}</span>
|
||||||
<div
|
<div
|
||||||
className={`ml-8 text-sm peer-checked:line-through font-normal text-muted-foreground`}
|
className={`ml-8 text-sm peer-checked:line-through font-normal text-muted-foreground`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -6,48 +6,48 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Avatar({
|
function Avatar({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
data-slot='avatar'
|
data-slot='avatar'
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarImage({
|
function AvatarImage({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
data-slot='avatar-image'
|
data-slot='avatar-image'
|
||||||
className={cn('aspect-square size-full', className)}
|
className={cn('aspect-square size-full', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarFallback({
|
function AvatarFallback({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
data-slot='avatar-fallback'
|
data-slot='avatar-fallback'
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback };
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
|
@ -5,42 +5,42 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||||
secondary:
|
secondary:
|
||||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||||
destructive:
|
destructive:
|
||||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
outline:
|
outline:
|
||||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: 'default',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'span'> &
|
}: React.ComponentProps<'span'> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : 'span';
|
const Comp = asChild ? Slot : 'span';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot='badge'
|
data-slot='badge'
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants };
|
||||||
|
@ -5,58 +5,57 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
outline:
|
outline:
|
||||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||||
ghost:
|
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
},
|
||||||
},
|
size: {
|
||||||
size: {
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
xl: 'h-12 rounded-md px-8 has-[>svg]:px-6',
|
||||||
xl: 'h-12 rounded-md px-8 has-[>svg]:px-6',
|
xxl: 'h-14 rounded-md px-10 has-[>svg]:px-8',
|
||||||
xxl: 'h-14 rounded-md px-10 has-[>svg]:px-8',
|
icon: 'size-9',
|
||||||
icon: 'size-9',
|
smicon: 'size-6',
|
||||||
smicon: 'size-6',
|
},
|
||||||
},
|
},
|
||||||
},
|
defaultVariants: {
|
||||||
defaultVariants: {
|
variant: 'default',
|
||||||
variant: 'default',
|
size: 'default',
|
||||||
size: 'default',
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'button'> &
|
}: React.ComponentProps<'button'> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : 'button';
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot='button'
|
data-slot='button'
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants };
|
||||||
|
@ -3,90 +3,90 @@ import * as React from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot='card'
|
data-slot='card'
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot='card-header'
|
data-slot='card-header'
|
||||||
className={cn(
|
className={cn(
|
||||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot='card-title'
|
data-slot='card-title'
|
||||||
className={cn('leading-none font-semibold', className)}
|
className={cn('leading-none font-semibold', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot='card-description'
|
data-slot='card-description'
|
||||||
className={cn('text-muted-foreground text-sm', className)}
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot='card-action'
|
data-slot='card-action'
|
||||||
className={cn(
|
className={cn(
|
||||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot='card-content'
|
data-slot='card-content'
|
||||||
className={cn('px-6', className)}
|
className={cn('px-6', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot='card-footer'
|
data-slot='card-footer'
|
||||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
};
|
};
|
||||||
|
@ -7,26 +7,26 @@ import { CheckIcon } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Checkbox({
|
function Checkbox({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot='checkbox'
|
data-slot='checkbox'
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
data-slot='checkbox-indicator'
|
data-slot='checkbox-indicator'
|
||||||
className='flex items-center justify-center text-current transition-none'
|
className='flex items-center justify-center text-current transition-none'
|
||||||
>
|
>
|
||||||
<CheckIcon className='size-3.5' />
|
<CheckIcon className='size-3.5' />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Checkbox };
|
export { Checkbox };
|
||||||
|
@ -7,251 +7,259 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
|
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
|
<DropdownMenuPrimitive.Portal
|
||||||
);
|
data-slot='dropdown-menu-portal'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
function DropdownMenuTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Trigger
|
<DropdownMenuPrimitive.Trigger
|
||||||
data-slot='dropdown-menu-trigger'
|
data-slot='dropdown-menu-trigger'
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
className,
|
className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
data-slot='dropdown-menu-content'
|
data-slot='dropdown-menu-content'
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
|
<DropdownMenuPrimitive.Group
|
||||||
);
|
data-slot='dropdown-menu-group'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
variant?: 'default' | 'destructive';
|
variant?: 'default' | 'destructive';
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
data-slot='dropdown-menu-item'
|
data-slot='dropdown-menu-item'
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
checked,
|
checked,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot='dropdown-menu-checkbox-item'
|
data-slot='dropdown-menu-checkbox-item'
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CheckIcon className='size-4' />
|
<CheckIcon className='size-4' />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
data-slot='dropdown-menu-radio-group'
|
data-slot='dropdown-menu-radio-group'
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot='dropdown-menu-radio-item'
|
data-slot='dropdown-menu-radio-item'
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CircleIcon className='size-2 fill-current' />
|
<CircleIcon className='size-2 fill-current' />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
data-slot='dropdown-menu-label'
|
data-slot='dropdown-menu-label'
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot='dropdown-menu-separator'
|
data-slot='dropdown-menu-separator'
|
||||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'span'>) {
|
}: React.ComponentProps<'span'>) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot='dropdown-menu-shortcut'
|
data-slot='dropdown-menu-shortcut'
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({
|
function DropdownMenuSub({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
|
return (
|
||||||
|
<DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
function DropdownMenuSubTrigger({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
data-slot='dropdown-menu-sub-trigger'
|
data-slot='dropdown-menu-sub-trigger'
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className='ml-auto size-4' />
|
<ChevronRightIcon className='ml-auto size-4' />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot='dropdown-menu-sub-content'
|
data-slot='dropdown-menu-sub-content'
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
};
|
};
|
||||||
|
@ -4,13 +4,13 @@ import * as React from 'react';
|
|||||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
FormProvider,
|
FormProvider,
|
||||||
useFormContext,
|
useFormContext,
|
||||||
useFormState,
|
useFormState,
|
||||||
type ControllerProps,
|
type ControllerProps,
|
||||||
type FieldPath,
|
type FieldPath,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
} from 'react-hook-form';
|
} from 'react-hook-form';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -19,150 +19,150 @@ import { Label } from '@/components/ui/label';
|
|||||||
const Form = FormProvider;
|
const Form = FormProvider;
|
||||||
|
|
||||||
type FormFieldContextValue<
|
type FormFieldContextValue<
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
> = {
|
> = {
|
||||||
name: TName;
|
name: TName;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
{} as FormFieldContextValue,
|
{} as FormFieldContextValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
const FormField = <
|
const FormField = <
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
>({
|
>({
|
||||||
...props
|
...props
|
||||||
}: ControllerProps<TFieldValues, TName>) => {
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
return (
|
return (
|
||||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
<Controller {...props} />
|
<Controller {...props} />
|
||||||
</FormFieldContext.Provider>
|
</FormFieldContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useFormField = () => {
|
const useFormField = () => {
|
||||||
const fieldContext = React.useContext(FormFieldContext);
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
const itemContext = React.useContext(FormItemContext);
|
const itemContext = React.useContext(FormItemContext);
|
||||||
const { getFieldState } = useFormContext();
|
const { getFieldState } = useFormContext();
|
||||||
const formState = useFormState({ name: fieldContext.name });
|
const formState = useFormState({ name: fieldContext.name });
|
||||||
const fieldState = getFieldState(fieldContext.name, formState);
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
if (!fieldContext) {
|
if (!fieldContext) {
|
||||||
throw new Error('useFormField should be used within <FormField>');
|
throw new Error('useFormField should be used within <FormField>');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = itemContext;
|
const { id } = itemContext;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: fieldContext.name,
|
name: fieldContext.name,
|
||||||
formItemId: `${id}-form-item`,
|
formItemId: `${id}-form-item`,
|
||||||
formDescriptionId: `${id}-form-item-description`,
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
formMessageId: `${id}-form-item-message`,
|
formMessageId: `${id}-form-item-message`,
|
||||||
...fieldState,
|
...fieldState,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormItemContextValue = {
|
type FormItemContextValue = {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
{} as FormItemContextValue,
|
{} as FormItemContextValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItemContext.Provider value={{ id }}>
|
<FormItemContext.Provider value={{ id }}>
|
||||||
<div
|
<div
|
||||||
data-slot='form-item'
|
data-slot='form-item'
|
||||||
className={cn('grid gap-2', className)}
|
className={cn('grid gap-2', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</FormItemContext.Provider>
|
</FormItemContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormLabel({
|
function FormLabel({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
const { error, formItemId } = useFormField();
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
data-slot='form-label'
|
data-slot='form-label'
|
||||||
data-error={!!error}
|
data-error={!!error}
|
||||||
className={cn('data-[error=true]:text-destructive', className)}
|
className={cn('data-[error=true]:text-destructive', className)}
|
||||||
htmlFor={formItemId}
|
htmlFor={formItemId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
useFormField();
|
useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slot
|
<Slot
|
||||||
data-slot='form-control'
|
data-slot='form-control'
|
||||||
id={formItemId}
|
id={formItemId}
|
||||||
aria-describedby={
|
aria-describedby={
|
||||||
!error
|
!error
|
||||||
? `${formDescriptionId}`
|
? `${formDescriptionId}`
|
||||||
: `${formDescriptionId} ${formMessageId}`
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
}
|
}
|
||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
const { formDescriptionId } = useFormField();
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
data-slot='form-description'
|
data-slot='form-description'
|
||||||
id={formDescriptionId}
|
id={formDescriptionId}
|
||||||
className={cn('text-muted-foreground text-sm', className)}
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
const { error, formMessageId } = useFormField();
|
const { error, formMessageId } = useFormField();
|
||||||
const body = error ? String(error?.message ?? '') : props.children;
|
const body = error ? String(error?.message ?? '') : props.children;
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
data-slot='form-message'
|
data-slot='form-message'
|
||||||
id={formMessageId}
|
id={formMessageId}
|
||||||
className={cn('text-destructive text-sm', className)}
|
className={cn('text-destructive text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
useFormField,
|
||||||
Form,
|
Form,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
FormField,
|
FormField,
|
||||||
};
|
};
|
||||||
|
@ -3,19 +3,19 @@ import * as React from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
data-slot='input'
|
data-slot='input'
|
||||||
className={cn(
|
className={cn(
|
||||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input };
|
export { Input };
|
||||||
|
@ -6,19 +6,19 @@ import * as LabelPrimitive from '@radix-ui/react-label';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Label({
|
function Label({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
data-slot='label'
|
data-slot='label'
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Label };
|
export { Label };
|
||||||
|
@ -6,23 +6,23 @@ import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
orientation = 'horizontal',
|
orientation = 'horizontal',
|
||||||
decorative = true,
|
decorative = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<SeparatorPrimitive.Root
|
<SeparatorPrimitive.Root
|
||||||
data-slot='separator-root'
|
data-slot='separator-root'
|
||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator };
|
export { Separator };
|
||||||
|
@ -4,22 +4,22 @@ import { useTheme } from 'next-themes';
|
|||||||
import { Toaster as Sonner, ToasterProps } from 'sonner';
|
import { Toaster as Sonner, ToasterProps } from 'sonner';
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = 'system' } = useTheme();
|
const { theme = 'system' } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps['theme']}
|
theme={theme as ToasterProps['theme']}
|
||||||
className='toaster group'
|
className='toaster group'
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--normal-bg': 'var(--popover)',
|
'--normal-bg': 'var(--popover)',
|
||||||
'--normal-text': 'var(--popover-foreground)',
|
'--normal-text': 'var(--popover-foreground)',
|
||||||
'--normal-border': 'var(--border)',
|
'--normal-border': 'var(--border)',
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Toaster };
|
export { Toaster };
|
||||||
|
101
src/env.js
101
src/env.js
@ -2,57 +2,58 @@ import { createEnv } from '@t3-oss/env-nextjs';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
/**
|
/**
|
||||||
* Specify your server-side environment variables schema here.
|
* Specify your server-side environment variables schema here.
|
||||||
* This way you can ensure the app isn't built with invalid env vars.
|
* This way you can ensure the app isn't built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(['development', 'test', 'production'])
|
.enum(['development', 'test', 'production'])
|
||||||
.default('development'),
|
.default('development'),
|
||||||
SENTRY_AUTH_TOKEN: z.string().min(1),
|
SENTRY_AUTH_TOKEN: z.string().min(1),
|
||||||
CI: z.enum(['true', 'false']).default('false'),
|
CI: z.enum(['true', 'false']).default('false'),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specify your client-side environment variables schema here.
|
* Specify your client-side environment variables schema here.
|
||||||
* This way you can ensure the app isn't built with invalid env vars.
|
* This way you can ensure the app isn't built with invalid env vars.
|
||||||
* To expose them to the client, prefix them with `NEXT_PUBLIC_`.
|
* To expose them to the client, prefix them with `NEXT_PUBLIC_`.
|
||||||
*/
|
*/
|
||||||
client: {
|
client: {
|
||||||
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
|
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
|
||||||
NEXT_PUBLIC_SITE_URL: z.string().url().default('http://localhost:3000'),
|
NEXT_PUBLIC_SITE_URL: z.string().url().default('http://localhost:3000'),
|
||||||
NEXT_PUBLIC_SENTRY_DSN: z.string().min(1),
|
NEXT_PUBLIC_SENTRY_DSN: z.string().min(1),
|
||||||
NEXT_PUBLIC_SENTRY_URL: z
|
NEXT_PUBLIC_SENTRY_URL: z
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.default('https://sentry.gbrown.org'),
|
.default('https://sentry.gbrown.org'),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||||
* middlewares) or client-side so we need to destruct manually.
|
* middlewares) or client-side so we need to destruct manually.
|
||||||
*/
|
*/
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
||||||
CI: process.env.CI,
|
CI: process.env.CI,
|
||||||
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
NEXT_PUBLIC_SUPABASE_ANON_KEY:
|
||||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||||
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_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.
|
* 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,
|
*/
|
||||||
/**
|
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.
|
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
|
||||||
*/
|
* `SOME_VAR=''` will throw an error.
|
||||||
emptyStringAsUndefined: true,
|
*/
|
||||||
|
emptyStringAsUndefined: true,
|
||||||
});
|
});
|
||||||
|
@ -4,31 +4,31 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
Sentry.init({
|
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:
|
// Adds request headers and IP for users, for more info visit:
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
||||||
sendDefaultPii: true,
|
sendDefaultPii: true,
|
||||||
|
|
||||||
// Set tracesSampleRate to 1.0 to capture 100%
|
// Set tracesSampleRate to 1.0 to capture 100%
|
||||||
// of transactions for tracing.
|
// of transactions for tracing.
|
||||||
// We recommend adjusting this value in production
|
// We recommend adjusting this value in production
|
||||||
// Learn more at
|
// Learn more at
|
||||||
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
|
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 1.0,
|
||||||
// Replay may only be enabled for the client-side
|
// Replay may only be enabled for the client-side
|
||||||
integrations: [Sentry.replayIntegration()],
|
integrations: [Sentry.replayIntegration()],
|
||||||
|
|
||||||
// Capture Replay for 10% of all sessions,
|
// Capture Replay for 10% of all sessions,
|
||||||
// plus for 100% of sessions with an error
|
// plus for 100% of sessions with an error
|
||||||
// Learn more at
|
// Learn more at
|
||||||
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
||||||
replaysSessionSampleRate: 0.1,
|
replaysSessionSampleRate: 0.1,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
|
||||||
// Note: if you want to override the automatic release value, do not set a
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
// that it will also get attached to your source maps
|
// that it will also get attached to your source maps
|
||||||
});
|
});
|
||||||
|
|
||||||
// This export will instrument router navigations, and is only relevant if you enable tracing.
|
// 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';
|
import type { Instrumentation } from 'next';
|
||||||
|
|
||||||
export const register = async () => {
|
export const register = async () => {
|
||||||
await import('../sentry.server.config');
|
await import('../sentry.server.config');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||||
Sentry.captureRequestError(...args);
|
Sentry.captureRequestError(...args);
|
||||||
};
|
};
|
||||||
|
@ -7,149 +7,149 @@ import type { User } from '@/utils/supabase';
|
|||||||
import type { Result } from '.';
|
import type { Result } from '.';
|
||||||
|
|
||||||
export const signUp = async (
|
export const signUp = async (
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<Result<string | null>> => {
|
): Promise<Result<string | null>> => {
|
||||||
const name = formData.get('name') as string;
|
const name = formData.get('name') as string;
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const origin = (await headers()).get('origin');
|
const origin = (await headers()).get('origin');
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return { success: false, error: 'Email and password are required' };
|
return { success: false, error: 'Email and password are required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase.auth.signUp({
|
const { error } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
options: {
|
options: {
|
||||||
emailRedirectTo: `${origin}/auth/callback`,
|
emailRedirectTo: `${origin}/auth/callback`,
|
||||||
data: {
|
data: {
|
||||||
full_name: name,
|
full_name: name,
|
||||||
email,
|
email,
|
||||||
provider: 'email',
|
provider: 'email',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: 'Thanks for signing up! Please check your email for a verification link.',
|
data: 'Thanks for signing up! Please check your email for a verification link.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signIn = async (formData: FormData): Promise<Result<null>> => {
|
export const signIn = async (formData: FormData): Promise<Result<null>> => {
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
|
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
} else {
|
} else {
|
||||||
return { success: true, data: null };
|
return { success: true, data: null };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
|
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const origin = (await headers()).get('origin');
|
const origin = (await headers()).get('origin');
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'azure',
|
provider: 'azure',
|
||||||
options: {
|
options: {
|
||||||
scopes: 'openid, profile email offline_access',
|
scopes: 'openid, profile email offline_access',
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) return { success: false, error: error.message };
|
if (error) return { success: false, error: error.message };
|
||||||
return { success: true, data: data.url };
|
return { success: true, data: data.url };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signInWithApple = async (): Promise<Result<string>> => {
|
export const signInWithApple = async (): Promise<Result<string>> => {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const origin = process.env.BASE_URL!;
|
const origin = process.env.BASE_URL!;
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'apple',
|
provider: 'apple',
|
||||||
options: {
|
options: {
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) return { success: false, error: error.message };
|
if (error) return { success: false, error: error.message };
|
||||||
return { success: true, data: data.url };
|
return { success: true, data: data.url };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const forgotPassword = async (
|
export const forgotPassword = async (
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<Result<string | null>> => {
|
): Promise<Result<string | null>> => {
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const origin = (await headers()).get('origin');
|
const origin = (await headers()).get('origin');
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return { success: false, error: 'Email is required' };
|
return { success: false, error: 'Email is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
|
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return { success: false, error: 'Could not reset password' };
|
return { success: false, error: 'Could not reset password' };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: 'Check your email for a link to reset your password.',
|
data: 'Check your email for a link to reset your password.',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resetPassword = async (
|
export const resetPassword = async (
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<Result<null>> => {
|
): Promise<Result<null>> => {
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
const confirmPassword = formData.get('confirmPassword') as string;
|
const confirmPassword = formData.get('confirmPassword') as string;
|
||||||
if (!password || !confirmPassword) {
|
if (!password || !confirmPassword) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Password and confirm password are required!',
|
error: 'Password and confirm password are required!',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
return { success: false, error: 'Passwords do not match!' };
|
return { success: false, error: 'Passwords do not match!' };
|
||||||
}
|
}
|
||||||
const { error } = await supabase.auth.updateUser({
|
const { error } = await supabase.auth.updateUser({
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Password update failed: ${error.message}`,
|
error: `Password update failed: ${error.message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: true, data: null };
|
return { success: true, data: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signOut = async (): Promise<Result<null>> => {
|
export const signOut = async (): Promise<Result<null>> => {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { error } = await supabase.auth.signOut();
|
const { error } = await supabase.auth.signOut();
|
||||||
if (error) return { success: false, error: error.message };
|
if (error) return { success: false, error: error.message };
|
||||||
return { success: true, data: null };
|
return { success: true, data: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUser = async (): Promise<Result<User>> => {
|
export const getUser = async (): Promise<Result<User>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { data, error } = await supabase.auth.getUser();
|
const { data, error } = await supabase.auth.getUser();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return { success: true, data: data.user };
|
return { success: true, data: data.user };
|
||||||
} catch {
|
} catch {
|
||||||
return { success: false, error: `Could not get user!` };
|
return { success: false, error: `Could not get user!` };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -3,5 +3,5 @@ export * from './storage';
|
|||||||
export * from './public';
|
export * from './public';
|
||||||
|
|
||||||
export type Result<T> =
|
export type Result<T> =
|
||||||
| { success: true; data: T }
|
| { success: true; data: T }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
@ -6,75 +6,75 @@ import { getUser } from '@/lib/actions';
|
|||||||
import type { Result } from '.';
|
import type { Result } from '.';
|
||||||
|
|
||||||
export const getProfile = async (): Promise<Result<Profile>> => {
|
export const getProfile = async (): Promise<Result<Profile>> => {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
if (!user.success || user.data === undefined)
|
if (!user.success || user.data === undefined)
|
||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', user.data.id)
|
.eq('id', user.data.id)
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return { success: true, data: data as Profile };
|
return { success: true, data: data as Profile };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unknown error getting profile',
|
: 'Unknown error getting profile',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type updateProfileProps = {
|
type updateProfileProps = {
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfile = async ({
|
export const updateProfile = async ({
|
||||||
full_name,
|
full_name,
|
||||||
email,
|
email,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
}: updateProfileProps): Promise<Result<Profile>> => {
|
}: updateProfileProps): Promise<Result<Profile>> => {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
full_name === undefined &&
|
full_name === undefined &&
|
||||||
email === undefined &&
|
email === undefined &&
|
||||||
avatar_url === undefined
|
avatar_url === undefined
|
||||||
)
|
)
|
||||||
throw new Error('No profile data provided');
|
throw new Error('No profile data provided');
|
||||||
|
|
||||||
const userResponse = await getUser();
|
const userResponse = await getUser();
|
||||||
if (!userResponse.success || userResponse.data === undefined)
|
if (!userResponse.success || userResponse.data === undefined)
|
||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
|
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update({
|
||||||
...(full_name !== undefined && { full_name }),
|
...(full_name !== undefined && { full_name }),
|
||||||
...(email !== undefined && { email }),
|
...(email !== undefined && { email }),
|
||||||
...(avatar_url !== undefined && { avatar_url }),
|
...(avatar_url !== undefined && { avatar_url }),
|
||||||
})
|
})
|
||||||
.eq('id', userResponse.data.id)
|
.eq('id', userResponse.data.id)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: data as Profile,
|
data: data as Profile,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unknown error updating profile',
|
: 'Unknown error updating profile',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -4,253 +4,261 @@ import { createServerClient } from '@/utils/supabase';
|
|||||||
import type { Result } from '.';
|
import type { Result } from '.';
|
||||||
|
|
||||||
export type GetStorageProps = {
|
export type GetStorageProps = {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
url: string;
|
url: string;
|
||||||
seconds?: number;
|
seconds?: number;
|
||||||
transform?: {
|
transform?: {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
quality?: number;
|
quality?: number;
|
||||||
format?: 'origin';
|
format?: 'origin';
|
||||||
resize?: 'cover' | 'contain' | 'fill';
|
resize?: 'cover' | 'contain' | 'fill';
|
||||||
};
|
};
|
||||||
download?: boolean | string;
|
download?: boolean | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UploadStorageProps = {
|
export type UploadStorageProps = {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
path: string;
|
path: string;
|
||||||
file: File;
|
file: File;
|
||||||
options?: {
|
options?: {
|
||||||
cacheControl?: string;
|
cacheControl?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReplaceStorageProps = {
|
export type ReplaceStorageProps = {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
path: string;
|
path: string;
|
||||||
file: File;
|
file: File;
|
||||||
options?: {
|
options?: {
|
||||||
cacheControl?: string;
|
cacheControl?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type resizeImageProps = {
|
export type resizeImageProps = {
|
||||||
file: File;
|
file: File;
|
||||||
options?: {
|
options?: {
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
quality?: number;
|
quality?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSignedUrl = async ({
|
export const getSignedUrl = async ({
|
||||||
bucket,
|
bucket,
|
||||||
url,
|
url,
|
||||||
seconds = 3600,
|
seconds = 3600,
|
||||||
transform = {},
|
transform = {},
|
||||||
download = false,
|
download = false,
|
||||||
}: GetStorageProps): Promise<Result<string>> => {
|
}: GetStorageProps): Promise<Result<string>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
.createSignedUrl(url, seconds, {
|
.createSignedUrl(url, seconds, {
|
||||||
download,
|
download,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (!data?.signedUrl) throw new Error('No signed URL returned');
|
if (!data?.signedUrl) throw new Error('No signed URL returned');
|
||||||
|
|
||||||
return { success: true, data: data.signedUrl };
|
return { success: true, data: data.signedUrl };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unknown error getting signed URL',
|
: 'Unknown error getting signed URL',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPublicUrl = async ({
|
export const getPublicUrl = async ({
|
||||||
bucket,
|
bucket,
|
||||||
url,
|
url,
|
||||||
transform = {},
|
transform = {},
|
||||||
download = false,
|
download = false,
|
||||||
}: GetStorageProps): Promise<Result<string>> => {
|
}: GetStorageProps): Promise<Result<string>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
|
const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
|
||||||
download,
|
download,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data?.publicUrl) throw new Error('No public URL returned');
|
if (!data?.publicUrl) throw new Error('No public URL returned');
|
||||||
|
|
||||||
return { success: true, data: data.publicUrl };
|
return { success: true, data: data.publicUrl };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unknown error getting public URL',
|
: 'Unknown error getting public URL',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadFile = async ({
|
export const uploadFile = async ({
|
||||||
bucket,
|
bucket,
|
||||||
path,
|
path,
|
||||||
file,
|
file,
|
||||||
options = {},
|
options = {},
|
||||||
}: UploadStorageProps): Promise<Result<string>> => {
|
}: UploadStorageProps): Promise<Result<string>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
.upload(path, file, options);
|
.upload(path, file, options);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (!data?.path) throw new Error('No path returned from upload');
|
if (!data?.path) throw new Error('No path returned from upload');
|
||||||
|
|
||||||
return { success: true, data: data.path };
|
return { success: true, data: data.path };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : 'Unknown error uploading file',
|
error instanceof Error
|
||||||
};
|
? error.message
|
||||||
}
|
: 'Unknown error uploading file',
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const replaceFile = async ({
|
export const replaceFile = async ({
|
||||||
bucket,
|
bucket,
|
||||||
path,
|
path,
|
||||||
file,
|
file,
|
||||||
options = {},
|
options = {},
|
||||||
}: ReplaceStorageProps): Promise<Result<string>> => {
|
}: ReplaceStorageProps): Promise<Result<string>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
.update(path, file, { ...options, upsert: true });
|
.update(path, file, { ...options, upsert: true });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (!data?.path) throw new Error('No path returned from upload');
|
if (!data?.path) throw new Error('No path returned from upload');
|
||||||
return { success: true, data: data.path };
|
return { success: true, data: data.path };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : 'Unknown error replacing file',
|
error instanceof Error
|
||||||
};
|
? error.message
|
||||||
}
|
: 'Unknown error replacing file',
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add a helper to delete files
|
// Add a helper to delete files
|
||||||
export const deleteFile = async ({
|
export const deleteFile = async ({
|
||||||
bucket,
|
bucket,
|
||||||
path,
|
path,
|
||||||
}: {
|
}: {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
path: string[];
|
path: string[];
|
||||||
}): Promise<Result<null>> => {
|
}): Promise<Result<null>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { error } = await supabase.storage.from(bucket).remove(path);
|
const { error } = await supabase.storage.from(bucket).remove(path);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return { success: true, data: null };
|
return { success: true, data: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : 'Unknown error deleting file',
|
error instanceof Error
|
||||||
};
|
? error.message
|
||||||
}
|
: 'Unknown error deleting file',
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add a helper to list files in a bucket
|
// Add a helper to list files in a bucket
|
||||||
export const listFiles = async ({
|
export const listFiles = async ({
|
||||||
bucket,
|
bucket,
|
||||||
path = '',
|
path = '',
|
||||||
options = {},
|
options = {},
|
||||||
}: {
|
}: {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
options?: {
|
options?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
sortBy?: { column: string; order: 'asc' | 'desc' };
|
sortBy?: { column: string; order: 'asc' | 'desc' };
|
||||||
};
|
};
|
||||||
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> => {
|
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
.list(path, options);
|
.list(path, options);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (!data) throw new Error('No data returned from list operation');
|
if (!data) throw new Error('No data returned from list operation');
|
||||||
|
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Could not list files!', error);
|
console.error('Could not list files!', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : 'Unknown error listing files',
|
error instanceof Error
|
||||||
};
|
? error.message
|
||||||
}
|
: 'Unknown error listing files',
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resizeImage = async ({
|
export const resizeImage = async ({
|
||||||
file,
|
file,
|
||||||
options = {},
|
options = {},
|
||||||
}: resizeImageProps): Promise<File> => {
|
}: resizeImageProps): Promise<File> => {
|
||||||
const { maxWidth = 800, maxHeight = 800, quality = 0.8 } = options;
|
const { maxWidth = 800, maxHeight = 800, quality = 0.8 } = options;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = event.target?.result as string;
|
img.src = event.target?.result as string;
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
let width = img.width;
|
let width = img.width;
|
||||||
let height = img.height;
|
let height = img.height;
|
||||||
if (width > height) {
|
if (width > height) {
|
||||||
if (width > maxWidth) {
|
if (width > maxWidth) {
|
||||||
height = Math.round((height * maxWidth) / width);
|
height = Math.round((height * maxWidth) / width);
|
||||||
width = maxWidth;
|
width = maxWidth;
|
||||||
}
|
}
|
||||||
} else if (height > maxHeight) {
|
} else if (height > maxHeight) {
|
||||||
width = Math.round((width * maxHeight) / height);
|
width = Math.round((width * maxHeight) / height);
|
||||||
height = maxHeight;
|
height = maxHeight;
|
||||||
}
|
}
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx?.drawImage(img, 0, 0, width, height);
|
ctx?.drawImage(img, 0, 0, width, height);
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => {
|
(blob) => {
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const resizedFile = new File([blob], file.name, {
|
const resizedFile = new File([blob], file.name, {
|
||||||
type: 'imgage/jpeg',
|
type: 'imgage/jpeg',
|
||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
});
|
});
|
||||||
resolve(resizedFile);
|
resolve(resizedFile);
|
||||||
},
|
},
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
quality,
|
quality,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -4,145 +4,145 @@ import type { User } from '@/utils/supabase';
|
|||||||
import type { Result } from '.';
|
import type { Result } from '.';
|
||||||
|
|
||||||
export const signUp = async (
|
export const signUp = async (
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<Result<string | null>> => {
|
): Promise<Result<string | null>> => {
|
||||||
const name = formData.get('name') as string;
|
const name = formData.get('name') as string;
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return { success: false, error: 'Email and password are required' };
|
return { success: false, error: 'Email and password are required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase.auth.signUp({
|
const { error } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
options: {
|
options: {
|
||||||
emailRedirectTo: `${origin}/auth/callback`,
|
emailRedirectTo: `${origin}/auth/callback`,
|
||||||
data: {
|
data: {
|
||||||
full_name: name,
|
full_name: name,
|
||||||
email,
|
email,
|
||||||
provider: 'email',
|
provider: 'email',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: 'Thanks for signing up! Please check your email for a verification link.',
|
data: 'Thanks for signing up! Please check your email for a verification link.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signIn = async (formData: FormData): Promise<Result<null>> => {
|
export const signIn = async (formData: FormData): Promise<Result<null>> => {
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
} else {
|
} else {
|
||||||
return { success: true, data: null };
|
return { success: true, data: null };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
|
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'azure',
|
provider: 'azure',
|
||||||
options: {
|
options: {
|
||||||
scopes: 'openid, profile email offline_access',
|
scopes: 'openid, profile email offline_access',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) return { success: false, error: error.message };
|
if (error) return { success: false, error: error.message };
|
||||||
return { success: true, data: data.url };
|
return { success: true, data: data.url };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signInWithApple = async (): Promise<Result<string>> => {
|
export const signInWithApple = async (): Promise<Result<string>> => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'apple',
|
provider: 'apple',
|
||||||
options: {
|
options: {
|
||||||
scopes: 'openid, profile email offline_access',
|
scopes: 'openid, profile email offline_access',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) return { success: false, error: error.message };
|
if (error) return { success: false, error: error.message };
|
||||||
return { success: true, data: data.url };
|
return { success: true, data: data.url };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const forgotPassword = async (
|
export const forgotPassword = async (
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<Result<string | null>> => {
|
): Promise<Result<string | null>> => {
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return { success: false, error: 'Email is required' };
|
return { success: false, error: 'Email is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
|
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return { success: false, error: 'Could not reset password' };
|
return { success: false, error: 'Could not reset password' };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: 'Check your email for a link to reset your password.',
|
data: 'Check your email for a link to reset your password.',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resetPassword = async (
|
export const resetPassword = async (
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<Result<null>> => {
|
): Promise<Result<null>> => {
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
const confirmPassword = formData.get('confirmPassword') as string;
|
const confirmPassword = formData.get('confirmPassword') as string;
|
||||||
if (!password || !confirmPassword) {
|
if (!password || !confirmPassword) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Password and confirm password are required!',
|
error: 'Password and confirm password are required!',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
return { success: false, error: 'Passwords do not match!' };
|
return { success: false, error: 'Passwords do not match!' };
|
||||||
}
|
}
|
||||||
const { error } = await supabase.auth.updateUser({
|
const { error } = await supabase.auth.updateUser({
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Password update failed: ${error.message}`,
|
error: `Password update failed: ${error.message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: true, data: null };
|
return { success: true, data: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signOut = async (): Promise<Result<null>> => {
|
export const signOut = async (): Promise<Result<null>> => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { error } = await supabase.auth.signOut();
|
const { error } = await supabase.auth.signOut();
|
||||||
if (error) return { success: false, error: error.message };
|
if (error) return { success: false, error: error.message };
|
||||||
return { success: true, data: null };
|
return { success: true, data: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUser = async (): Promise<Result<User>> => {
|
export const getUser = async (): Promise<Result<User>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase.auth.getUser();
|
const { data, error } = await supabase.auth.getUser();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return { success: true, data: data.user };
|
return { success: true, data: data.user };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: 'Could not get user!' };
|
return { success: false, error: 'Could not get user!' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -4,5 +4,5 @@ export * from './storage';
|
|||||||
export * from './useFileUpload';
|
export * from './useFileUpload';
|
||||||
|
|
||||||
export type Result<T> =
|
export type Result<T> =
|
||||||
| { success: true; data: T }
|
| { success: true; data: T }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
@ -5,75 +5,75 @@ import { getUser } from '@/lib/hooks';
|
|||||||
import type { Result } from '.';
|
import type { Result } from '.';
|
||||||
|
|
||||||
export const getProfile = async (): Promise<Result<Profile>> => {
|
export const getProfile = async (): Promise<Result<Profile>> => {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
if (!user.success || user.data === undefined)
|
if (!user.success || user.data === undefined)
|
||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', user.data.id)
|
.eq('id', user.data.id)
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return { success: true, data: data as Profile };
|
return { success: true, data: data as Profile };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unknown error getting profile',
|
: 'Unknown error getting profile',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type updateProfileProps = {
|
type updateProfileProps = {
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfile = async ({
|
export const updateProfile = async ({
|
||||||
full_name,
|
full_name,
|
||||||
email,
|
email,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
}: updateProfileProps): Promise<Result<Profile>> => {
|
}: updateProfileProps): Promise<Result<Profile>> => {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
full_name === undefined &&
|
full_name === undefined &&
|
||||||
email === undefined &&
|
email === undefined &&
|
||||||
avatar_url === undefined
|
avatar_url === undefined
|
||||||
)
|
)
|
||||||
throw new Error('No profile data provided');
|
throw new Error('No profile data provided');
|
||||||
|
|
||||||
const userResponse = await getUser();
|
const userResponse = await getUser();
|
||||||
if (!userResponse.success || userResponse.data === undefined)
|
if (!userResponse.success || userResponse.data === undefined)
|
||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
|
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update({
|
||||||
...(full_name !== undefined && { full_name }),
|
...(full_name !== undefined && { full_name }),
|
||||||
...(email !== undefined && { email }),
|
...(email !== undefined && { email }),
|
||||||
...(avatar_url !== undefined && { avatar_url }),
|
...(avatar_url !== undefined && { avatar_url }),
|
||||||
})
|
})
|
||||||
.eq('id', userResponse.data.id)
|
.eq('id', userResponse.data.id)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: data as Profile,
|
data: data as Profile,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unknown error updating profile',
|
: 'Unknown error updating profile',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -4,256 +4,264 @@ import { createClient } from '@/utils/supabase';
|
|||||||
import type { Result } from '.';
|
import type { Result } from '.';
|
||||||
|
|
||||||
export type GetStorageProps = {
|
export type GetStorageProps = {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
url: string;
|
url: string;
|
||||||
seconds?: number;
|
seconds?: number;
|
||||||
transform?: {
|
transform?: {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
quality?: number;
|
quality?: number;
|
||||||
format?: 'origin';
|
format?: 'origin';
|
||||||
resize?: 'cover' | 'contain' | 'fill';
|
resize?: 'cover' | 'contain' | 'fill';
|
||||||
};
|
};
|
||||||
download?: boolean | string;
|
download?: boolean | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UploadStorageProps = {
|
export type UploadStorageProps = {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
path: string;
|
path: string;
|
||||||
file: File;
|
file: File;
|
||||||
options?: {
|
options?: {
|
||||||
cacheControl?: string;
|
cacheControl?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReplaceStorageProps = {
|
export type ReplaceStorageProps = {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
path: string;
|
path: string;
|
||||||
file: File;
|
file: File;
|
||||||
options?: {
|
options?: {
|
||||||
cacheControl?: string;
|
cacheControl?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type resizeImageProps = {
|
export type resizeImageProps = {
|
||||||
file: File;
|
file: File;
|
||||||
options?: {
|
options?: {
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
quality?: number;
|
quality?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSignedUrl = async ({
|
export const getSignedUrl = async ({
|
||||||
bucket,
|
bucket,
|
||||||
url,
|
url,
|
||||||
seconds = 3600,
|
seconds = 3600,
|
||||||
transform = {},
|
transform = {},
|
||||||
download = false,
|
download = false,
|
||||||
}: GetStorageProps): Promise<Result<string>> => {
|
}: GetStorageProps): Promise<Result<string>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
.createSignedUrl(url, seconds, {
|
.createSignedUrl(url, seconds, {
|
||||||
download,
|
download,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (!data?.signedUrl) throw new Error('No signed URL returned');
|
if (!data?.signedUrl) throw new Error('No signed URL returned');
|
||||||
|
|
||||||
return { success: true, data: data.signedUrl };
|
return { success: true, data: data.signedUrl };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unknown error getting signed URL',
|
: 'Unknown error getting signed URL',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPublicUrl = async ({
|
export const getPublicUrl = async ({
|
||||||
bucket,
|
bucket,
|
||||||
url,
|
url,
|
||||||
transform = {},
|
transform = {},
|
||||||
download = false,
|
download = false,
|
||||||
}: GetStorageProps): Promise<Result<string>> => {
|
}: GetStorageProps): Promise<Result<string>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
|
const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
|
||||||
download,
|
download,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data?.publicUrl) throw new Error('No public URL returned');
|
if (!data?.publicUrl) throw new Error('No public URL returned');
|
||||||
|
|
||||||
return { success: true, data: data.publicUrl };
|
return { success: true, data: data.publicUrl };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unknown error getting public URL',
|
: 'Unknown error getting public URL',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadFile = async ({
|
export const uploadFile = async ({
|
||||||
bucket,
|
bucket,
|
||||||
path,
|
path,
|
||||||
file,
|
file,
|
||||||
options = {},
|
options = {},
|
||||||
}: UploadStorageProps): Promise<Result<string>> => {
|
}: UploadStorageProps): Promise<Result<string>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
.upload(path, file, options);
|
.upload(path, file, options);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (!data?.path) throw new Error('No path returned from upload');
|
if (!data?.path) throw new Error('No path returned from upload');
|
||||||
|
|
||||||
return { success: true, data: data.path };
|
return { success: true, data: data.path };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : 'Unknown error uploading file',
|
error instanceof Error
|
||||||
};
|
? error.message
|
||||||
}
|
: 'Unknown error uploading file',
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const replaceFile = async ({
|
export const replaceFile = async ({
|
||||||
bucket,
|
bucket,
|
||||||
path,
|
path,
|
||||||
file,
|
file,
|
||||||
options = {},
|
options = {},
|
||||||
}: ReplaceStorageProps): Promise<Result<string>> => {
|
}: ReplaceStorageProps): Promise<Result<string>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
.update(path, file, {
|
.update(path, file, {
|
||||||
...options,
|
...options,
|
||||||
upsert: true,
|
upsert: true,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (!data?.path) throw new Error('No path returned from upload');
|
if (!data?.path) throw new Error('No path returned from upload');
|
||||||
return { success: true, data: data.path };
|
return { success: true, data: data.path };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : 'Unknown error replacing file',
|
error instanceof Error
|
||||||
};
|
? error.message
|
||||||
}
|
: 'Unknown error replacing file',
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add a helper to delete files
|
// Add a helper to delete files
|
||||||
export const deleteFile = async ({
|
export const deleteFile = async ({
|
||||||
bucket,
|
bucket,
|
||||||
path,
|
path,
|
||||||
}: {
|
}: {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
path: string[];
|
path: string[];
|
||||||
}): Promise<Result<null>> => {
|
}): Promise<Result<null>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { error } = await supabase.storage.from(bucket).remove(path);
|
const { error } = await supabase.storage.from(bucket).remove(path);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return { success: true, data: null };
|
return { success: true, data: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : 'Unknown error deleting file',
|
error instanceof Error
|
||||||
};
|
? error.message
|
||||||
}
|
: 'Unknown error deleting file',
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add a helper to list files in a bucket
|
// Add a helper to list files in a bucket
|
||||||
export const listFiles = async ({
|
export const listFiles = async ({
|
||||||
bucket,
|
bucket,
|
||||||
path = '',
|
path = '',
|
||||||
options = {},
|
options = {},
|
||||||
}: {
|
}: {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
options?: {
|
options?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
sortBy?: { column: string; order: 'asc' | 'desc' };
|
sortBy?: { column: string; order: 'asc' | 'desc' };
|
||||||
};
|
};
|
||||||
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> => {
|
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
.list(path, options);
|
.list(path, options);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (!data) throw new Error('No data returned from list operation');
|
if (!data) throw new Error('No data returned from list operation');
|
||||||
|
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Could not list files!', error);
|
console.error('Could not list files!', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : 'Unknown error listing files',
|
error instanceof Error
|
||||||
};
|
? error.message
|
||||||
}
|
: 'Unknown error listing files',
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resizeImage = async ({
|
export const resizeImage = async ({
|
||||||
file,
|
file,
|
||||||
options = {},
|
options = {},
|
||||||
}: resizeImageProps): Promise<File> => {
|
}: resizeImageProps): Promise<File> => {
|
||||||
const { maxWidth = 800, maxHeight = 800, quality = 0.8 } = options;
|
const { maxWidth = 800, maxHeight = 800, quality = 0.8 } = options;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = event.target?.result as string;
|
img.src = event.target?.result as string;
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
let width = img.width;
|
let width = img.width;
|
||||||
let height = img.height;
|
let height = img.height;
|
||||||
if (width > height) {
|
if (width > height) {
|
||||||
if (width > maxWidth) {
|
if (width > maxWidth) {
|
||||||
height = Math.round((height * maxWidth) / width);
|
height = Math.round((height * maxWidth) / width);
|
||||||
width = maxWidth;
|
width = maxWidth;
|
||||||
}
|
}
|
||||||
} else if (height > maxHeight) {
|
} else if (height > maxHeight) {
|
||||||
width = Math.round((width * maxHeight) / height);
|
width = Math.round((width * maxHeight) / height);
|
||||||
height = maxHeight;
|
height = maxHeight;
|
||||||
}
|
}
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx?.drawImage(img, 0, 0, width, height);
|
ctx?.drawImage(img, 0, 0, width, height);
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => {
|
(blob) => {
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const resizedFile = new File([blob], file.name, {
|
const resizedFile = new File([blob], file.name, {
|
||||||
type: 'imgage/jpeg',
|
type: 'imgage/jpeg',
|
||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
});
|
});
|
||||||
resolve(resizedFile);
|
resolve(resizedFile);
|
||||||
},
|
},
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
quality,
|
quality,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -10,96 +10,98 @@ import type { Result } from '.';
|
|||||||
export type Replace = { replace: true; path: string } | false;
|
export type Replace = { replace: true; path: string } | false;
|
||||||
|
|
||||||
export type uploadToStorageProps = {
|
export type uploadToStorageProps = {
|
||||||
file: File;
|
file: File;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
resize: boolean;
|
resize: boolean;
|
||||||
options?: {
|
options?: {
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
quality?: number;
|
quality?: number;
|
||||||
};
|
};
|
||||||
replace?: Replace;
|
replace?: Replace;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFileUpload = () => {
|
export const useFileUpload = () => {
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { profile, isAuthenticated } = useAuth();
|
const { profile, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const uploadToStorage = async ({
|
const uploadToStorage = async ({
|
||||||
file,
|
file,
|
||||||
bucket,
|
bucket,
|
||||||
resize = false,
|
resize = false,
|
||||||
options = {},
|
options = {},
|
||||||
replace = false,
|
replace = false,
|
||||||
}: uploadToStorageProps): Promise<Result<string>> => {
|
}: uploadToStorageProps): Promise<Result<string>> => {
|
||||||
try {
|
try {
|
||||||
if (!isAuthenticated) throw new Error('User is not authenticated');
|
if (!isAuthenticated) throw new Error('User is not authenticated');
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
if (replace) {
|
if (replace) {
|
||||||
const updateResult = await replaceFile({
|
const updateResult = await replaceFile({
|
||||||
bucket,
|
bucket,
|
||||||
path: replace.path,
|
path: replace.path,
|
||||||
file,
|
file,
|
||||||
options: {
|
options: {
|
||||||
contentType: file.type,
|
contentType: file.type,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!updateResult.success) {
|
if (!updateResult.success) {
|
||||||
return { success: false, error: updateResult.error };
|
return { success: false, error: updateResult.error };
|
||||||
} else {
|
} else {
|
||||||
return { success: true, data: updateResult.data };
|
return { success: true, data: updateResult.data };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileToUpload = file;
|
let fileToUpload = file;
|
||||||
if (resize && file.type.startsWith('image/'))
|
if (resize && file.type.startsWith('image/'))
|
||||||
fileToUpload = await resizeImage({ file, options });
|
fileToUpload = await resizeImage({ file, options });
|
||||||
|
|
||||||
// Generate a unique filename to avoid collisions
|
// Generate a unique filename to avoid collisions
|
||||||
const fileExt = file.name.split('.').pop();
|
const fileExt = file.name.split('.').pop();
|
||||||
const fileName = `${Date.now()}-${profile?.id}.${fileExt}`;
|
const fileName = `${Date.now()}-${profile?.id}.${fileExt}`;
|
||||||
|
|
||||||
// Upload the file to Supabase storage
|
// Upload the file to Supabase storage
|
||||||
const uploadResult = await uploadFile({
|
const uploadResult = await uploadFile({
|
||||||
bucket,
|
bucket,
|
||||||
path: fileName,
|
path: fileName,
|
||||||
file: fileToUpload,
|
file: fileToUpload,
|
||||||
options: {
|
options: {
|
||||||
contentType: file.type,
|
contentType: file.type,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!uploadResult.success) {
|
if (!uploadResult.success) {
|
||||||
throw new Error(uploadResult.error || `Failed to upload to ${bucket}`);
|
throw new Error(
|
||||||
}
|
uploadResult.error || `Failed to upload to ${bucket}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, data: uploadResult.data };
|
return { success: true, data: uploadResult.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: `Failed to upload to ${bucket}`,
|
: `Failed to upload to ${bucket}`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Error: ${
|
error: `Error: ${
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: `Failed to upload to ${bucket}`
|
: `Failed to upload to ${bucket}`
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
// Clear the input value so the same file can be selected again
|
// Clear the input value so the same file can be selected again
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isUploading,
|
isUploading,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
uploadToStorage,
|
uploadToStorage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -2,5 +2,5 @@ import { clsx, type ClassValue } from 'clsx';
|
|||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
@ -2,20 +2,20 @@ import { type NextRequest } from 'next/server';
|
|||||||
import { updateSession } from '@/utils/supabase/middleware';
|
import { updateSession } from '@/utils/supabase/middleware';
|
||||||
|
|
||||||
export const middleware = async (request: NextRequest) => {
|
export const middleware = async (request: NextRequest) => {
|
||||||
return await updateSession(request);
|
return await updateSession(request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
/*
|
/*
|
||||||
* Match all request paths except:
|
* Match all request paths except:
|
||||||
* - _next/static (static files)
|
* - _next/static (static files)
|
||||||
* - _next/image (image optimization files)
|
* - _next/image (image optimization files)
|
||||||
* - favicon.ico (favicon file)
|
* - favicon.ico (favicon file)
|
||||||
* - /monitoring-tunnel (Sentry monitoring)
|
* - /monitoring-tunnel (Sentry monitoring)
|
||||||
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp
|
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp
|
||||||
* Feel free to modify this pattern to include more paths.
|
* Feel free to modify this pattern to include more paths.
|
||||||
*/
|
*/
|
||||||
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
'/((?!_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';
|
import { serve } from 'https://deno.land/std@0.177.1/http/server.ts';
|
||||||
|
|
||||||
serve(async () => {
|
serve(async () => {
|
||||||
return new Response(`"Hello from Edge Functions!"`, {
|
return new Response(`"Hello from Edge Functions!"`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// To invoke:
|
// To invoke:
|
||||||
|
@ -7,88 +7,88 @@ const JWT_SECRET = Deno.env.get('JWT_SECRET');
|
|||||||
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true';
|
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true';
|
||||||
|
|
||||||
function getAuthToken(req: Request) {
|
function getAuthToken(req: Request) {
|
||||||
const authHeader = req.headers.get('authorization');
|
const authHeader = req.headers.get('authorization');
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new Error('Missing authorization header');
|
throw new Error('Missing authorization header');
|
||||||
}
|
}
|
||||||
const [bearer, token] = authHeader.split(' ');
|
const [bearer, token] = authHeader.split(' ');
|
||||||
if (bearer !== 'Bearer') {
|
if (bearer !== 'Bearer') {
|
||||||
throw new Error(`Auth header is not 'Bearer {token}'`);
|
throw new Error(`Auth header is not 'Bearer {token}'`);
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyJWT(jwt: string): Promise<boolean> {
|
async function verifyJWT(jwt: string): Promise<boolean> {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const secretKey = encoder.encode(JWT_SECRET);
|
const secretKey = encoder.encode(JWT_SECRET);
|
||||||
try {
|
try {
|
||||||
await jose.jwtVerify(jwt, secretKey);
|
await jose.jwtVerify(jwt, secretKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
serve(async (req: Request) => {
|
serve(async (req: Request) => {
|
||||||
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
|
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken(req);
|
const token = getAuthToken(req);
|
||||||
const isValidJWT = await verifyJWT(token);
|
const isValidJWT = await verifyJWT(token);
|
||||||
|
|
||||||
if (!isValidJWT) {
|
if (!isValidJWT) {
|
||||||
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
|
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
return new Response(JSON.stringify({ msg: e.toString() }), {
|
return new Response(JSON.stringify({ msg: e.toString() }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const { pathname } = url;
|
const { pathname } = url;
|
||||||
const path_parts = pathname.split('/');
|
const path_parts = pathname.split('/');
|
||||||
const service_name = path_parts[1];
|
const service_name = path_parts[1];
|
||||||
|
|
||||||
if (!service_name || service_name === '') {
|
if (!service_name || service_name === '') {
|
||||||
const error = { msg: 'missing function name in request' };
|
const error = { msg: 'missing function name in request' };
|
||||||
return new Response(JSON.stringify(error), {
|
return new Response(JSON.stringify(error), {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const servicePath = `/home/deno/functions/${service_name}`;
|
const servicePath = `/home/deno/functions/${service_name}`;
|
||||||
console.error(`serving the request with ${servicePath}`);
|
console.error(`serving the request with ${servicePath}`);
|
||||||
|
|
||||||
const memoryLimitMb = 150;
|
const memoryLimitMb = 150;
|
||||||
const workerTimeoutMs = 1 * 60 * 1000;
|
const workerTimeoutMs = 1 * 60 * 1000;
|
||||||
const noModuleCache = false;
|
const noModuleCache = false;
|
||||||
const importMapPath = null;
|
const importMapPath = null;
|
||||||
const envVarsObj = Deno.env.toObject();
|
const envVarsObj = Deno.env.toObject();
|
||||||
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]);
|
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const worker = await EdgeRuntime.userWorkers.create({
|
const worker = await EdgeRuntime.userWorkers.create({
|
||||||
servicePath,
|
servicePath,
|
||||||
memoryLimitMb,
|
memoryLimitMb,
|
||||||
workerTimeoutMs,
|
workerTimeoutMs,
|
||||||
noModuleCache,
|
noModuleCache,
|
||||||
importMapPath,
|
importMapPath,
|
||||||
envVars,
|
envVars,
|
||||||
});
|
});
|
||||||
return await worker.fetch(req);
|
return await worker.fetch(req);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = { msg: e.toString() };
|
const error = { msg: e.toString() };
|
||||||
return new Response(JSON.stringify(error), {
|
return new Response(JSON.stringify(error), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -4,7 +4,7 @@ import { createBrowserClient } from '@supabase/ssr';
|
|||||||
import type { Database } from '@/utils/supabase/types';
|
import type { Database } from '@/utils/supabase/types';
|
||||||
|
|
||||||
export const createClient = () =>
|
export const createClient = () =>
|
||||||
createBrowserClient<Database>(
|
createBrowserClient<Database>(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
);
|
);
|
||||||
|
@ -3,54 +3,57 @@ import { type NextRequest, NextResponse } from 'next/server';
|
|||||||
import type { Database } from '@/utils/supabase/types';
|
import type { Database } from '@/utils/supabase/types';
|
||||||
|
|
||||||
export const updateSession = async (
|
export const updateSession = async (
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
): Promise<NextResponse> => {
|
): Promise<NextResponse> => {
|
||||||
try {
|
try {
|
||||||
// Create an unmodified response
|
// Create an unmodified response
|
||||||
let response = NextResponse.next({
|
let response = NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const supabase = createServerClient<Database>(
|
const supabase = createServerClient<Database>(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
{
|
{
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() {
|
||||||
return request.cookies.getAll();
|
return request.cookies.getAll();
|
||||||
},
|
},
|
||||||
setAll(cookiesToSet) {
|
setAll(cookiesToSet) {
|
||||||
cookiesToSet.forEach(({ name, value }) =>
|
cookiesToSet.forEach(({ name, value }) =>
|
||||||
request.cookies.set(name, value),
|
request.cookies.set(name, value),
|
||||||
);
|
);
|
||||||
response = NextResponse.next({
|
response = NextResponse.next({
|
||||||
request,
|
request,
|
||||||
});
|
});
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
response.cookies.set(name, value, options),
|
response.cookies.set(name, value, options),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// This will refresh session if expired - required for Server Components
|
// This will refresh session if expired - required for Server Components
|
||||||
// https://supabase.com/docs/guides/auth/server-side/nextjs
|
// https://supabase.com/docs/guides/auth/server-side/nextjs
|
||||||
const user = await supabase.auth.getUser();
|
const user = await supabase.auth.getUser();
|
||||||
|
|
||||||
// protected routes
|
// protected routes
|
||||||
if (request.nextUrl.pathname.startsWith('/reset-password') && user.error) {
|
if (
|
||||||
return NextResponse.redirect(new URL('/sign-in', request.url));
|
request.nextUrl.pathname.startsWith('/reset-password') &&
|
||||||
}
|
user.error
|
||||||
|
) {
|
||||||
|
return NextResponse.redirect(new URL('/sign-in', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.next({
|
return NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -6,28 +6,28 @@ import type { Database } from '@/utils/supabase/types';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
export const createServerClient = async () => {
|
export const createServerClient = async () => {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
return CreateServerClient<Database>(
|
return CreateServerClient<Database>(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
{
|
{
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() {
|
||||||
return cookieStore.getAll();
|
return cookieStore.getAll();
|
||||||
},
|
},
|
||||||
setAll(cookiesToSet) {
|
setAll(cookiesToSet) {
|
||||||
try {
|
try {
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
cookiesToSet.forEach(({ name, value, options }) => {
|
||||||
cookieStore.set(name, value, options);
|
cookieStore.set(name, value, options);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The `set` method was called from a Server Component.
|
// The `set` method was called from a Server Component.
|
||||||
// This can be ignored if you have middleware refreshing
|
// This can be ignored if you have middleware refreshing
|
||||||
// user sessions.
|
// user sessions.
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,188 +1,188 @@
|
|||||||
export type Json =
|
export type Json =
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
| null
|
| null
|
||||||
| { [key: string]: Json | undefined }
|
| { [key: string]: Json | undefined }
|
||||||
| Json[];
|
| Json[];
|
||||||
|
|
||||||
export type Database = {
|
export type Database = {
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
profiles: {
|
profiles: {
|
||||||
Row: {
|
Row: {
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
full_name: string | null;
|
full_name: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
full_name?: string | null;
|
full_name?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
full_name?: string | null;
|
full_name?: string | null;
|
||||||
id?: string;
|
id?: string;
|
||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
Relationships: [];
|
Relationships: [];
|
||||||
};
|
};
|
||||||
statuses: {
|
statuses: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
updated_by_id: string | null;
|
updated_by_id: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
status: string;
|
status: string;
|
||||||
updated_by_id?: string | null;
|
updated_by_id?: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
updated_by_id?: string | null;
|
updated_by_id?: string | null;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
};
|
};
|
||||||
Relationships: [];
|
Relationships: [];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
Views: {
|
Views: {
|
||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
};
|
};
|
||||||
Functions: {
|
Functions: {
|
||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
};
|
};
|
||||||
Enums: {
|
Enums: {
|
||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
};
|
};
|
||||||
CompositeTypes: {
|
CompositeTypes: {
|
||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type DefaultSchema = Database[Extract<keyof Database, 'public'>];
|
type DefaultSchema = Database[Extract<keyof Database, 'public'>];
|
||||||
|
|
||||||
export type Tables<
|
export type Tables<
|
||||||
DefaultSchemaTableNameOrOptions extends
|
DefaultSchemaTableNameOrOptions extends
|
||||||
| keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
|
| keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
|
||||||
| { schema: keyof Database },
|
| { schema: keyof Database },
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
schema: keyof Database;
|
schema: keyof Database;
|
||||||
}
|
}
|
||||||
? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
||||||
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])
|
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])
|
||||||
: never = never,
|
: never = never,
|
||||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||||
? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
||||||
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
|
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
|
||||||
Row: infer R;
|
Row: infer R;
|
||||||
}
|
}
|
||||||
? R
|
? R
|
||||||
: never
|
: never
|
||||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] &
|
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] &
|
||||||
DefaultSchema['Views'])
|
DefaultSchema['Views'])
|
||||||
? (DefaultSchema['Tables'] &
|
? (DefaultSchema['Tables'] &
|
||||||
DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends {
|
DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends {
|
||||||
Row: infer R;
|
Row: infer R;
|
||||||
}
|
}
|
||||||
? R
|
? R
|
||||||
: never
|
: never
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export type TablesInsert<
|
export type TablesInsert<
|
||||||
DefaultSchemaTableNameOrOptions extends
|
DefaultSchemaTableNameOrOptions extends
|
||||||
| keyof DefaultSchema['Tables']
|
| keyof DefaultSchema['Tables']
|
||||||
| { schema: keyof Database },
|
| { schema: keyof Database },
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
schema: keyof Database;
|
schema: keyof Database;
|
||||||
}
|
}
|
||||||
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
||||||
: never = never,
|
: never = never,
|
||||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||||
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||||
Insert: infer I;
|
Insert: infer I;
|
||||||
}
|
}
|
||||||
? I
|
? I
|
||||||
: never
|
: never
|
||||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
||||||
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
||||||
Insert: infer I;
|
Insert: infer I;
|
||||||
}
|
}
|
||||||
? I
|
? I
|
||||||
: never
|
: never
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export type TablesUpdate<
|
export type TablesUpdate<
|
||||||
DefaultSchemaTableNameOrOptions extends
|
DefaultSchemaTableNameOrOptions extends
|
||||||
| keyof DefaultSchema['Tables']
|
| keyof DefaultSchema['Tables']
|
||||||
| { schema: keyof Database },
|
| { schema: keyof Database },
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
schema: keyof Database;
|
schema: keyof Database;
|
||||||
}
|
}
|
||||||
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
||||||
: never = never,
|
: never = never,
|
||||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||||
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||||
Update: infer U;
|
Update: infer U;
|
||||||
}
|
}
|
||||||
? U
|
? U
|
||||||
: never
|
: never
|
||||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
||||||
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
||||||
Update: infer U;
|
Update: infer U;
|
||||||
}
|
}
|
||||||
? U
|
? U
|
||||||
: never
|
: never
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export type Enums<
|
export type Enums<
|
||||||
DefaultSchemaEnumNameOrOptions extends
|
DefaultSchemaEnumNameOrOptions extends
|
||||||
| keyof DefaultSchema['Enums']
|
| keyof DefaultSchema['Enums']
|
||||||
| { schema: keyof Database },
|
| { schema: keyof Database },
|
||||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||||
schema: keyof Database;
|
schema: keyof Database;
|
||||||
}
|
}
|
||||||
? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
|
? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
|
||||||
: never = never,
|
: never = never,
|
||||||
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
|
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
|
||||||
? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
|
? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
|
||||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums']
|
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums']
|
||||||
? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions]
|
? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions]
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export type CompositeTypes<
|
export type CompositeTypes<
|
||||||
PublicCompositeTypeNameOrOptions extends
|
PublicCompositeTypeNameOrOptions extends
|
||||||
| keyof DefaultSchema['CompositeTypes']
|
| keyof DefaultSchema['CompositeTypes']
|
||||||
| { schema: keyof Database },
|
| { schema: keyof Database },
|
||||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||||
schema: keyof Database;
|
schema: keyof Database;
|
||||||
}
|
}
|
||||||
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
|
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
|
||||||
: never = never,
|
: never = never,
|
||||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||||
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
|
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
|
||||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes']
|
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes']
|
||||||
? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
|
? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export const Constants = {
|
export const Constants = {
|
||||||
public: {
|
public: {
|
||||||
Enums: {},
|
Enums: {},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -15,12 +15,12 @@ export type StatusUpdate = Database['public']['Tables']['statuses']['Update'];
|
|||||||
|
|
||||||
// Generic helper to get any table's row type
|
// Generic helper to get any table's row type
|
||||||
export type TableRow<T extends keyof Database['public']['Tables']> =
|
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
|
// Generic helper to get any table's insert type
|
||||||
export type TableInsert<T extends keyof Database['public']['Tables']> =
|
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
|
// Generic helper to get any table's update type
|
||||||
export type TableUpdate<T extends keyof Database['public']['Tables']> =
|
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