Making progress.

This commit is contained in:
2025-06-11 14:15:43 -05:00
parent 6c06dbc535
commit ee6bede841
86 changed files with 4594 additions and 4185 deletions

View File

@ -2,6 +2,5 @@
"singleQuote": true, "singleQuote": true,
"jsxSingleQuote": true, "jsxSingleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"useTabs": true,
"tabWidth": 2 "tabWidth": 2
} }

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

@ -3,65 +3,68 @@
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs'; import { withSentryConfig } from '@sentry/nextjs';
import { withPlausibleProxy } from 'next-plausible';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = withPlausibleProxy({
output: 'standalone', customDomain: 'https://plausible.gbrown.org',
images: { })({
remotePatterns: [ output: 'standalone',
{ images: {
protocol: 'https', remotePatterns: [
hostname: '*.gbrown.org', {
}, protocol: 'https',
], hostname: '*.gbrown.org',
}, },
serverExternalPackages: ['require-in-the-middle'], ],
experimental: { },
serverActions: { serverExternalPackages: ['require-in-the-middle'],
bodySizeLimit: '10mb', experimental: {
}, serverActions: {
}, bodySizeLimit: '10mb',
turbopack: { },
rules: { },
'*.svg': { turbopack: {
loaders: [ rules: {
{ '*.svg': {
loader: '@svgr/webpack', loaders: [
options: { {
icon: true, loader: '@svgr/webpack',
}, options: {
}, 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

@ -32,6 +32,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.510.0", "lucide-react": "^0.510.0",
"next": "^15.3.3", "next": "^15.3.3",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",

16
pnpm-lock.yaml generated
View File

@ -53,6 +53,9 @@ importers:
next: next:
specifier: ^15.3.3 specifier: ^15.3.3
version: 15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-plausible:
specifier: ^3.12.4
version: 3.12.4(next@15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -2758,6 +2761,13 @@ packages:
neo-async@2.6.2: neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
next-plausible@3.12.4:
resolution: {integrity: sha512-cD3+ixJxf8yBYvsideTxqli3fvrB7R4BXcvsNJz8Sm2X1QN039WfiXjCyNWkub4h5++rRs6fHhchUMnOuJokcg==}
peerDependencies:
next: '^11.1.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 '
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
next-themes@0.4.6: next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies: peerDependencies:
@ -6327,6 +6337,12 @@ snapshots:
neo-async@2.6.2: {} neo-async@2.6.2: {}
next-plausible@3.12.4(next@15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
next: 15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies: dependencies:
react: 19.1.0 react: 19.1.0

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

1
public/icons/gitea.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M414.4 376.5 200 379.6l-1.4-256.7 103.5-15.2 108.8-1.5z" style="fill:#fff"/><path d="M502.6 103.7c-3.3-3.3-7.8-3.3-7.8-3.3s-95.5 5.4-144.9 6.5c-10.8.2-21.6.5-32.3.6V203c-4.5-2.1-9-4.3-13.5-6.4 0-29.6-.1-88.9-.1-88.9-23.6.3-72.7-1.8-72.7-1.8s-115.2-5.8-127.7-6.9c-8-.5-18.3-1.7-31.8 1.2-7.1 1.5-27.3 6-43.8 21.9C-8.7 154.8.7 206.7 1.9 214.5c1.4 9.5 5.6 36 25.8 59 37.3 45.7 117.6 44.6 117.6 44.6s9.9 23.5 24.9 45.2c20.4 27 41.3 48 61.7 50.5 51.3 0 153.9-.1 153.9-.1s9.8.1 23-8.4c11.4-6.9 21.6-19.1 21.6-19.1s10.5-11.2 25.2-36.9c4.5-7.9 8.2-15.6 11.5-22.8 0 0 45-95.4 45-188.2-1-28-7.9-33-9.5-34.6M97.7 269.9c-21.1-6.9-30.1-15.2-30.1-15.2S52 243.8 44.2 222.3c-13.4-36-1.1-58-1.1-58s6.8-18.3 31.4-24.4c11.2-3 25.2-2.5 25.2-2.5s5.8 48.4 12.8 76.7c5.9 23.8 20.2 63.3 20.2 63.3s-21.3-2.6-35-7.5m289.4-4.5c-5.2 12.6-44.8 92.1-44.8 92.1s-5 11.8-16 12.5c-4.7.3-8.4-1-8.4-1s-.2-.1-4.3-1.7l-92-44.8s-8.9-4.6-10.4-12.7c-1.8-6.6 2.2-14.7 2.2-14.7l44.2-91.1s3.9-7.9 9.9-10.6c.5-.2 1.9-.8 3.7-1.2 6.6-1.7 14.7 2.3 14.7 2.3l18.4 8.9c-3.7 7.6-7.5 15.2-11.2 22.9-5.5-.1-10.5 2.9-13.1 7.7-2.8 5.1-2.2 11.5 1.5 16.1-6.6 13.8-13.3 27.5-19.9 41.1-6.7.1-12.5 4.7-14.1 11.2-1.5 6.5 1.6 13.3 7.4 16.3 6.3 3.3 14.3 1.5 18.5-4.4 4.2-5.8 3.5-13.8-1.5-18.8l19.5-40c1.2.1 3 .2 5-.4 3.3-.7 5.8-2.9 5.8-2.9 3.4 1.5 7 3.1 10.8 5 3.9 2 7.6 4 10.9 5.9.7.4 1.5.9 2.3 1.5 1.3 1.1 2.8 2.5 3.8 4.5 1.5 4.5-1.5 12.1-1.5 12.1-1.9 6.2-15 33.1-15 33.1-6.6-.2-12.5 4.1-14.4 10.2-2.1 6.6.9 14.1 7.2 17.3 6.4 3.3 14.2 1.4 18.3-4.3 4.1-5.5 3.7-13.3-.9-18.4l4.6-9.2c4.1-8.5 11-24.8 11-24.8.7-1.4 4.6-8.4 2.2-17.3-2-9.3-10.3-13.6-10.3-13.6-9.9-6.4-23.8-12.4-23.8-12.4s0-3.3-.9-5.8-2.3-4.2-3.2-5.1c3.6-7.6 7.4-15.1 11-22.6l61.8 29.9s10.3 4.6 12.5 13.2c1.5 6-.4 11.4-1.5 14" style="fill:#609926"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

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

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

View File

@ -72,58 +72,63 @@ const ForgotPassword = () => {
}; };
return ( return (
<Card className='min-w-xs md:min-w-sm'>
<CardHeader> <div className='w-full mx-auto text-center pt-2 md:pt-10'>
<CardTitle className='text-2xl font-medium'>Reset Password</CardTitle> <div className='mx-auto flex flex-col items-center justify-center'>
<CardDescription className='text-sm text-foreground'> <Card className='min-w-xs sm:min-w-sm sm:max-w-xs max-w-lg'>
Don&apos;t have an account?{' '} <CardHeader>
<Link className='font-medium underline' href='/sign-up'> <CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
Sign up <CardDescription className='text-sm text-foreground'>
</Link> Don&apos;t have an account?{' '}
</CardDescription> <Link className='font-medium underline' href='/sign-up'>
</CardHeader> Sign up
<CardContent> </Link>
<Form {...form}> </CardDescription>
<form </CardHeader>
onSubmit={form.handleSubmit(handleForgotPassword)} <CardContent>
className='flex flex-col min-w-64 space-y-6' <Form {...form}>
> <form
<FormField onSubmit={form.handleSubmit(handleForgotPassword)}
control={form.control} className='flex flex-col min-w-64 space-y-6'
name='email' >
render={({ field }) => ( <FormField
<FormItem> control={form.control}
<FormLabel>Email</FormLabel> name='email'
<FormControl> render={({ field }) => (
<Input <FormItem>
type='email' <FormLabel>Email</FormLabel>
placeholder='you@example.com' <FormControl>
{...field} <Input
/> type='email'
</FormControl> placeholder='you@example.com'
<FormMessage /> {...field}
</FormItem> />
)} </FormControl>
/> <FormMessage />
<SubmitButton </FormItem>
disabled={isLoading} )}
pendingText='Resetting Password...' />
> <SubmitButton
Reset Password disabled={isLoading}
</SubmitButton> pendingText='Resetting Password...'
{statusMessage && >
(statusMessage.includes('Error') || Reset Password
statusMessage.includes('error') || </SubmitButton>
statusMessage.includes('failed') || {statusMessage &&
statusMessage.includes('invalid') ? ( (statusMessage.includes('Error') ||
<StatusMessage message={{ error: statusMessage }} /> statusMessage.includes('error') ||
) : ( statusMessage.includes('failed') ||
<StatusMessage message={{ success: statusMessage }} /> statusMessage.includes('invalid') ? (
))} <StatusMessage message={{ error: statusMessage }} />
</form> ) : (
</Form> <StatusMessage message={{ success: statusMessage }} />
</CardContent> ))}
</Card> </form>
</Form>
</CardContent>
</Card>
</div>
</div>
); );
}; };
export default ForgotPassword; export default ForgotPassword;

View File

@ -24,8 +24,7 @@ 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';
import { SignInWithMicrosoft } from '@/components/default/auth/SignInWithMicrosoft'; import { SignInWithApple, SignInWithMicrosoft } from '@/components/default/auth';
import { SignInWithApple } from '@/components/default/auth/SignInWithApple';
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ email: z.string().email({
@ -78,93 +77,98 @@ const Login = () => {
}; };
return ( return (
<Card className='min-w-xs md:min-w-sm'>
<CardHeader>
<CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
<CardDescription className='text-foreground'>
Don&apos;t have an account?{' '}
<Link className='font-medium underline' href='/sign-up'>
Sign up
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSignIn)}
className='flex flex-col min-w-64 space-y-6 pb-4'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <div className='w-full mx-auto text-center pt-2 md:pt-10'>
control={form.control} <div className='mx-auto flex flex-col items-center justify-center'>
name='password' <Card className='min-w-xs md:min-w-sm sm:max-w-xs max-w-lg'>
render={({ field }) => ( <CardHeader>
<FormItem> <CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
<div className='flex justify-between'> <CardDescription className='text-foreground'>
<FormLabel className='text-lg'>Password</FormLabel> Don&apos;t have an account?{' '}
<Link <Link className='font-medium underline' href='/sign-up'>
className='text-xs text-foreground underline text-right' Sign up
href='/forgot-password' </Link>
> </CardDescription>
Forgot Password? </CardHeader>
</Link> <CardContent>
</div> <Form {...form}>
<FormControl> <form
<Input onSubmit={form.handleSubmit(handleSignIn)}
type='password' className='flex flex-col min-w-64 space-y-6 pb-4'
placeholder='Your password' >
{...field} <FormField
/> control={form.control}
</FormControl> name='email'
<FormMessage /> render={({ field }) => (
</FormItem> <FormItem>
)} <FormLabel className='text-lg'>Email</FormLabel>
/> <FormControl>
{statusMessage && <Input
(statusMessage.includes('Error') || type='email'
statusMessage.includes('error') || placeholder='you@example.com'
statusMessage.includes('failed') || {...field}
statusMessage.includes('invalid') ? ( />
<StatusMessage message={{ error: statusMessage }} /> </FormControl>
) : ( <FormMessage />
<StatusMessage message={{ message: statusMessage }} /> </FormItem>
))} )}
<SubmitButton />
disabled={isLoading}
pendingText='Signing In...'
className='text-[1.0rem] cursor-pointer'
>
Sign in
</SubmitButton>
</form>
</Form>
<div className='flex items-center w-full gap-4'> <FormField
<Separator className='flex-1 bg-accent py-0.5' /> control={form.control}
<span className='text-sm text-muted-foreground'>or</span> name='password'
<Separator className='flex-1 bg-accent py-0.5' /> render={({ field }) => (
</div> <FormItem>
<SignInWithMicrosoft /> <div className='flex justify-between'>
<SignInWithApple /> <FormLabel className='text-lg'>Password</FormLabel>
</CardContent> <Link
</Card> className='text-xs text-foreground underline text-right'
href='/forgot-password'
>
Forgot Password?
</Link>
</div>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ message: statusMessage }} />
))}
<SubmitButton
disabled={isLoading}
pendingText='Signing In...'
className='text-[1.0rem] cursor-pointer'
>
Sign in
</SubmitButton>
</form>
</Form>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft />
<SignInWithApple />
</CardContent>
</Card>
</div>
</div>
); );
}; };

View File

@ -99,112 +99,116 @@ const SignUp = () => {
}; };
return ( return (
<Card className='min-w-xs md:min-w-sm'> <div className='w-full mx-auto text-center pt-2 md:pt-10'>
<CardHeader> <div className='mx-auto flex flex-col items-center justify-center'>
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle> <Card className='min-w-xs md:min-w-sm sm:max-w-xs lg:max-w-lg'>
<CardDescription className='text-foreground'> <CardHeader>
Already have an account?{' '} <CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
<Link className='text-primary font-medium underline' href='/sign-in'> <CardDescription className='text-foreground'>
Sign in Already have an account?{' '}
</Link> <Link className='text-primary font-medium underline' href='/sign-in'>
</CardDescription> Sign in
</CardHeader> </Link>
<CardContent> </CardDescription>
<Form {...form}> </CardHeader>
<form <CardContent>
onSubmit={form.handleSubmit(handleSignUp)} <Form {...form}>
className='flex flex-col mx-auto space-y-4 mb-4' <form
> onSubmit={form.handleSubmit(handleSignUp)}
<FormField className='flex flex-col mx-auto space-y-4 mb-4'
control={form.control} >
name='name' <FormField
render={({ field }) => ( control={form.control}
<FormItem> name='name'
<FormLabel className='text-lg'>Name</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input type='text' placeholder='Full Name' {...field} /> <FormLabel className='text-lg'>Name</FormLabel>
</FormControl> <FormControl>
</FormItem> <Input type='text' placeholder='Full Name' {...field} />
)} </FormControl>
/> </FormItem>
<FormField )}
control={form.control} />
name='email' <FormField
render={({ field }) => ( control={form.control}
<FormItem> name='email'
<FormLabel className='text-lg'>Email</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input <FormLabel className='text-lg'>Email</FormLabel>
type='email' <FormControl>
placeholder='you@example.com' <Input
{...field} type='email'
/> placeholder='you@example.com'
</FormControl> {...field}
<FormMessage /> />
</FormItem> </FormControl>
)} <FormMessage />
/> </FormItem>
<FormField )}
control={form.control} />
name='password' <FormField
render={({ field }) => ( control={form.control}
<FormItem> name='password'
<FormLabel className='text-lg'>Password</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input <FormLabel className='text-lg'>Password</FormLabel>
type='password' <FormControl>
placeholder='Your password' <Input
{...field} type='password'
/> placeholder='Your password'
</FormControl> {...field}
<FormMessage /> />
</FormItem> </FormControl>
)} <FormMessage />
/> </FormItem>
<FormField )}
control={form.control} />
name='confirmPassword' <FormField
render={({ field }) => ( control={form.control}
<FormItem> name='confirmPassword'
<FormLabel className='text-lg'>Confirm Password</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input <FormLabel className='text-lg'>Confirm Password</FormLabel>
type='password' <FormControl>
placeholder='Confirm password' <Input
{...field} type='password'
/> placeholder='Confirm password'
</FormControl> {...field}
<FormMessage /> />
</FormItem> </FormControl>
)} <FormMessage />
/> </FormItem>
{statusMessage && )}
(statusMessage.includes('Error') || />
statusMessage.includes('error') || {statusMessage &&
statusMessage.includes('failed') || (statusMessage.includes('Error') ||
statusMessage.includes('invalid') ? ( statusMessage.includes('error') ||
<StatusMessage message={{ error: statusMessage }} /> statusMessage.includes('failed') ||
) : ( statusMessage.includes('invalid') ? (
<StatusMessage message={{ success: statusMessage }} /> <StatusMessage message={{ error: statusMessage }} />
))} ) : (
<SubmitButton <StatusMessage message={{ success: statusMessage }} />
className='text-[1.0rem] cursor-pointer' ))}
disabled={isLoading} <SubmitButton
pendingText='Signing Up...' className='text-[1.0rem] cursor-pointer'
> disabled={isLoading}
Sign Up pendingText='Signing Up...'
</SubmitButton> >
</form> Sign Up
</Form> </SubmitButton>
<div className='flex items-center w-full gap-4'> </form>
<Separator className='flex-1 bg-accent py-0.5' /> </Form>
<span className='text-sm text-muted-foreground'>or</span> <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' />
</div> <span className='text-sm text-muted-foreground'>or</span>
<SignInWithMicrosoft /> <Separator className='flex-1 bg-accent py-0.5' />
<SignInWithApple /> </div>
</CardContent> <SignInWithMicrosoft type='signUp' />
</Card> <SignInWithApple type='signUp' />
</CardContent>
</Card>
</div>
</div>
); );
}; };
export default SignUp; export default SignUp;

