Compare commits

...

4 Commits

Author SHA1 Message Date
Gib
7d7ed00c22 Add middleware to block misbehaving clients 2025-06-11 09:31:19 -05:00
Gib
42b07ea2da Move to tabs over spaces! 2025-06-09 09:57:27 -05:00
Gib
476d6c91b4 Update context imports for simplicity and consistency 2025-06-09 07:42:13 -05:00
Gib
6a6c0934d5 Got Apple Auth working 2025-06-08 18:31:35 -05:00
74 changed files with 4300 additions and 4192 deletions

View File

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

View File

@ -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,
}, },
}, },
}, },
); );

View File

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

View File

@ -6,8 +6,8 @@
"scripts": { "scripts": {
"build": "next build", "build": "next build",
"check": "next lint && tsc --noEmit", "check": "next lint && tsc --noEmit",
"dev": "next dev", "dev": "next dev --turbo",
"dev:turbo": "next dev --turbo", "dev:slow": "next dev",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint", "lint": "next lint",

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}); });

View File

@ -6,38 +6,38 @@ import { type NextRequest } from 'next/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export const GET = async (request: NextRequest) => { export const GET = async (request: NextRequest) => {
const { searchParams, 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('/');
}; };

View File

@ -1,39 +1,39 @@
'use client'; 'use client';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
const AuthSuccessPage = () => { const AuthSuccessPage = () => {
const { refreshUserData, isAuthenticated } = 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;

View File

@ -3,127 +3,127 @@ 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';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default'; import { 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(); 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-2xl font-medium'>Reset Password</CardTitle> <CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
<CardDescription className='text-sm text-foreground'> <CardDescription className='text-sm text-foreground'>
Don&apos;t have an account?{' '} Don&apos;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(handleForgotPassword)} onSubmit={form.handleSubmit(handleForgotPassword)}
className='flex flex-col min-w-64 space-y-6' className='flex flex-col min-w-64 space-y-6'
> >
<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 <Input
type='email' type='email'
placeholder='you@example.com' placeholder='you@example.com'
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<SubmitButton <SubmitButton
disabled={isLoading} disabled={isLoading}
pendingText='Resetting Password...' pendingText='Resetting Password...'
> >
Reset Password Reset Password
</SubmitButton> </SubmitButton>
{statusMessage && {statusMessage &&
(statusMessage.includes('Error') || (statusMessage.includes('Error') ||
statusMessage.includes('error') || statusMessage.includes('error') ||
statusMessage.includes('failed') || statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? ( statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} /> <StatusMessage message={{ error: statusMessage }} />
) : ( ) : (
<StatusMessage message={{ success: statusMessage }} /> <StatusMessage message={{ success: statusMessage }} />
))} ))}
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
); );
}; };
export default ForgotPassword; export default ForgotPassword;

View File

@ -1,19 +1,19 @@
'use client'; 'use client';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { 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,103 @@ 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 others
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{isLoading && !profile ? ( {isLoading && !profile ? (
<div className='flex justify-center py-8'> <div className='flex justify-center py-8'>
<Loader2 className='h-8 w-8 animate-spin text-gray-500' /> <Loader2 className='h-8 w-8 animate-spin text-gray-500' />
</div> </div>
) : ( ) : (
<div className='space-y-8'> <div className='space-y-8'>
<AvatarUpload onAvatarUploaded={handleAvatarUploaded} /> <AvatarUpload onAvatarUploaded={handleAvatarUploaded} />
<Separator /> <Separator />
<ProfileForm onSubmit={handleProfileSubmit} /> <ProfileForm onSubmit={handleProfileSubmit} />
<Separator /> <Separator />
<ResetPasswordForm onSubmit={handleResetPasswordSubmit} /> <ResetPasswordForm onSubmit={handleResetPasswordSubmit} />
<Separator /> <Separator />
<SignOut /> <SignOut />
</div> </div>
)} )}
</Card> </Card>
</div> </div>
); );
}; };
export default ProfilePage; export default ProfilePage;

View File

@ -4,23 +4,23 @@ 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';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default'; import { StatusMessage, SubmitButton } from '@/components/default';
import { Separator } from '@/components/ui'; import { Separator } from '@/components/ui';
@ -28,144 +28,144 @@ 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&apos;t have an account?{' '} Don&apos;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'>Email</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='email' type='email'
placeholder='you@example.com' placeholder='you@example.com'
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </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'>Password</FormLabel>
<Link <Link
className='text-xs text-foreground underline text-right' className='text-xs text-foreground underline text-right'
href='/forgot-password' href='/forgot-password'
> >
Forgot Password? Forgot Password?
</Link> </Link>
</div> </div>
<FormControl> <FormControl>
<Input <Input
type='password' type='password'
placeholder='Your password' placeholder='Your password'
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{statusMessage && {statusMessage &&
(statusMessage.includes('Error') || (statusMessage.includes('Error') ||
statusMessage.includes('error') || statusMessage.includes('error') ||
statusMessage.includes('failed') || statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? ( statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} /> <StatusMessage message={{ error: statusMessage }} />
) : ( ) : (
<StatusMessage message={{ message: statusMessage }} /> <StatusMessage message={{ message: statusMessage }} />
))} ))}
<SubmitButton <SubmitButton
disabled={isLoading} disabled={isLoading}
pendingText='Signing In...' pendingText='Signing In...'
className='text-[1.0rem] cursor-pointer' className='text-[1.0rem] cursor-pointer'
> >
Sign in Sign in
</SubmitButton> </SubmitButton>
</form> </form>
</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;

View File

@ -7,204 +7,204 @@ import Link from 'next/link';
import { signUp } from '@/lib/actions'; import { signUp } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default'; import { StatusMessage, SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { 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 className='text-primary font-medium underline' href='/sign-in'>
Sign in Sign in
</Link> </Link>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(handleSignUp)} onSubmit={form.handleSubmit(handleSignUp)}
className='flex flex-col mx-auto space-y-4 mb-4' className='flex flex-col mx-auto space-y-4 mb-4'
> >
<FormField <FormField
control={form.control} control={form.control}
name='name' name='name'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-lg'>Name</FormLabel> <FormLabel className='text-lg'>Name</FormLabel>
<FormControl> <FormControl>
<Input type='text' placeholder='Full Name' {...field} /> <Input type='text' placeholder='Full Name' {...field} />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
<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'>Email</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='email' type='email'
placeholder='you@example.com' placeholder='you@example.com'
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name='password' name='password'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-lg'>Password</FormLabel> <FormLabel className='text-lg'>Password</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='password' type='password'
placeholder='Your password' placeholder='Your password'
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name='confirmPassword' name='confirmPassword'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-lg'>Confirm Password</FormLabel> <FormLabel className='text-lg'>Confirm Password</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='password' type='password'
placeholder='Confirm password' placeholder='Confirm password'
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{statusMessage && {statusMessage &&
(statusMessage.includes('Error') || (statusMessage.includes('Error') ||
statusMessage.includes('error') || statusMessage.includes('error') ||
statusMessage.includes('failed') || statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? ( statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} /> <StatusMessage message={{ error: statusMessage }} />
) : ( ) : (
<StatusMessage message={{ success: statusMessage }} /> <StatusMessage message={{ success: statusMessage }} />
))} ))}
<SubmitButton <SubmitButton
className='text-[1.0rem] cursor-pointer' className='text-[1.0rem] cursor-pointer'
disabled={isLoading} disabled={isLoading}
pendingText='Signing Up...' pendingText='Signing Up...'
> >
Sign Up Sign Up
</SubmitButton> </SubmitButton>
</form> </form>
</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 type='signUp' />
<SignInWithApple /> <SignInWithApple type='signUp' />
</CardContent> </CardContent>
</Card> </Card>
); );
}; };
export default SignUp; export default SignUp;

View File

@ -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...' });
} }

View File

@ -2,8 +2,7 @@
import '@/styles/globals.css'; import '@/styles/globals.css';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme'; import { AuthProvider, ThemeProvider } from '@/components/context';
import { AuthProvider } from '@/components/context/auth';
import Navigation from '@/components/default/navigation'; import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer'; import Footer from '@/components/default/footer';
import { Button, Toaster } from '@/components/ui'; import { Button, Toaster } from '@/components/ui';
@ -13,68 +12,68 @@ 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 lang='en' className={`${geist.variable}`} suppressHydrationWarning>
<body <body
className={cn('bg-background text-foreground font-sans antialiased')} className={cn('bg-background text-foreground font-sans antialiased')}
> >
<ThemeProvider <ThemeProvider
attribute='class' attribute='class'
defaultTheme='system' defaultTheme='system'
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<AuthProvider> <AuthProvider>
<main className='min-h-screen flex flex-col items-center'> <main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'> <div className='flex-1 w-full flex flex-col gap-20 items-center'>
<Navigation /> <Navigation />
<div <div
className='flex flex-col gap-20 max-w-5xl 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</Button>
)} )}
</div> </div>
</div> </div>
<Footer /> <Footer />
</main> </main>
<Toaster /> <Toaster />
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
); );
return ( return (
<html lang='en'> <html lang='en'>
<body> <body>
{/* `NextError` is the default Next.js error page component. Its type {/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a does not expose status codes for errors, we simply pass 0 to render a
generic error message. */} generic error message. */}
<NextError statusCode={0} /> <NextError statusCode={0} />
{reset !== undefined && ( {reset !== undefined && (
<Button onClick={() => reset()}>Try again</Button> <Button onClick={() => reset()}>Try again</Button>
)} )}
</body> </body>
</html> </html>
); );
}; };
export default GlobalError; export default GlobalError;