View File

@ -1,16 +0,0 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
class SentryExampleAPIError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = 'SentryExampleAPIError';
}
}
// A faulty API route to test Sentry's error monitoring
export function GET() {
throw new SentryExampleAPIError(
'This error is raised on the backend called by the example page.',
);
return NextResponse.json({ data: 'Testing Sentry Error...' });
}

View File

@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
class SentryExampleAPIError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = 'SentryExampleAPIError';
}
}
// A faulty API route to test Sentry's error monitoring
export function GET() {
throw new SentryExampleAPIError(
'This error is raised on the backend called by the example page.',
);
return NextResponse.json({ data: 'Testing Sentry Error...' });
}

View File

@ -3,7 +3,7 @@
import '@/styles/globals.css'; import '@/styles/globals.css';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AuthProvider, ThemeProvider } from '@/components/context'; import { AuthProvider, ThemeProvider } from '@/components/context';
import Navigation from '@/components/default/navigation'; import Header from '@/components/default/header';
import Footer from '@/components/default/footer'; import Footer from '@/components/default/footer';
import { Button, Toaster } from '@/components/ui'; import { Button, Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
@ -12,53 +12,53 @@ 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 /> <Header />
<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>
); );
}; };
export default GlobalError; export default GlobalError;

View File

@ -3,451 +3,421 @@ import '@/styles/globals.css';
import { Geist } from 'next/font/google'; import { Geist } from 'next/font/google';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
AuthProvider, AuthProvider,
ThemeProvider, ThemeProvider,
TVModeProvider, TVModeProvider,
} from '@/components/context'; } from '@/components/context';
import Navigation from '@/components/default/navigation'; import PlausibleProvider from 'next-plausible';
import Header from '@/components/default/header';
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 | Tech Tracker', template: '%s | Tech Tracker',
default: 'Tech Tracker', default: 'Tech Tracker',
}, },
description: 'Created by Gib with Next.js & Supabase!', description:
applicationName: 'Tech Tracker', 'App used by COG IT employees to \
keywords: update their status throughout the day.',
'Tech Tracker, City of Gulfport, Information Technology, T3 Template, ' + applicationName: 'Tech Tracker',
'Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib', keywords:
authors: [{ name: 'Gib', url: 'https://gbrown.org' }], 'Tech Tracker, City of Gulfport, Information Technology, T3 Template, ' +
creator: 'Gib Brown', 'Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib',
publisher: 'Gib Brown', authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
formatDetection: { creator: 'Gib Brown',
email: false, publisher: 'Gib Brown',
address: false, formatDetection: {
telephone: false, email: false,
}, address: false,
robots: { telephone: false,
index: true, },
follow: true, robots: {
nocache: false, index: true,
googleBot: { follow: true,
index: true, nocache: false,
follow: true, googleBot: {
noimageindex: false, index: true,
'max-video-preview': -1, follow: true,
'max-image-preview': 'large', noimageindex: false,
'max-snippet': -1, 'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
icons: {
icon: [
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
{
url: '/favicon-16x16.png',
type: 'image/png',
sizes: '16x16',
},
{
url: '/favicon-32x32.png',
type: 'image/png',
sizes: '32x32',
},
{ url: '/favicon.png', type: 'image/png', sizes: '96x96' },
{
url: '/favicon.ico',
type: 'image/x-icon',
sizes: 'any',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-16x16.png',
type: 'image/png',
sizes: '16x16',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-32x32.png',
type: 'image/png',
sizes: '32x32',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
},
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
shortcut: [
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
},
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
apple: [
{
url: 'appicon/icon-57x57.png',
type: 'image/png',
sizes: '57x57',
},
{
url: 'appicon/icon-60x60.png',
type: 'image/png',
sizes: '60x60',
},
{
url: 'appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
},
{
url: 'appicon/icon-76x76.png',
type: 'image/png',
sizes: '76x76',
},
{
url: 'appicon/icon-114x114.png',
type: 'image/png',
sizes: '114x114',
},
{
url: 'appicon/icon-120x120.png',
type: 'image/png',
sizes: '120x120',
},
{
url: 'appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: 'appicon/icon-152x152.png',
type: 'image/png',
sizes: '152x152',
},
{
url: 'appicon/icon-180x180.png',
type: 'image/png',
sizes: '180x180',
},
{
url: 'appicon/icon.png',
type: 'image/png',
sizes: '192x192',
},
{
url: 'appicon/icon-57x57.png',
type: 'image/png',
sizes: '57x57',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-60x60.png',
type: 'image/png',
sizes: '60x60',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-76x76.png',
type: 'image/png',
sizes: '76x76',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-114x114.png',
type: 'image/png',
sizes: '114x114',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-120x120.png',
type: 'image/png',
sizes: '120x120',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-152x152.png',
type: 'image/png',
sizes: '152x152',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-180x180.png',
type: 'image/png',
sizes: '180x180',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
other: [
{
rel: 'apple-touch-icon-precomposed',
url: '/appicon/icon-precomposed.png',
type: 'image/png',
sizes: '180x180',
},
],
},
other: {
...Sentry.getTraceData(),
},
appleWebApp: {
title: 'Tech Tracker',
statusBarStyle: 'black-translucent',
startupImage: [
'/icons/apple/splash-768x1004.png',
{
url: '/icons/apple/splash-1536x2008.png',
media: '(device-width: 768px) and (device-height: 1024px)',
},
],
},
verification: {
google: 'google',
yandex: 'yandex',
yahoo: 'yahoo',
},
category: 'technology',
/*
appLinks: {
ios: {
url: 'https://techtracker.gbrown.org/ios',
app_store_id: 'com.gbrown.techtracker',
},
android: {
package: 'https://techtracker.gbrown.org/android',
app_name: 'app_t3_template',
},
web: {
url: 'https://techtracker.gbrown.org',
should_fallback: true,
}, },
}, },
icons: { */
icon: [ };
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
{
url: '/favicon-16x16.png',
type: 'image/png',
sizes: '16x16',
},
{
url: '/favicon-32x32.png',
type: 'image/png',
sizes: '32x32',
},
{ url: '/favicon.png', type: 'image/png', sizes: '96x96' },
{
url: '/favicon.ico',
type: 'image/x-icon',
sizes: 'any',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-16x16.png',
type: 'image/png',
sizes: '16x16',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-32x32.png',
type: 'image/png',
sizes: '32x32',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
},
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
shortcut: [
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
},
{
url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
apple: [
{
url: 'appicon/icon-57x57.png',
type: 'image/png',
sizes: '57x57',
},
{
url: 'appicon/icon-60x60.png',
type: 'image/png',
sizes: '60x60',
},
{
url: 'appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
},
{
url: 'appicon/icon-76x76.png',
type: 'image/png',
sizes: '76x76',
},
{
url: 'appicon/icon-114x114.png',
type: 'image/png',
sizes: '114x114',
},
{
url: 'appicon/icon-120x120.png',
type: 'image/png',
sizes: '120x120',
},
{
url: 'appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: 'appicon/icon-152x152.png',
type: 'image/png',
sizes: '152x152',
},
{
url: 'appicon/icon-180x180.png',
type: 'image/png',
sizes: '180x180',
},
{
url: 'appicon/icon.png',
type: 'image/png',
sizes: '192x192',
},
{
url: 'appicon/icon-57x57.png',
type: 'image/png',
sizes: '57x57',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-60x60.png',
type: 'image/png',
sizes: '60x60',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-76x76.png',
type: 'image/png',
sizes: '76x76',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-114x114.png',
type: 'image/png',
sizes: '114x114',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-120x120.png',
type: 'image/png',
sizes: '120x120',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-152x152.png',
type: 'image/png',
sizes: '152x152',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-180x180.png',
type: 'image/png',
sizes: '180x180',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
other: [
{
rel: 'apple-touch-icon-precomposed',
url: '/appicon/icon-precomposed.png',
type: 'image/png',
sizes: '180x180',
},
],
},
other: {
...Sentry.getTraceData(),
},
//twitter: {
//card: 'app',
//title: 'Tech Tracker',
//description: 'Created by Gib with Next.js & Supabase!',
//siteId: '',
//creator: '@cs_gib',
//creatorId: '',
//images: {
//url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
//alt: 'Tech Tracker',
//},
//app: {
//name: 'Tech Tracker',
//id: {
//iphone: '',
//ipad: '',
//googleplay: '',
//},
//url: {
//iphone: '',
//ipad: '',
//googleplay: '',
//},
//},
//},
//verification: {
//google: 'google',
//yandex: 'yandex',
//yahoo: 'yahoo',
//},
//itunes: {
//appId: '',
//appArgument: '',
//},
//appleWebApp: {
//title: 'Tech Tracker',
//statusBarStyle: 'black-translucent',
//startupImage: [
//'/icons/apple/splash-768x1004.png',
//{
//url: '/icons/apple/splash-1536x2008.png',
//media: '(device-width: 768px) and (device-height: 1024px)',
//},
//],
//},
//appLinks: {
//ios: {
//url: 'https://techtracker.gbrown.org/ios',
//app_store_id: 'com.gbrown.techtracker',
//},
//android: {
//package: 'https://techtracker.gbrown.org/android',
//app_name: 'app_t3_template',
//},
//web: {
//url: 'https://techtracker.gbrown.org',
//should_fallback: true,
//},
//},
//facebook: {
//appId: '',
//},
//pinterest: {
//richPin: true,
//},
category: 'technology',
};
}; };
const geist = Geist({ const geist = Geist({
subsets: ['latin'], subsets: ['latin'],
variable: '--font-geist-sans', variable: '--font-geist-sans',
}); });
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return ( return (
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning> <html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
<body <body
className={cn('bg-background text-foreground font-sans antialiased')} className={cn('bg-background text-foreground font-sans antialiased')}
> >
<ThemeProvider <ThemeProvider
attribute='class' attribute='class'
defaultTheme='system' defaultTheme='system'
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<AuthProvider> <AuthProvider>
<TVModeProvider> <PlausibleProvider
<main className='min-h-screen flex flex-col items-center'> domain='techtracker.gbrown.org'
<div className='flex-1 w-full flex flex-col gap-20 items-center'> customDomain='https://plausible.gbrown.org'
<Navigation /> trackOutboundLinks={true}
<div selfHosted={true}
className='flex flex-col gap-20 max-w-5xl >
p-5 w-full items-center' <TVModeProvider>
> <div className='min-h-screen'>
{children} <Header />
</div> {children}
</div> <Toaster />
<Footer /> </div>
</main> <Footer />
<Toaster /> </TVModeProvider>
</TVModeProvider> </PlausibleProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
); );
}; };
export default RootLayout; export default RootLayout;

View File

@ -1,96 +1,15 @@
'use server'; 'use server';
import { FetchDataSteps } from '@/components/default/tutorial';
import { InfoIcon } from 'lucide-react';
import { getUser } from '@/lib/actions'; import { getUser } from '@/lib/actions';
import type { User } from '@/utils/supabase'; import type { User } from '@/utils/supabase';
import { TestSentryCard } from '@/components/default/sentry'; import { redirect } from 'next/navigation';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from '@/components/ui';
import {
SignInSignUp,
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const HomePage = async () => { const HomePage = async () => {
const response = await getUser(); const response = await getUser();
if (!response.success || !response.data) { if (!response.success || !response.data) {
return ( redirect('/sign-in');
<main className='w-full items-center justify-center'> }
<div className='flex flex-col p-5 items-center justify-center space-y-6'> const user: User = response.data;
<Card className='md:min-w-2xl'> return <NoSession />;
<CardHeader className='flex flex-col items-center'>
<CardTitle className='text-3xl'>
Welcome to the T3 Supabase Template!
</CardTitle>
<CardDescription className='text-[1.0rem] mb-2'>
A great place to start is by creating a new user account &
ensuring you can sign up! If you already have an account, go
ahead and sign in!
</CardDescription>
<SignInSignUp
className='flex gap-4 w-full justify-center'
signInSize='xl'
signUpSize='xl'
/>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<div className='flex gap-4'>
<SignInWithMicrosoft buttonSize='lg' />
<SignInWithApple buttonSize='lg' />
</div>
</CardHeader>
<Separator className='bg-accent' />
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'>
<CardTitle className='text-lg mb-6 w-2/3 text-center'>
You can also test out your connection to Sentry if you want to
start there!
</CardTitle>
<TestSentryCard />
</CardContent>
</Card>
</div>
</main>
);
}
const user: User = response.data;
return (
<div className='flex-1 w-full flex flex-col gap-12'>
<div className='w-full'>
<div
className='bg-accent text-sm p-3 px-5
rounded-md text-foreground flex gap-3 items-center'
>
<InfoIcon size='16' strokeWidth={2} />
This is a protected component that you can only see as an
authenticated user
</div>
</div>
<div className='flex flex-col gap-2 items-start'>
<h2 className='font-bold text-3xl mb-4'>Your user details</h2>
<pre
className='text-sm font-mono p-3 rounded
border max-h-50 overflow-auto'
>
{JSON.stringify(user, null, 2)}
</pre>
</div>
<TestSentryCard />
<div>
<h2 className='font-bold text-2xl mb-4'>Next steps</h2>
<FetchDataSteps />
</div>
</div>
);
}; };
export default HomePage; export default HomePage;

View File

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

View File

@ -8,72 +8,72 @@ import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority'; import { type VariantProps } from 'class-variance-authority';
type TVModeContextProps = { type TVModeContextProps = {
tvMode: boolean; tvMode: boolean;
toggleTVMode: () => void; toggleTVMode: () => void;
}; };
type TVToggleProps = { type TVToggleProps = {
className?: ComponentProps<'button'>['className']; className?: ComponentProps<'button'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size']; buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant']; buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
imageWidth?: number; imageWidth?: number;
imageHeight?: number; imageHeight?: number;
}; };
const TVModeContext = createContext<TVModeContextProps | undefined>(undefined); const TVModeContext = createContext<TVModeContextProps | undefined>(undefined);
export const TVModeProvider = ({ children }: { children: ReactNode }) => { export const TVModeProvider = ({ children }: { children: ReactNode }) => {
const [tvMode, setTVMode] = useState(false); const [tvMode, setTVMode] = useState(false);
const toggleTVMode = () => { const toggleTVMode = () => {
setTVMode((prev) => !prev); setTVMode((prev) => !prev);
}; };
return ( return (
<TVModeContext.Provider value={{ tvMode, toggleTVMode }}> <TVModeContext.Provider value={{ tvMode, toggleTVMode }}>
{children} {children}
</TVModeContext.Provider> </TVModeContext.Provider>
); );
}; };
export const useTVMode = () => { export const useTVMode = () => {
const context = useContext(TVModeContext); const context = useContext(TVModeContext);
if (!context) { if (!context) {
throw new Error('useTVMode must be used within a TVModeProvider'); throw new Error('useTVMode must be used within a TVModeProvider');
} }
return context; return context;
}; };
export const TVToggle = ({ export const TVToggle = ({
className = 'my-auto cursor-pointer', className = 'my-auto cursor-pointer',
buttonSize = 'default', buttonSize = 'default',
buttonVariant = 'link', buttonVariant = 'link',
imageWidth = 25, imageWidth = 25,
imageHeight = 25, imageHeight = 25,
}: TVToggleProps) => { }: TVToggleProps) => {
const { tvMode, toggleTVMode } = useTVMode(); const { tvMode, toggleTVMode } = useTVMode();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
if (!isAuthenticated) return <div />; if (!isAuthenticated) return <div />;
return ( return (
<Button <Button
onClick={toggleTVMode} onClick={toggleTVMode}
className={className} className={className}
size={buttonSize} size={buttonSize}
variant={buttonVariant} variant={buttonVariant}
> >
{tvMode ? ( {tvMode ? (
<Image <Image
src='/icons/tv/exit.svg' src='/icons/tv/exit.svg'
alt='Exit TV Mode' alt='Exit TV Mode'
width={imageWidth} width={imageWidth}
height={imageHeight} height={imageHeight}
/> />
) : ( ) : (
<Image <Image
src='/icons/tv/enter.svg' src='/icons/tv/enter.svg'
alt='Exit TV Mode' alt='Exit TV Mode'
width={imageWidth} width={imageWidth}
height={imageHeight} height={imageHeight}
/> />
)} )}
</Button> </Button>
); );
}; };

View File

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

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

@ -0,0 +1,11 @@
import { SignUpCard } from './cards/SignUp';
export default function NoSession() {
return (
<div className='w-full mx-auto text-center pt-2 md:pt-10'>
<div className='mx-auto flex flex-col items-center justify-center'>
<SignUpCard />
</div>
</div>
);
}

View File

@ -1,33 +0,0 @@
'use client';
import Link from 'next/link';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInSignUpProps = {
className?: ComponentProps<'div'>['className'];
signInSize?: VariantProps<typeof buttonVariants>['size'];
signUpSize?: VariantProps<typeof buttonVariants>['size'];
signInVariant?: VariantProps<typeof buttonVariants>['variant'];
signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInSignUp = ({
className = 'flex gap-2',
signInSize = 'default',
signUpSize = 'sm',
signInVariant = 'outline',
signUpVariant = 'default',
}: SignInSignUpProps) => {
return (
<div className={className}>
<Button asChild size={signInSize} variant={signInVariant}>
<Link href='/sign-in'>Sign In</Link>
</Button>
<Button asChild size={signUpSize} variant={signUpVariant}>
<Link href='/sign-up'>Sign Up</Link>
</Button>
</div>
);
};

View File

@ -1,77 +0,0 @@
'use client';
import { signInWithApple } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithAppleProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithApple = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithAppleProps) => {
const router = useRouter();
const { isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithApple = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithApple();
if (result?.success && result.data) {
// Redirect to Apple OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error signing in with Apple!`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
} finally {
setIsSigningIn(false);
await refreshUserData();
router.push('');
}
};
return (
<form onSubmit={handleSignInWithApple} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/apple.svg'
alt='Apple logo'
className='invert-75 dark:invert-25'
width={22}
height={22}
/>
<p className='text-[1.0rem]'>Sign In with Apple</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -1,70 +0,0 @@
'use client';
import { signInWithMicrosoft } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithMicrosoftProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithMicrosoft = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithMicrosoftProps) => {
const { isLoading } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithMicrosoft = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithMicrosoft();
if (result?.success && result.data) {
// Redirect to Microsoft OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error: Could not sign in with Microsoft!`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<form onSubmit={handleSignInWithMicrosoft} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/microsoft.svg'
alt='Microsoft logo'
width={20}
height={20}
/>
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -0,0 +1,33 @@
'use client';
import Link from 'next/link';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInSignUpProps = {
className?: ComponentProps<'div'>['className'];
signInSize?: VariantProps<typeof buttonVariants>['size'];
signUpSize?: VariantProps<typeof buttonVariants>['size'];
signInVariant?: VariantProps<typeof buttonVariants>['variant'];
signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInSignUp = ({
className = 'flex gap-2',
signInSize = 'default',
signUpSize = 'sm',
signInVariant = 'outline',
signUpVariant = 'default',
}: SignInSignUpProps) => {
return (
<div className={className}>
<Button asChild size={signInSize} variant={signInVariant}>
<Link href='/sign-in'>Sign In</Link>
</Button>
<Button asChild size={signUpSize} variant={signUpVariant}>
<Link href='/sign-up'>Sign Up</Link>
</Button>
</div>
);
};

View File

@ -0,0 +1,77 @@
'use client';
import { signInWithApple } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithAppleProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithApple = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithAppleProps) => {
const router = useRouter();
const { isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithApple = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithApple();
if (result?.success && result.data) {
// Redirect to Apple OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error signing in with Apple!`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
} finally {
setIsSigningIn(false);
await refreshUserData();
router.push('');
}
};
return (
<form onSubmit={handleSignInWithApple} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/apple.svg'
alt='Apple logo'
className='invert-75 dark:invert-25'
width={22}
height={22}
/>
<p className='text-[1.0rem]'>Sign In with Apple</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -0,0 +1,70 @@
'use client';
import { signInWithMicrosoft } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithMicrosoftProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithMicrosoft = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithMicrosoftProps) => {
const { isLoading } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithMicrosoft = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithMicrosoft();
if (result?.success && result.data) {
// Redirect to Microsoft OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error: Could not sign in with Microsoft!`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<form onSubmit={handleSignInWithMicrosoft} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/microsoft.svg'
alt='Microsoft logo'
width={20}
height={20}
/>
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -0,0 +1,128 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@/components/ui';
import Link from 'next/link';
import { forgotPassword } from '@/lib/actions';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
const formSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
});
export const ForgotPasswordCard = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
},
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleForgotPassword = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('email', values.email);
const result = await forgotPassword(formData);
if (result?.success) {
await refreshUserData();
setStatusMessage(
result?.data ?? 'Check your email for a link to reset your password.',
);
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<Card className='min-w-xs md:min-w-sm'>
<CardHeader>
<CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
<CardDescription className='text-sm text-foreground'>
Don&apos;t have an account?{' '}
<Link className='font-medium underline' href='/sign-up'>
Sign up
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleForgotPassword)}
className='flex flex-col min-w-64 space-y-6'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SubmitButton
disabled={isLoading}
pendingText='Resetting Password...'
>
Reset Password
</SubmitButton>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ success: statusMessage }} />
))}
</form>
</Form>
</CardContent>
</Card>
);
};