View File

@ -2,378 +2,377 @@ import type { Metadata } from 'next';
import '@/styles/globals.css'; import '@/styles/globals.css';
import { Geist } from 'next/font/google'; import { Geist } from 'next/font/google';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme'; import { AuthProvider, ThemeProvider } from '@/components/context';
import { AuthProvider } from '@/components/context/auth';
import Navigation from '@/components/default/navigation'; import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer'; import Footer from '@/components/default/footer';
import { Toaster } from '@/components/ui'; import { Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs'; 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-16x16.png', type: 'image/png', sizes: '16x16' },
{ url: '/favicon-32x32.png', type: 'image/png', sizes: '32x32' }, { url: '/favicon-32x32.png', type: 'image/png', sizes: '32x32' },
{ url: '/favicon.png', type: 'image/png', sizes: '96x96' }, { url: '/favicon.png', type: 'image/png', sizes: '96x96' },
{ {
url: '/favicon.ico', url: '/favicon.ico',
type: 'image/x-icon', type: 'image/x-icon',
sizes: 'any', sizes: 'any',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/favicon-16x16.png', url: '/favicon-16x16.png',
type: 'image/png', type: 'image/png',
sizes: '16x16', sizes: '16x16',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/favicon-32x32.png', url: '/favicon-32x32.png',
type: 'image/png', type: 'image/png',
sizes: '32x32', sizes: '32x32',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/favicon-96x96.png', url: '/favicon-96x96.png',
type: 'image/png', type: 'image/png',
sizes: '96x96', sizes: '96x96',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36' }, { url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36' },
{ url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48' }, { url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48' },
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' }, { url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' }, { url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' },
{ {
url: '/appicon/icon-144x144.png', url: '/appicon/icon-144x144.png',
type: 'image/png', type: 'image/png',
sizes: '144x144', sizes: '144x144',
}, },
{ url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' }, { url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' },
{ {
url: '/appicon/icon-36x36.png', url: '/appicon/icon-36x36.png',
type: 'image/png', type: 'image/png',
sizes: '36x36', sizes: '36x36',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon-48x48.png', url: '/appicon/icon-48x48.png',
type: 'image/png', type: 'image/png',
sizes: '48x48', sizes: '48x48',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon-72x72.png', url: '/appicon/icon-72x72.png',
type: 'image/png', type: 'image/png',
sizes: '72x72', sizes: '72x72',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon-96x96.png', url: '/appicon/icon-96x96.png',
type: 'image/png', type: 'image/png',
sizes: '96x96', sizes: '96x96',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon-144x144.png', url: '/appicon/icon-144x144.png',
type: 'image/png', type: 'image/png',
sizes: '144x144', sizes: '144x144',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon.png', url: '/appicon/icon.png',
type: 'image/png', type: 'image/png',
sizes: '192x192', sizes: '192x192',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
], ],
shortcut: [ shortcut: [
{ url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36' }, { url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36' },
{ url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48' }, { url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48' },
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' }, { url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' }, { url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' },
{ {
url: '/appicon/icon-144x144.png', url: '/appicon/icon-144x144.png',
type: 'image/png', type: 'image/png',
sizes: '144x144', sizes: '144x144',
}, },
{ url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' }, { url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' },
{ {
url: '/appicon/icon-36x36.png', url: '/appicon/icon-36x36.png',
type: 'image/png', type: 'image/png',
sizes: '36x36', sizes: '36x36',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon-48x48.png', url: '/appicon/icon-48x48.png',
type: 'image/png', type: 'image/png',
sizes: '48x48', sizes: '48x48',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon-72x72.png', url: '/appicon/icon-72x72.png',
type: 'image/png', type: 'image/png',
sizes: '72x72', sizes: '72x72',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon-96x96.png', url: '/appicon/icon-96x96.png',
type: 'image/png', type: 'image/png',
sizes: '96x96', sizes: '96x96',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon-144x144.png', url: '/appicon/icon-144x144.png',
type: 'image/png', type: 'image/png',
sizes: '144x144', sizes: '144x144',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: '/appicon/icon.png', url: '/appicon/icon.png',
type: 'image/png', type: 'image/png',
sizes: '192x192', sizes: '192x192',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
], ],
apple: [ apple: [
{ url: 'appicon/icon-57x57.png', type: 'image/png', sizes: '57x57' }, { url: 'appicon/icon-57x57.png', type: 'image/png', sizes: '57x57' },
{ url: 'appicon/icon-60x60.png', type: 'image/png', sizes: '60x60' }, { url: 'appicon/icon-60x60.png', type: 'image/png', sizes: '60x60' },
{ url: 'appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' }, { url: 'appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
{ url: 'appicon/icon-76x76.png', type: 'image/png', sizes: '76x76' }, { url: 'appicon/icon-76x76.png', type: 'image/png', sizes: '76x76' },
{ {
url: 'appicon/icon-114x114.png', url: 'appicon/icon-114x114.png',
type: 'image/png', type: 'image/png',
sizes: '114x114', sizes: '114x114',
}, },
{ {
url: 'appicon/icon-120x120.png', url: 'appicon/icon-120x120.png',
type: 'image/png', type: 'image/png',
sizes: '120x120', sizes: '120x120',
}, },
{ {
url: 'appicon/icon-144x144.png', url: 'appicon/icon-144x144.png',
type: 'image/png', type: 'image/png',
sizes: '144x144', sizes: '144x144',
}, },
{ {
url: 'appicon/icon-152x152.png', url: 'appicon/icon-152x152.png',
type: 'image/png', type: 'image/png',
sizes: '152x152', sizes: '152x152',
}, },
{ {
url: 'appicon/icon-180x180.png', url: 'appicon/icon-180x180.png',
type: 'image/png', type: 'image/png',
sizes: '180x180', sizes: '180x180',
}, },
{ url: 'appicon/icon.png', type: 'image/png', sizes: '192x192' }, { url: 'appicon/icon.png', type: 'image/png', sizes: '192x192' },
{ {
url: 'appicon/icon-57x57.png', url: 'appicon/icon-57x57.png',
type: 'image/png', type: 'image/png',
sizes: '57x57', sizes: '57x57',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: 'appicon/icon-60x60.png', url: 'appicon/icon-60x60.png',
type: 'image/png', type: 'image/png',
sizes: '60x60', sizes: '60x60',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: 'appicon/icon-72x72.png', url: 'appicon/icon-72x72.png',
type: 'image/png', type: 'image/png',
sizes: '72x72', sizes: '72x72',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: 'appicon/icon-76x76.png', url: 'appicon/icon-76x76.png',
type: 'image/png', type: 'image/png',
sizes: '76x76', sizes: '76x76',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: 'appicon/icon-114x114.png', url: 'appicon/icon-114x114.png',
type: 'image/png', type: 'image/png',
sizes: '114x114', sizes: '114x114',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: 'appicon/icon-120x120.png', url: 'appicon/icon-120x120.png',
type: 'image/png', type: 'image/png',
sizes: '120x120', sizes: '120x120',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: 'appicon/icon-144x144.png', url: 'appicon/icon-144x144.png',
type: 'image/png', type: 'image/png',
sizes: '144x144', sizes: '144x144',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: 'appicon/icon-152x152.png', url: 'appicon/icon-152x152.png',
type: 'image/png', type: 'image/png',
sizes: '152x152', sizes: '152x152',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: 'appicon/icon-180x180.png', url: 'appicon/icon-180x180.png',
type: 'image/png', type: 'image/png',
sizes: '180x180', sizes: '180x180',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
{ {
url: 'appicon/icon.png', url: 'appicon/icon.png',
type: 'image/png', type: 'image/png',
sizes: '192x192', sizes: '192x192',
media: '(prefers-color-scheme: dark)', media: '(prefers-color-scheme: dark)',
}, },
], ],
other: [ other: [
{ {
rel: 'apple-touch-icon-precomposed', rel: 'apple-touch-icon-precomposed',
url: '/appicon/icon-precomposed.png', url: '/appicon/icon-precomposed.png',
type: 'image/png', type: 'image/png',
sizes: '180x180', sizes: '180x180',
}, },
], ],
}, },
other: { other: {
...Sentry.getTraceData(), ...Sentry.getTraceData(),
}, },
twitter: { twitter: {
card: 'app', card: 'app',
title: 'T3 Template', title: 'T3 Template',
description: 'Created by Gib with T3!', description: 'Created by Gib with T3!',
siteId: '', siteId: '',
creator: '@cs_gib', creator: '@cs_gib',
creatorId: '', creatorId: '',
images: { images: {
url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png', url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
alt: 'T3 Template', alt: 'T3 Template',
}, },
app: { app: {
name: 'T3 Template', name: 'T3 Template',
id: { id: {
iphone: '', iphone: '',
ipad: '', ipad: '',
googleplay: '', googleplay: '',
}, },
url: { url: {
iphone: '', iphone: '',
ipad: '', ipad: '',
googleplay: '', googleplay: '',
}, },
}, },
}, },
verification: { verification: {
google: 'google', google: 'google',
yandex: 'yandex', yandex: 'yandex',
yahoo: 'yahoo', yahoo: 'yahoo',
}, },
itunes: { itunes: {
appId: '', appId: '',
appArgument: '', appArgument: '',
}, },
appleWebApp: { appleWebApp: {
title: 'T3 Template', title: 'T3 Template',
statusBarStyle: 'black-translucent', statusBarStyle: 'black-translucent',
startupImage: [ startupImage: [
'/icons/apple/splash-768x1004.png', '/icons/apple/splash-768x1004.png',
{ {
url: '/icons/apple/splash-1536x2008.png', url: '/icons/apple/splash-1536x2008.png',
media: '(device-width: 768px) and (device-height: 1024px)', media: '(device-width: 768px) and (device-height: 1024px)',
}, },
], ],
}, },
appLinks: { appLinks: {
ios: { ios: {
url: 'https://t3-template.gbrown.org/ios', url: 'https://t3-template.gbrown.org/ios',
app_store_id: 't3_template', app_store_id: 't3_template',
}, },
android: { android: {
package: 'org.gbrown.android/t3-template', package: 'org.gbrown.android/t3-template',
app_name: 'app_t3_template', app_name: 'app_t3_template',
}, },
web: { web: {
url: 'https://t3-template.gbrown.org/web', url: 'https://t3-template.gbrown.org/web',
should_fallback: true, should_fallback: true,
}, },
}, },
facebook: { facebook: {
appId: '', appId: '',
}, },
pinterest: { pinterest: {
richPin: true, richPin: true,
}, },
category: 'technology', category: 'technology',
}; };
}; };
const geist = Geist({ const geist = Geist({
subsets: ['latin'], subsets: ['latin'],
variable: '--font-geist-sans', variable: '--font-geist-sans',
}); });
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return ( return (
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning> <html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
<body <body
className={cn('bg-background text-foreground font-sans antialiased')} className={cn('bg-background text-foreground font-sans antialiased')}
> >
<ThemeProvider <ThemeProvider
attribute='class' attribute='class'
defaultTheme='system' defaultTheme='system'
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<AuthProvider> <AuthProvider>
<main className='min-h-screen flex flex-col items-center'> <main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'> <div className='flex-1 w-full flex flex-col gap-20 items-center'>
<Navigation /> <Navigation />
<div <div
className='flex flex-col gap-20 max-w-5xl 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 />
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
); );
}; };
export default RootLayout; export default RootLayout;

View File

@ -6,91 +6,91 @@ 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 account &
ensuring you can sign up! If you already have an account, go ensuring you can sign up! If you already have an account, go
ahead and sign in! 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'>or</span>
<Separator className='flex-1 bg-accent py-0.5' /> <Separator className='flex-1 bg-accent py-0.5' />
</div> </div>
<div className='flex gap-4'> <div className='flex gap-4'>
<SignInWithMicrosoft buttonSize='lg' /> <SignInWithMicrosoft buttonSize='lg' />
<SignInWithApple buttonSize='lg' /> <SignInWithApple buttonSize='lg' />
</div> </div>
</CardHeader> </CardHeader>
<Separator className='bg-accent' /> <Separator className='bg-accent' />
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'> <CardContent className='flex flex-col px-5 py-2 items-center justify-center'>
<CardTitle className='text-lg mb-6 w-2/3 text-center'> <CardTitle className='text-lg mb-6 w-2/3 text-center'>
You can also test out your connection to Sentry if you want to You can also test out your connection to Sentry if you want to
start there! start there!
</CardTitle> </CardTitle>
<TestSentryCard /> <TestSentryCard />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</main> </main>
); );
} }
const user: User = response.data; const user: User = response.data;
return ( return (
<div className='flex-1 w-full flex flex-col gap-12'> <div className='flex-1 w-full flex flex-col gap-12'>
<div className='w-full'> <div className='w-full'>
<div <div
className='bg-accent text-sm p-3 px-5 className='bg-accent text-sm p-3 px-5
rounded-md text-foreground flex gap-3 items-center' rounded-md text-foreground flex gap-3 items-center'
> >
<InfoIcon size='16' strokeWidth={2} /> <InfoIcon size='16' strokeWidth={2} />
This is a protected component that you can only see as an This is a protected component that you can only see as an
authenticated user authenticated user
</div> </div>
</div> </div>
<div className='flex flex-col gap-2 items-start'> <div className='flex flex-col gap-2 items-start'>
<h2 className='font-bold text-3xl mb-4'>Your user details</h2> <h2 className='font-bold text-3xl mb-4'>Your user details</h2>
<pre <pre
className='text-sm font-mono p-3 rounded className='text-sm font-mono p-3 rounded
border max-h-50 overflow-auto' border max-h-50 overflow-auto'
> >
{JSON.stringify(user, null, 2)} {JSON.stringify(user, null, 2)}
</pre> </pre>
</div> </div>
<TestSentryCard /> <TestSentryCard />
<div> <div>
<h2 className='font-bold text-2xl mb-4'>Next steps</h2> <h2 className='font-bold text-2xl mb-4'>Next steps</h2>
<FetchDataSteps /> <FetchDataSteps />
</div> </div>
</div> </div>
); );
}; };
export default HomePage; export default HomePage;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -1,77 +1,77 @@
'use client'; 'use client';
import { signInWithApple } from '@/lib/actions'; import { signInWithApple } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default'; import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { Button, type buttonVariants } from '@/components/ui'; import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react'; import { type 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: ${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!'}`,
); );
} 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 && <StatusMessage message={{ error: statusMessage }} />}
</form> </form>
); );
}; };