View File

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

View File

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

View File

@ -0,0 +1,209 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import Link from 'next/link';
import { signUp } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
} from '@/components/ui';
import { useEffect, useState } from 'react';
import {
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const formSchema = z
.object({
name: z.string().min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
confirmPassword: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
export const SignUpCard = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
mode: 'onChange',
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleSignUp = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('name', values.name);
formData.append('email', values.email);
formData.append('password', values.password);
const result = await signUp(formData);
if (result?.success) {
await refreshUserData();
setStatusMessage(
result.data ??
'Thanks for signing up! Please check your email for a verification link.',
);
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<Card className='min-w-xs md:min-w-sm'>
<CardHeader>
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
<CardDescription className='text-foreground'>
Already have an account?{' '}
<Link className='text-primary font-medium underline' href='/sign-in'>
Sign in
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSignUp)}
className='flex flex-col mx-auto space-y-4 mb-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Name</FormLabel>
<FormControl>
<Input type='text' placeholder='Full Name' {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Confirm Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ success: statusMessage }} />
))}
<SubmitButton
className='text-[1.0rem] cursor-pointer'
disabled={isLoading}
pendingText='Signing Up...'
>
Sign Up
</SubmitButton>
</form>
</Form>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft />
<SignInWithApple />
</CardContent>
</Card>
);
};