View File

@ -1,70 +1,70 @@
'use client'; 'use client';
import { signInWithMicrosoft } from '@/lib/actions'; import { signInWithMicrosoft } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default'; import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useState } from 'react'; import { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { Button, type buttonVariants } from '@/components/ui'; import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react'; import { type 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: ${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 (
<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 && <StatusMessage message={{ error: statusMessage }} />}
</form> </form>
); );
}; };

View File

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

View File

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

View File

@ -2,86 +2,86 @@
import Link from 'next/link'; import Link from 'next/link';
import { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
AvatarImage, AvatarImage,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui'; } from '@/components/ui';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { signOut } from '@/lib/actions'; import { signOut } from '@/lib/actions';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
const AvatarDropdown = () => { const AvatarDropdown = () => {
const { profile, avatarUrl, isLoading, refreshUserData } = useAuth(); const { profile, avatarUrl, isLoading, refreshUserData } = useAuth();
const router = useRouter(); const router = useRouter();
const handleSignOut = async () => { const handleSignOut = async () => {
const result = await signOut(); const result = await signOut();
if (result?.success) { if (result?.success) {
await refreshUserData(); await refreshUserData();
router.push('/sign-in'); router.push('/sign-in');
} }
}; };
const getInitials = (name: string | null | undefined): string => { const getInitials = (name: string | null | undefined): string => {
if (!name) return ''; if (!name) return '';
return name 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;

View File

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

View File

@ -3,38 +3,38 @@
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui'; import { Button } from '@/components/ui';
import NavigationAuth from './auth'; import NavigationAuth from './auth';
import { ThemeToggle } from '@/components/context/theme'; import { ThemeToggle } from '@/components/context';
import Image from 'next/image'; 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 src='/favicon.png' alt='T3 Logo' width={50} height={50} />
<h1 className='my-auto text-2xl'>T3 Supabase Template</h1> <h1 className='my-auto text-2xl'>T3 Supabase Template</h1>
</Link> </Link>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button asChild> <Button asChild>
<Link href='https://git.gbrown.org/gib/T3-Template'> <Link href='https://git.gbrown.org/gib/T3-Template'>
Go to Git Repo Go to Git Repo
</Link> </Link>
</Button> </Button>
</div> </div>
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<ThemeToggle /> <ThemeToggle />
<NavigationAuth /> <NavigationAuth />
</div> </div>
</div> </div>
</nav> </nav>
); );
}; };
export default Navigation; export default Navigation;

View File

@ -1,112 +1,112 @@
import { useFileUpload } from '@/lib/hooks/useFileUpload'; import { useFileUpload } from '@/lib/hooks/useFileUpload';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { 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>
); );
}; };