View File

@ -1,3 +1,8 @@
export * from './SignInSignUp'; export * from './NoSession';
export * from './SignInWithApple'; export * from './buttons/SignInSignUp';
export * from './SignInWithMicrosoft'; export * from './buttons/SignInWithApple';
export * from './buttons/SignInWithMicrosoft';
export * from './cards/ForgotPassword';
export * from './cards/Profile';
export * from './cards/SignIn';
export * from './cards/SignUp';

View File

@ -1,20 +1,43 @@
'use server'; 'use server';
import Link from 'next/link';
import Image from 'next/image';
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 h-16 flex items-center justify-between border-t bg-background px-8 mt-auto'>
<p> <div className='flex items-center'>
Powered by{' '} <Link
<a href='https://git.gibbyb.com/gib/tech-tracker-next'
href='https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs' className='text-sm font-semibold px-3 py-2 rounded-lg
target='_blank' bg-gradient-to-tl from-[#35363F] to-[#24191A] hover:text-sky-200
className='font-bold hover:underline' hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]
rel='noreferrer' flex items-center gap-2 transition-all duration-200'
> >
Supabase <Image src='/icons/gitea.svg' alt='Gitea' width={20} height={20} />
</a> <span>View Source Code on Gitea</span>
</p> </Link>
</footer> </div>
); <div className='flex-1 flex justify-center'>
<div className='text-xs text-center space-y-1'>
<p>
<strong>Tech Tracker</strong> - Built for City of Gulfport IT Department
</p>
<p className='text-muted-foreground'>
Open Source MIT Licensed Self-Hosted
<a
href='https://supabase.com'
target='_blank'
className='hover:underline ml-1'
rel='noreferrer'
>
Powered by Supabase
</a>
</p>
</div>
</div>
<div className='w-[160px]'></div> {/* Spacer to balance the layout */}
</footer>
);
}; };
export default Footer; export default Footer;