View File

@ -2,99 +2,99 @@ import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
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/auth'; 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 onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
<FormField <FormField
control={form.control} control={form.control}
name='full_name' name='full_name'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Full Name</FormLabel> <FormLabel>Full Name</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription>Your public display name.</FormDescription> <FormDescription>Your public display name.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your email address associated with your account. Your email address associated with your account.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div className='flex justify-center'> <div className='flex justify-center'>
<SubmitButton disabled={isLoading} pendingText='Saving...'> <SubmitButton disabled={isLoading} pendingText='Saving...'>
Save Changes Save Changes
</SubmitButton> </SubmitButton>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
); );
}; };

View File

@ -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,130 +21,127 @@ 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 : 'Password was not updated!',
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<div> <div>
<CardHeader className='pb-5'> <CardHeader className='pb-5'>
<CardTitle className='text-2xl'>Change Password</CardTitle> <CardTitle className='text-2xl'>Change Password</CardTitle>
<CardDescription> <CardDescription>
Update your password to keep your account secure Update your password to keep your account secure
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(handleUpdatePassword)} onSubmit={form.handleSubmit(handleUpdatePassword)}
className='space-y-6' className='space-y-6'
> >
<FormField <FormField
control={form.control} control={form.control}
name='password' name='password'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>New Password</FormLabel> <FormLabel>New Password</FormLabel>
<FormControl> <FormControl>
<Input type='password' {...field} /> <Input type='password' {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter your new password. Must be at least 8 characters. Enter your new password. Must be at least 8 characters.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name='confirmPassword' name='confirmPassword'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Confirm Password</FormLabel> <FormLabel>Confirm Password</FormLabel>
<FormControl> <FormControl>
<Input type='password' {...field} /> <Input type='password' {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Please re-enter your new password to confirm. Please re-enter your new password to confirm.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{statusMessage && ( {statusMessage &&
<div (statusMessage.includes('Error') ||
className={`text-sm text-center ${ statusMessage.includes('error') ||
statusMessage.includes('Error') || statusMessage.includes('failed') ||
statusMessage.includes('failed') statusMessage.includes('invalid') ? (
? 'text-destructive' <StatusMessage message={{ error: statusMessage }} />
: 'text-green-600' ) : (
}`} <StatusMessage message={{ message: statusMessage }} />
> ))}
{statusMessage} <div className='flex justify-center'>
</div> <SubmitButton
)} disabled={isLoading}
<div className='flex justify-center'> pendingText='Updating Password...'
<SubmitButton >
disabled={isLoading} Update Password
pendingText='Updating Password...' </SubmitButton>
> </div>
Update Password </form>
</SubmitButton> </Form>
</div> </CardContent>
</form> </div>
</Form> );
</CardContent>
</div>
);
}; };