View File

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

View File

@ -5,13 +5,13 @@ import { SignInSignUp } from '@/components/default/auth';
import { useAuth } from '@/components/context'; import { useAuth } from '@/components/context';
const NavigationAuth = () => { const NavigationAuth = () => {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
return isAuthenticated ? ( return isAuthenticated ? (
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<AvatarDropdown /> <AvatarDropdown />
</div> </div>
) : ( ) : (
<SignInSignUp /> <SignInSignUp />
); );
}; };
export default NavigationAuth; export default NavigationAuth;

View File

@ -0,0 +1,48 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import NavigationAuth from './auth';
import { ThemeToggle, TVToggle, useTVMode } from '@/components/context';
const Header = () => {
const { tvMode } = useTVMode();
return tvMode ? (
<div className='absolute top-4 right-2'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4'>
<TVToggle />
</div>
</div>
) : (
<header className='w-full py-2 pt-6 md:py-5'>
<div className='absolute top-4 right-6'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4 md:pr-8'>
<TVToggle />
<ThemeToggle className='mx-2' />
</div>
</div>
<Link
href='/'
scroll={false}
className='flex flex-row items-center text-center
justify-center sm:ml-0 p-4 mt-10 sm:mt-0'
>
<Image
src='/favicon.png'
alt='Tech Tracker Logo'
width={100}
height={100}
className='max-w-[40px] md:max-w-[120px]'
/>
<h1
className='title-text text-sm md:text-4xl lg:text-8xl
bg-gradient-to-r from-[#bec8e6] via-[#F0EEE4] to-[#FFF8E7]
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
>
Tech Tracker
</h1>
</Link>
</header>
);
};
export default Header;

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

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

View File

@ -1,43 +0,0 @@
'use client'
import Image from 'next/image';
import Link from 'next/link';
import NavigationAuth from './auth';
import { ThemeToggle, TVToggle, useTVMode } from '@/components/context';
const Navigation = () => {
const { tvMode } = useTVMode();
if (tvMode) {
return (
<div className='absolute top-4 right-2'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4'>
<TVToggle />
</div>
</div>
);
}
return (
<nav
className='w-full flex justify-center
border-b border-b-foreground/10 h-16'
>
<div
className='w-full max-w-5xl flex justify-between
items-center p-3 px-5 text-sm'
>
<div className='flex gap-5 items-center font-semibold'>
<Link className='flex flex-row my-auto gap-2' href='/'>
<Image src='/favicon.png' alt='T3 Logo' width={50} height={50} />
<h1 className='my-auto text-2xl'>Tech Tracker</h1>
</Link>
</div>
<div className='flex items-center gap-2'>
<TVToggle />
<ThemeToggle />
<NavigationAuth />
</div>
</div>
</nav>
);
};
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'; 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'; 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,127 +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 &&
(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 }} />
))} ))}
<div className='flex justify-center'> <div className='flex justify-center'>
<SubmitButton <SubmitButton
disabled={isLoading} disabled={isLoading}
pendingText='Updating Password...' pendingText='Updating Password...'
> >
Update Password Update Password
</SubmitButton> </SubmitButton>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</div> </div>
); );
}; };