View File

@ -3,32 +3,32 @@
import { CardHeader } from '@/components/ui'; import { CardHeader } from '@/components/ui';
import { SubmitButton } from '@/components/default'; import { SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { 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>
); );
}; };

View File

@ -3,124 +3,124 @@
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'>Test Sentry</CardTitle>
</div> </div>
<CardDescription className='text-[1.0rem]'> <CardDescription className='text-[1.0rem]'>
Click the button below & view the sample error on{' '} Click the button below & view the sample error on{' '}
<Link <Link
href={`${process.env.NEXT_PUBLIC_SENTRY_URL}`} href={`${process.env.NEXT_PUBLIC_SENTRY_URL}`}
className='text-accent-foreground underline hover:text-primary' className='text-accent-foreground underline hover:text-primary'
> >
the Sentry website the Sentry website
</Link> </Link>
. Navigate to the {"'"}Issues{"'"} page & you should see the sample . Navigate to the {"'"}Issues{"'"} page & you should see the sample
error! error!
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='flex flex-row gap-4 my-auto'> <div className='flex flex-row gap-4 my-auto'>
<Button <Button
type='button' type='button'
onClick={createError} onClick={createError}
className='cursor-pointer text-md my-auto py-6' className='cursor-pointer text-md my-auto py-6'
> >
<span>Throw Sample Error</span> <span>Throw Sample Error</span>
</Button> </Button>
{hasSentError ? ( {hasSentError ? (
<div className='rounded-md bg-green-500/80 dark:bg-green-500/50 py-2 px-4 flex flex-row gap-2 my-auto'> <div className='rounded-md bg-green-500/80 dark:bg-green-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
<CheckCircle size={30} className='my-auto' /> <CheckCircle size={30} className='my-auto' />
<p className='text-lg'>Sample error was sent to Sentry!</p> <p className='text-lg'>Sample error was sent to Sentry!</p>
</div> </div>
) : !isConnected ? ( ) : !isConnected ? (
<div className='rounded-md bg-red-600/50 dark:bg-red-500/50 py-2 px-4 flex flex-row gap-2 my-auto'> <div className='rounded-md bg-red-600/50 dark:bg-red-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
<MessageCircleWarning size={40} className='my-auto' /> <MessageCircleWarning size={40} className='my-auto' />
<p> <p>
Wait! The Sentry SDK is not able to reach Sentry right now - Wait! The Sentry SDK is not able to reach Sentry right now -
this may be due to an adblocker. For more information, see{' '} this may be due to an adblocker. For more information, see{' '}
<Link <Link
href='https://docs.sentry.io/platforms/javascript/guides/nextjs/troubleshooting/#the-sdk-is-not-sending-any-data' 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' className='text-accent-foreground underline hover:text-primary'
> >
the troubleshooting guide. the troubleshooting guide.
</Link> </Link>
</p> </p>
</div> </div>
) : ( ) : (
<div className='success_placeholder' /> <div className='success_placeholder' />
)} )}
</div> </div>
<Separator className='my-4 bg-accent' /> <Separator className='my-4 bg-accent' />
<p className='description'> <p className='description'>
Warning! Sometimes Adblockers will prevent errors from being sent to Warning! Sometimes Adblockers will prevent errors from being sent to
Sentry. Sentry.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
); );
}; };

View File

@ -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>
); );
} }

View File

@ -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 example
data. If you&apos;re stuck for creativity, you can copy and paste the data. If you&apos;re stuck for creativity, you can copy and paste the
following into 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 Server
Component, create a new page.tsx file at{' '} 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&apos;re ready to launch your product to the world! 🚀</p> <p>You&apos;re ready to launch your product to the world! 🚀</p>
</TutorialStep> </TutorialStep>
</ol> </ol>
); );
}; };