View File

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

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

@ -1,61 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui';
const CopyIcon = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<rect x='9' y='9' width='13' height='13' rx='2' ry='2'></rect>
<path d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'></path>
</svg>
);
const CheckIcon = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<polyline points='20 6 9 17 4 12'></polyline>
</svg>
);
export function CodeBlock({ code }: { code: string }) {
const [icon, setIcon] = useState(CopyIcon);
const copy = async () => {
await navigator?.clipboard?.writeText(code);
setIcon(CheckIcon);
setTimeout(() => setIcon(CopyIcon), 2000);
};
return (
<pre className='bg-muted rounded-md p-6 my-6 relative'>
<Button
size='icon'
onClick={copy}
variant={'outline'}
className='absolute right-2 top-2'
>
{icon}
</Button>
<code className='text-xs p-3'>{code}</code>
</pre>
);
}

View File

@ -1,95 +0,0 @@
import { TutorialStep, CodeBlock } from '@/components/default/tutorial';
const create = `create table notes (
id bigserial primary key,
title text
);
insert into notes(title)
values
('Today I created a Supabase project.'),
('I added some data and queried it from Next.js.'),
('It was awesome!');
`.trim();
const server = `import { createClient } from '@/utils/supabase/server'
export default async function Page() {
const supabase = await createClient()
const { data: notes } = await supabase.from('notes').select()
return <pre>{JSON.stringify(notes, null, 2)}</pre>
}
`.trim();
const client = `'use client'
import { createClient } from '@/utils/supabase/client'
import { useEffect, useState } from 'react'
export default function Page() {
const [notes, setNotes] = useState<any[] | null>(null)
const supabase = createClient()
useEffect(() => {
const getData = async () => {
const { data } = await supabase.from('notes').select()
setNotes(data)
}
getData()
}, [])
return <pre>{JSON.stringify(notes, null, 2)}</pre>
}
`.trim();
export const FetchDataSteps = () => {
return (
<ol className='flex flex-col gap-6'>
<TutorialStep title='Create some tables and insert some data'>
<p>
Head over to the{' '}
<a
href='https://supabase.com/dashboard/project/_/editor'
className='font-bold hover:underline text-foreground/80'
target='_blank'
rel='noreferrer'
>
Table Editor
</a>{' '}
for your Supabase project to create a table and insert some example
data. If you&apos;re stuck for creativity, you can copy and paste the
following into the{' '}
<a
href='https://supabase.com/dashboard/project/_/sql/new'
className='font-bold hover:underline text-foreground/80'
target='_blank'
rel='noreferrer'
>
SQL Editor
</a>{' '}
and click RUN!
</p>
<CodeBlock code={create} />
</TutorialStep>
<TutorialStep title='Query Supabase data from Next.js'>
<p>
To create a Supabase client and query data from an Async Server
Component, create a new page.tsx file at{' '}
<span className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs font-medium text-secondary-foreground border'>
/app/notes/page.tsx
</span>{' '}
and add the following.
</p>
<CodeBlock code={server} />
<p>Alternatively, you can use a Client Component.</p>
<CodeBlock code={client} />
</TutorialStep>
<TutorialStep title='Build in a weekend and scale to millions!'>
<p>You&apos;re ready to launch your product to the world! 🚀</p>
</TutorialStep>
</ol>
);
};

View File

@ -1,30 +0,0 @@
import { Checkbox } from '@/components/ui';
export const TutorialStep = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => {
return (
<li className='relative'>
<Checkbox
id={title}
name={title}
className={`absolute top-[3px] mr-2 peer`}
/>
<label
htmlFor={title}
className={`relative text-base text-foreground peer-checked:line-through font-medium`}
>
<span className='ml-8'>{title}</span>
<div
className={`ml-8 text-sm peer-checked:line-through font-normal text-muted-foreground`}
>
{children}
</div>
</label>
</li>
);
};

View File

@ -1,3 +0,0 @@
export { CodeBlock } from './CodeBlock';
export { FetchDataSteps } from './FetchDataSteps';
export { TutorialStep } from './TutorialStep';

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

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,148 @@ 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 origin = process.env.NEXT_PUBLIC_SITE_URL!;
provider: 'azure', const { data, error } = await supabase.auth.signInWithOAuth({
options: { provider: 'azure',
scopes: 'openid, profile email offline_access', options: {
}, scopes: 'openid, profile email offline_access',
}); redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
if (error) return { success: false, error: error.message }; },
return { success: true, data: data.url }; });
if (error) return { success: false, error: error.message };
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 origin = process.env.NEXT_PUBLIC_SITE_URL!;
provider: 'apple', const { data, error } = await supabase.auth.signInWithOAuth({
options: { provider: 'apple',
scopes: 'openid, profile email offline_access', options: {
}, redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
}); },
if (error) return { success: false, error: error.message }; });
return { success: true, data: data.url }; if (error) return { success: false, error: error.message };
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=/profile`,
}); });
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

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

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

@ -10,96 +10,96 @@ 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

@ -7,25 +7,25 @@ const bannedIPs = new Set<string>();
// Suspicious patterns that indicate malicious activity // Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [ const MALICIOUS_PATTERNS = [
/web-inf/i, /web-inf/i,
/\.jsp/i, /\.jsp/i,
/\.php/i, /\.php/i,
/puttest/i, /puttest/i,
/WEB-INF/i, /WEB-INF/i,
/\.xml$/i, /\.xml$/i,
/perl/i, /perl/i,
/xampp/i, /xampp/i,
/phpwebgallery/i, /phpwebgallery/i,
/FileManager/i, /FileManager/i,
/standalonemanager/i, /standalonemanager/i,
/h2console/i, /h2console/i,
/WebAdmin/i, /WebAdmin/i,
/login_form\.php/i, /login_form\.php/i,
/%2e/i, /%2e/i,
/%u002e/i, /%u002e/i,
/\.%00/i, /\.%00/i,
/\.\./, /\.\./,
/lcgi/i, /lcgi/i,
]; ];
// Suspicious HTTP methods // Suspicious HTTP methods
@ -36,98 +36,98 @@ const MAX_ATTEMPTS = 10; // Max suspicious requests per window
const BAN_DURATION = 30 * 60 * 1000; // 30 minutes const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
const getClientIP = (request: NextRequest): string => { const getClientIP = (request: NextRequest): string => {
const forwarded = request.headers.get('x-forwarded-for'); const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip'); const realIP = request.headers.get('x-real-ip');
if (forwarded) { if (forwarded) {
return forwarded.split(',')[0].trim(); return forwarded.split(',')[0].trim();
} }
if (realIP) { if (realIP) {
return realIP; return realIP;
} }
return request.ip ?? 'unknown'; return request.ip ?? 'unknown';
}; };
const isPathSuspicious = (pathname: string): boolean => { const isPathSuspicious = (pathname: string): boolean => {
return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname)); return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
}; };
const isMethodSuspicious = (method: string): boolean => { const isMethodSuspicious = (method: string): boolean => {
return SUSPICIOUS_METHODS.includes(method); return SUSPICIOUS_METHODS.includes(method);
}; };
const updateIPAttempts = (ip: string): boolean => { const updateIPAttempts = (ip: string): boolean => {
const now = Date.now(); const now = Date.now();
const attempts = ipAttempts.get(ip); const attempts = ipAttempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) { if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
ipAttempts.set(ip, { count: 1, lastAttempt: now }); ipAttempts.set(ip, { count: 1, lastAttempt: now });
return false; return false;
} }
attempts.count++; attempts.count++;
attempts.lastAttempt = now; attempts.lastAttempt = now;
if (attempts.count > MAX_ATTEMPTS) { if (attempts.count > MAX_ATTEMPTS) {
bannedIPs.add(ip); bannedIPs.add(ip);
// Clean up the attempts record // Clean up the attempts record
ipAttempts.delete(ip); ipAttempts.delete(ip);
// Auto-unban after duration (in production, use a proper scheduler) // Auto-unban after duration (in production, use a proper scheduler)
setTimeout(() => { setTimeout(() => {
bannedIPs.delete(ip); bannedIPs.delete(ip);
}, BAN_DURATION); }, BAN_DURATION);
return true; return true;
} }
return false; return false;
}; };
export const middleware = async (request: NextRequest) => { export const middleware = async (request: NextRequest) => {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
const method = request.method; const method = request.method;
const ip = getClientIP(request); const ip = getClientIP(request);
// Check if IP is already banned // Check if IP is already banned
if (bannedIPs.has(ip)) { if (bannedIPs.has(ip)) {
console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`); console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`);
return new NextResponse('Access denied.', { status: 403 }); return new NextResponse('Access denied.', { status: 403 });
} }
// Check for suspicious activity // Check for suspicious activity
const isSuspiciousPath = isPathSuspicious(pathname); const isSuspiciousPath = isPathSuspicious(pathname);
const isSuspiciousMethod = isMethodSuspicious(method); const isSuspiciousMethod = isMethodSuspicious(method);
if (isSuspiciousPath || isSuspiciousMethod) { if (isSuspiciousPath || isSuspiciousMethod) {
console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`); console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`);
const shouldBan = updateIPAttempts(ip); const shouldBan = updateIPAttempts(ip);
if (shouldBan) { if (shouldBan) {
console.log(`🔨 IP ${ip} has been banned for suspicious activity`); console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
return new NextResponse('Access denied - IP banned', { status: 403 }); return new NextResponse('Access denied - IP banned', { status: 403 });
} }
// Return 404 to not reveal the blocking mechanism // Return 404 to not reveal the blocking mechanism
return new NextResponse('Not Found', { status: 404 }); return new NextResponse('Not Found', { status: 404 });
} }
return await updateSession(request); return await updateSession(request);
}; };
export const config = { export const config = {
matcher: [ matcher: [
/* /*
* Match all request paths except: * Match all request paths except:
* - _next/static (static files) * - _next/static (static files)
* - _next/image (image optimization files) * - _next/image (image optimization files)
* - favicon.ico (favicon file) * - favicon.ico (favicon file)
* - /monitoring-tunnel (Sentry monitoring) * - /monitoring-tunnel (Sentry monitoring)
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp * - images - .svg, .png, .jpg, .jpeg, .gif, .webp
* Feel free to modify this pattern to include more paths. * Feel free to modify this pattern to include more paths.
*/ */
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', '/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
], ],
}; };

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