View File

@ -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>
); );
}; };

View File

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

View File

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

View File

@ -5,58 +5,58 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,57 +2,57 @@ import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod'; import { z } from 'zod';
export const env = createEnv({ export const env = createEnv({
/** /**
* Specify your server-side environment variables schema here. * Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars. * This way you can ensure the app isn't built with invalid env vars.
*/ */
server: { server: {
NODE_ENV: z 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: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL, 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 * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds. * 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 * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error. * `SOME_VAR=''` will throw an error.
*/ */
emptyStringAsUndefined: true, emptyStringAsUndefined: true,
}); });

View File

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

View File

@ -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);
}; };

View File

@ -7,150 +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: {
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 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 (error) { } catch (error) {
return { success: false, error: 'Could not get user!' }; return { success: false, error: 'Could not get user!' };
} }
}; };

View File

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

View File

@ -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',
}; };
} }
}; };

View File

@ -4,253 +4,253 @@ 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,
); );
}; };
}; };
}); });
}; };

View File

@ -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!' };
} }
}; };

View File

@ -1,9 +1,8 @@
export * from './auth'; export * from './auth';
export * from './public'; export * from './public';
//export * from './resizeImage';
export * from './storage'; 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 };

View File

@ -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',
}; };
} }
}; };

View File

@ -4,256 +4,256 @@ 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,
); );
}; };
}; };
}); });
}; };

View File

@ -3,103 +3,103 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { replaceFile, uploadFile } from '@/lib/hooks'; import { replaceFile, uploadFile } from '@/lib/hooks';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { resizeImage } from '@/lib/hooks'; import { resizeImage } from '@/lib/hooks';
import type { Result } from '.'; 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,
}; };
}; };

View File

@ -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));
} }

View File

@ -1,21 +1,133 @@
import { type NextRequest } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import { updateSession } from '@/utils/supabase/middleware'; import { updateSession } from '@/utils/supabase/middleware';
// In-memory store for tracking IPs (use Redis in production)
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
const bannedIPs = new Set<string>();
// Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [
/web-inf/i,
/\.jsp/i,
/\.php/i,
/puttest/i,
/WEB-INF/i,
/\.xml$/i,
/perl/i,
/xampp/i,
/phpwebgallery/i,
/FileManager/i,
/standalonemanager/i,
/h2console/i,
/WebAdmin/i,
/login_form\.php/i,
/%2e/i,
/%u002e/i,
/\.%00/i,
/\.\./,
/lcgi/i,
];
// Suspicious HTTP methods
const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH'];
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const MAX_ATTEMPTS = 10; // Max suspicious requests per window
const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
const getClientIP = (request: NextRequest): string => {
const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
if (realIP) {
return realIP;
}
return request.ip ?? 'unknown';
};
const isPathSuspicious = (pathname: string): boolean => {
return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
};
const isMethodSuspicious = (method: string): boolean => {
return SUSPICIOUS_METHODS.includes(method);
};
const updateIPAttempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ipAttempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
ipAttempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_ATTEMPTS) {
bannedIPs.add(ip);
// Clean up the attempts record
ipAttempts.delete(ip);
// Auto-unban after duration (in production, use a proper scheduler)
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
return false;
};
export const middleware = async (request: NextRequest) => { export const middleware = async (request: NextRequest) => {
return await updateSession(request); const { pathname } = request.nextUrl;
const method = request.method;
const ip = getClientIP(request);
// Check if IP is already banned
if (bannedIPs.has(ip)) {
console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`);
return new NextResponse('Access denied.', { status: 403 });
}
// Check for suspicious activity
const isSuspiciousPath = isPathSuspicious(pathname);
const isSuspiciousMethod = isMethodSuspicious(method);
if (isSuspiciousPath || isSuspiciousMethod) {
console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`);
const shouldBan = updateIPAttempts(ip);
if (shouldBan) {
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
return new NextResponse('Access denied - IP banned', { status: 403 });
}
// Return 404 to not reveal the blocking mechanism
return new NextResponse('Not Found', { status: 404 });
}
return await updateSession(request);
}; };
export const config = { 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)$).*)',
], ],
}; };

View File

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

View File

@ -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' },
}); });
} }
}); });

View File

@ -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!,
); );

View File

@ -3,54 +3,54 @@ 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 (request.nextUrl.pathname.startsWith('/reset-password') && user.error) {
return NextResponse.redirect(new URL('/sign-in', request.url)); 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,
}, },
}); });
} }
}; };

View File

@ -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.
} }
}, },
}, },
}, },
); );
}; };

View File

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

View File

@ -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'];