wipe out old repo & replace with template

This commit is contained in:
2025-06-09 05:57:10 -05:00
parent 4576ebdf88
commit 5f2d25f9dd
171 changed files with 9144 additions and 4691 deletions

View File

@ -1,14 +1,31 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js" # When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly. # should be updated accordingly.
# Example: # Example:
# SERVERVAR="foo" # SERVERVAR="foo"
# NEXT_PUBLIC_CLIENTVAR="bar" # NEXT_PUBLIC_CLIENTVAR="bar"
### Server Variables ###
# Next Variables # Default Values:
#NODE_ENV= # development
#SKIP_ENV_VALIDATION= # false
# Sentry Variables # Default Values:
SENTRY_AUTH_TOKEN=
#CI= # true
### Client Variables ###
# Next Variables # Default Values:
#NEXT_PUBLIC_SITE_URL= # http://localhost:3000
# Supabase Variables
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Sentry Variables
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_URL= # https://sentry.gbrown.org
### Script Variables ### These variables are only needed for our scripts, so do not add these to env.js! ###
# generateTypes # Default Values:
SUPABASE_DB_PASSWORD=
#SUPABASE_DB_PORT= # 5432
#SUPABASE_DB_USER= # postgres
#SUPABASE_DB_NAME= # postgres

View File

@ -1,40 +0,0 @@
/** @type {import("eslint").Linter.Config} */
const config = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: true,
},
plugins: ['@typescript-eslint'],
extends: [
'next/core-web-vitals',
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
],
rules: {
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
'@typescript-eslint/consistent-type-imports': [
'warn',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
},
],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
},
],
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: {
attributes: false,
},
},
],
},
};
module.exports = config;

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

View File

@ -1,36 +1,19 @@
<h1 align="center"> # T3 Template with Self Hosted Supabase
<br>
<a href="https://techtracker.gibbyb.com"><img src="https://git.gibbyb.com/gib/Tech_Tracker_Web/raw/branch/master/public/images/tech_tracker_logo.png" alt="Tech Tracker Logo" width="100"></a>
<br>
<b>Tech Tracker</b>
<br>
</h1>
# [Find Here](https://techtracker.gibbyb.com/) This is my template for self hosting both Next.js & Supabase in order to create a perfect app!!
- Application used by COG employees to update their status & location throughout the day. ## What to do
<details> - [Self Host Supabase](https://supabase.com/docs/guides/self-hosting/docker)
<summary> - You will need to make sure you have some way to connect to the postgres database from the host. I had to remove the database port from the supabase-pooler and add it to the supabase-db in order to directly connect to it. This will be important for generating our types.
<h3>How to run:</h3> - Clone this repo.
</summary> - Go to src/server/db/schema.sql & run this SQL in the SQL editor on the Web UI of your Supabase instance.
- Generate your types
I'd recommend installing pnpm. Clone the repo, then rename env.example to .env & fill it out. - This part is potentially super weird if you are self hosting. If you are connecting directly to your database that you plan to use for production, you will need to clone your repo on the host running supabase so that you can then use the supabase cli tool. Once you have done that, you will need to install the supabase-cli tool with sudo. I just run something like `sudo npx supabase --help` and then accept the prompt to install the program. Once you have done this, you can then run the following command, replacing the password and the port to match your supabase database. You can also try running the provided script `./scripts/generate_types`
```bash ```bash
mv ./env.example ./.env sudo npx supabase gen types typescript \
``` --db-url "postgres://postgres:password@localhost:5432/postgres" \
--schema public \
Run > ./src/lib/types
```bash
pnpm install
```
to install all dependencies.
You can run
```bash
pnpm dev
``` ```

View File

@ -4,7 +4,7 @@
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "tailwind.config.ts", "config": "",
"css": "src/styles/globals.css", "css": "src/styles/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,

53
eslint.config.js Normal file
View File

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

View File

@ -1,34 +1,50 @@
/** /* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful * This is especially useful for Docker builds.
* for Docker builds.
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs'; import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
// You can put your base config options here output: 'standalone',
images: { images: {
remotePatterns: [ remotePatterns: [
{
protocol: 'https',
hostname: '*.gibbyb.com',
},
{ {
protocol: 'https', protocol: 'https',
hostname: '*.gbrown.org', hostname: '*.gbrown.org',
}, },
], ],
}, },
serverExternalPackages: ['require-in-the-middle'],
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
turbopack: {
rules: {
'*.svg': {
loaders: [
{
loader: '@svgr/webpack',
options: {
icon: true,
},
},
],
as: '*.js',
},
},
},
}; };
// Sentry configuration
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: 'tech-tracker-next', project: 't3-supabase-template',
sentryUrl: 'https://sentry.gbrown.org/', sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
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:
@ -42,12 +58,10 @@ const sentryConfig = {
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,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) // Capture React Component Names
// See the following for more information: reactComponentAnnotation: {
// https://docs.sentry.io/product/crons/ enabled: true,
// https://vercel.com/docs/cron-jobs },
automaticVercelMonitors: true,
}; };
// Export the config with Sentry configuration
export default withSentryConfig(config, sentryConfig); export default withSentryConfig(config, sentryConfig);

469
output.md
View File

@ -1,469 +0,0 @@
src/app/layout.tsx
```tsx
import '@/styles/globals.css';
import { GeistSans } from 'geist/font/sans';
import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/Theme';
import { TVModeProvider } from '@/components/context/TVMode';
import { createClient } from '@/utils/supabase/server';
import LoginForm from '@/components/auth/LoginForm';
import Header from '@/components/defaults/Header';
import { type Metadata } from 'next';
export const metadata: Metadata = {
title: 'Tech Tracker',
description:
'App used by COG IT employees to \
update their status throughout the day.',
icons: [
{
rel: 'icon',
url: '/favicon.ico',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
url: '/images/tech_tracker_favicon.png',
},
{
rel: 'apple-touch-icon',
url: '/imges/tech_tracker_appicon.png',
},
],
};
const RootLayout = async ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return (
<html
lang='en'
className={`${GeistSans.variable}`}
suppressHydrationWarning
>
<body className={cn('min-h-screen bg-background font-sans antialiased')}>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<TVModeProvider>
<main className='min-h-screen'>
<Header />
{children}
</main>
</TVModeProvider>
</ThemeProvider>
</body>
</html>
);
};
export default RootLayout;
```
src/components/auth/AvatarDropdown.tsx
```tsx
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { createClient } from '@/utils/supabase/client';
import type { Session } from '@supabase/supabase-js';
import { Button } from '@/components/ui/button';
import type { User } from '@/lib/types';
import { getImageUrl } from '@/server/actions/image';
import { useRouter } from 'next/navigation';
const AvatarDropdown = () => {
const supabase = createClient();
const router = useRouter();
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [pfp, setPfp] = useState<string>('/images/default_user_pfp.png');
useEffect(() => {
// Function to fetch the session
async function fetchSession() {
try {
const {
data: { session },
} = await supabase.auth.getSession();
setSession(session);
if (session?.user?.id) {
const { data: userData, error } = await supabase
.from('profiles')
.select('*')
.eq('id', session?.user.id)
.single();
if (error) {
console.error('Error fetching user data:', error);
return;
}
if (userData) {
const user = userData as User;
console.log(user);
setUser(user);
}
}
} catch (error) {
console.error('Error fetching session:', error);
} finally {
setLoading(false);
}
}
// Call the function
fetchSession().catch((error) => {
console.error('Error fetching session:', error);
});
// Set up auth state change listener
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
});
// Clean up the subscription when component unmounts
return () => {
subscription.unsubscribe();
};
}, [supabase]);
useEffect(() => {
const downloadImage = async (path: string) => {
try {
setLoading(true);
const url = await getImageUrl('avatars', path);
console.log(url);
setPfp(url);
} catch (error) {
console.error(
'Error downloading image:',
error instanceof Error ? error.message : error,
);
} finally {
setLoading(false);
}
};
if (user?.avatar_url) {
try {
downloadImage(user.avatar_url).catch((error) => {
console.error('Error downloading image:', error);
});
} catch (error) {
console.error('Error: ', error);
}
}
}, [user, supabase]);
const getInitials = (fullName: string | undefined): string => {
if (!fullName || fullName.trim() === '' || fullName === 'undefined')
return 'NA';
const nameParts = fullName.trim().split(' ');
const firstInitial = nameParts[0]?.charAt(0).toUpperCase() ?? 'N';
if (nameParts.length === 1) return 'NA';
const lastIntitial =
nameParts[nameParts.length - 1]?.charAt(0).toUpperCase() ?? 'A';
return firstInitial + lastIntitial;
};
// Handle sign out
const handleSignOut = async () => {
await supabase.auth.signOut();
router.push('/');
};
// Show nothing while loading
if (loading) {
return <div className='animate-pulse h-8 w-8 rounded-full bg-gray-300' />;
}
// If no session, return empty div
if (!session) return <div />;
return (
<div className='m-auto mt-1'>
<DropdownMenu>
<DropdownMenuTrigger>
<Image
src={pfp}
alt={getInitials(user?.full_name) ?? 'NA'}
width={40}
height={40}
className='rounded-full border-2
border-muted-foreground m-auto mr-1 md:mr-2
max-w-[35px] sm:max-w-[40px]'
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{user?.full_name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button onClick={handleSignOut} className='w-full text-left'>
Sign Out
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
export default AvatarDropdown;
```
src/components/context/TVMode.tsx
```tsx
'use client';
import React, { createContext, useContext, useState } from 'react';
import Image from 'next/image';
import type { ReactNode } from 'react';
interface TVModeContextProps {
tvMode: boolean;
toggleTVMode: () => void;
}
const TVModeContext = createContext<TVModeContextProps | undefined>(undefined);
export const TVModeProvider = ({ children }: { children: ReactNode }) => {
const [tvMode, setTVMode] = useState(false);
const toggleTVMode = () => {
setTVMode((prev) => !prev);
};
return (
<TVModeContext.Provider value={{ tvMode, toggleTVMode }}>
{children}
</TVModeContext.Provider>
);
};
export const useTVMode = () => {
const context = useContext(TVModeContext);
if (!context) {
throw new Error('useTVMode must be used within a TVModeProvider');
}
return context;
};
type TVToggleProps = {
width?: number;
height?: number;
};
export const TVToggle = ({ width = 25, height = 25 }: TVToggleProps) => {
const { tvMode, toggleTVMode } = useTVMode();
return (
<button onClick={toggleTVMode} className='mr-4 mt-1'>
{tvMode ? (
<Image
src='/images/exit_fullscreen.svg'
alt='Exit TV Mode'
width={width}
height={height}
/>
) : (
<Image
src='/images/fullscreen.svg'
alt='Enter TV Mode'
width={width}
height={height}
/>
)}
</button>
);
};
```
src/components/defaults/Header.tsx
```tsx
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import { TVToggle, useTVMode } from '@/components/context/TVMode';
import { ThemeToggle } from '@/components/context/Theme';
import AvatarDropdown from '@/components/auth/AvatarDropdown';
import { createClient } from '@/utils/supabase/client';
import type { Session } from '@supabase/supabase-js';
const Header = () => {
const { tvMode } = useTVMode();
const supabase = createClient();
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Function to fetch the session
async function fetchSession() {
try {
const {
data: { session },
} = await supabase.auth.getSession();
setSession(session);
} catch (error) {
console.error('Error fetching session:', error);
} finally {
setLoading(false);
}
}
// Call the function
fetchSession().catch((error) => {
console.error('Error fetching session:', error);
});
// Set up auth state change listener
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
});
// Clean up the subscription when component unmounts
return () => {
subscription.unsubscribe();
};
}, [supabase]);
if (tvMode) {
return (
<div className='w-full flex flex-row items-end justify-end'>
<div
className='flex flex-row my-auto items-center
justify-center pt-2 pr-0 sm:pt-4 sm:pr-8'
>
<ThemeToggle />
{session && !loading && (
<div
className='flex flex-row my-auto items-center
justify-center'
>
<div className='mb-0.5 ml-4'>
<TVToggle width={22} height={22} />
</div>
<AvatarDropdown />
</div>
)}
</div>
</div>
);
} else {
return (
<header className='w-full min-h-[10vh]'>
<div className='w-full flex flex-row items-end justify-end'>
<div
className='flex flex-row my-auto items-center
justify-center pt-2 pr-0 sm:pt-4 sm:pr-8'
>
<ThemeToggle />
{session && !loading && (
<div
className='flex flex-row my-auto items-center
justify-center'
>
<div className='mb-0.5 ml-4'>
<TVToggle width={22} height={22} />
</div>
<AvatarDropdown />
</div>
)}
</div>
</div>
<div
className='flex flex-row items-center text-center
justify-center'
>
<Image
src='/images/tech_tracker_logo.png'
alt='Tech Tracker Logo'
width={100}
height={100}
className='max-w-[40px] md:max-w-[120px]'
/>
<h1
className='title-text text-xl sm:text-4xl md:text-6xl lg:text-8xl
bg-gradient-to-r dark:from-[#bec8e6] dark:via-[#F0EEE4]
dark:to-[#FFF8E7] from-[#2e3266] via-slate-600 to-zinc-700
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
>
Tech Tracker
</h1>
</div>
</header>
);
}
};
export default Header;
```
src/server/actions/auth.ts
```ts
'use server';
import 'server-only';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { createClient } from '@/utils/supabase/server';
export const login = async (formData: FormData) => {
const supabase = await createClient();
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
};
const { error } = await supabase.auth.signInWithPassword(data);
if (error) {
redirect('/error');
}
revalidatePath('/', 'layout');
redirect('/');
};
export const signup = async (formData: FormData) => {
const supabase = await createClient();
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
fullName: formData.get('fullName') as string,
email: formData.get('email') as string,
password: formData.get('password') as string,
};
const { error } = await supabase.auth.signUp(data);
if (error) {
redirect('/error');
}
revalidatePath('/', 'layout');
redirect('/');
};
```

View File

@ -1,66 +1,70 @@
{ {
"name": "tech-tracker-next", "name": "tech-tracker-next",
"version": "0.1.0", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "next build", "build": "next build",
"check": "next lint && tsc --noEmit", "check": "next lint && tsc --noEmit",
"dev": "next dev", "dev": "next dev --turbo",
"dev:slow": "next dev",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint", "lint": "next lint",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit"
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^4.1.3", "@hookform/resolvers": "^5.1.0",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-separator": "^1.1.2", "@sentry/nextjs": "^9.27.0",
"@radix-ui/react-slot": "^1.1.2",
"@sentry/nextjs": "^9.6.1",
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.1", "@supabase/supabase-js": "^2.50.0",
"@t3-oss/env-nextjs": "^0.10.1", "@t3-oss/env-nextjs": "^0.12.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"geist": "^1.3.1", "lucide-react": "^0.510.0",
"lucide-react": "^0.483.0", "next": "^15.3.3",
"next": "^15.2.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^18.3.1", "react": "^19.1.0",
"react-dom": "^18.3.1", "react-dom": "^19.1.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.57.0",
"server-only": "^0.0.1", "require-in-the-middle": "^7.5.2",
"tailwind-merge": "^3.0.2", "sonner": "^2.0.5",
"tailwindcss-animate": "^1.0.7", "zod": "^3.25.56"
"vaul": "^1.1.2",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@types/eslint": "^8.56.12", "@eslint/eslintrc": "^3.3.1",
"@types/node": "^20.17.24", "@tailwindcss/postcss": "^4.1.8",
"@types/react": "^18.3.19", "@types/cors": "^2.8.19",
"@types/react-dom": "^18.3.5", "@types/express": "^5.0.3",
"@typescript-eslint/eslint-plugin": "^8.27.0", "@types/node": "^20.19.0",
"@typescript-eslint/parser": "^8.27.0", "@types/react": "^19.1.6",
"eslint": "^8.57.1", "@types/react-dom": "^19.1.6",
"eslint-config-next": "^15.2.3", "eslint": "^9.28.0",
"postcss": "^8.5.3", "eslint-config-next": "^15.3.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.1",
"import-in-the-middle": "^1.14.0",
"postcss": "^8.5.4",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.12",
"tailwindcss": "^3.4.17", "tailwind-merge": "^3.3.0",
"typescript": "^5.8.2" "tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"typescript-eslint": "^8.33.1"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.38.1" "initVersion": "7.39.3"
}, },
"packageManager": "pnpm@10.6.5+sha512.cdf928fca20832cd59ec53826492b7dc25dc524d4370b6b4adbf65803d32efaa6c1c88147c0ae4e8d579a6c9eec715757b50d4fa35eea179d868eada4ed043af" "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
} }

3696
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@sentry/cli'
- '@tailwindcss/oxide'
- sharp
- unrs-resolver

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/appicon/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
public/favicon.ico Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

19
public/icons/apple.svg Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-1.5 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>apple [#173]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-102.000000, -7439.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M57.5708873,7282.19296 C58.2999598,7281.34797 58.7914012,7280.17098 58.6569121,7279 C57.6062792,7279.04 56.3352055,7279.67099 55.5818643,7280.51498 C54.905374,7281.26397 54.3148354,7282.46095 54.4735932,7283.60894 C55.6455696,7283.69593 56.8418148,7283.03894 57.5708873,7282.19296 M60.1989864,7289.62485 C60.2283111,7292.65181 62.9696641,7293.65879 63,7293.67179 C62.9777537,7293.74279 62.562152,7295.10677 61.5560117,7296.51675 C60.6853718,7297.73474 59.7823735,7298.94772 58.3596204,7298.97372 C56.9621472,7298.99872 56.5121648,7298.17973 54.9134635,7298.17973 C53.3157735,7298.17973 52.8162425,7298.94772 51.4935978,7298.99872 C50.1203933,7299.04772 49.0738052,7297.68074 48.197098,7296.46676 C46.4032359,7293.98379 45.0330649,7289.44985 46.8734421,7286.3899 C47.7875635,7284.87092 49.4206455,7283.90793 51.1942837,7283.88393 C52.5422083,7283.85893 53.8153044,7284.75292 54.6394294,7284.75292 C55.4635543,7284.75292 57.0106846,7283.67793 58.6366882,7283.83593 C59.3172232,7283.86293 61.2283842,7284.09893 62.4549652,7285.8199 C62.355868,7285.8789 60.1747177,7287.09489 60.1989864,7289.62485" id="apple-[#173]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#F35325" d="M1 1h6.5v6.5H1V1z"/><path fill="#81BC06" d="M8.5 1H15v6.5H8.5V1z"/><path fill="#05A6F0" d="M1 8.5h6.5V15H1V8.5z"/><path fill="#FFBA08" d="M8.5 8.5H15V15H8.5V8.5z"/></svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000">
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/>
</svg>

Before

Width:  |  Height:  |  Size: 660 B

View File

@ -1 +0,0 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 994.71 151.65"><defs><style>.cls-1{fill:#fd4b2d;}</style></defs><path class="cls-1" d="M284.72,50.4H305.5v82.84H284.72v-8.76a40.79,40.79,0,0,1-12.21,8.34,34.14,34.14,0,0,1-13.27,2.55q-16.05,0-27.76-12.45T219.77,92q0-19.18,11.33-31.45t27.53-12.26a34.94,34.94,0,0,1,14,2.82,38.32,38.32,0,0,1,12.1,8.45ZM262.87,67.45a21,21,0,0,0-16,6.82q-6.37,6.81-6.38,17.47T247,109.4a21,21,0,0,0,16,6.93,21.42,21.42,0,0,0,16.24-6.81q6.45-6.81,6.45-17.86,0-10.8-6.45-17.51A21.71,21.71,0,0,0,262.87,67.45Z"/><path class="cls-1" d="M335.8,50.4h21V90.29q0,11.65,1.6,16.18a14.16,14.16,0,0,0,5.16,7,14.76,14.76,0,0,0,8.74,2.51,15.25,15.25,0,0,0,8.81-2.48,14.49,14.49,0,0,0,5.38-7.27q1.31-3.57,1.3-15.3V50.4h20.79V85.5q0,21.69-3.43,29.69a32.32,32.32,0,0,1-12.33,15q-8.16,5.22-20.71,5.22-13.64,0-22.05-6.09a32.2,32.2,0,0,1-11.84-17q-2.43-7.55-2.43-27.41Z"/><path class="cls-1" d="M441.32,19.86H462.1V50.4h12.34V68.29H462.1v65H441.32V68.29H430.66V50.4h10.66Z"/><path class="cls-1" d="M495,18.42h20.63V58.77a47.41,47.41,0,0,1,12.26-7.88,31.62,31.62,0,0,1,12.49-2.63,28.13,28.13,0,0,1,20.78,8.53q7.23,7.4,7.24,21.7v54.75H547.9V96.92q0-14.4-1.37-19.49a13.6,13.6,0,0,0-4.68-7.62,13.19,13.19,0,0,0-8.18-2.51,15.43,15.43,0,0,0-10.85,4.19,22.14,22.14,0,0,0-6.28,11.42q-.91,3.72-.92,17v33.28H495Z"/><path class="cls-1" d="M680.84,97.83H614.06a22.25,22.25,0,0,0,7.73,14q6.29,5.22,16,5.21a27.7,27.7,0,0,0,20-8.14l17.51,8.22a41.31,41.31,0,0,1-15.68,13.74q-9.13,4.46-21.7,4.46-19.5,0-31.75-12.3T594,92.27q0-19,12.22-31.48t30.65-12.53q19.56,0,31.82,12.53t12.26,33.08ZM660.05,81.46a20.87,20.87,0,0,0-8.12-11.27,23.61,23.61,0,0,0-14.08-4.34,24.88,24.88,0,0,0-15.25,4.88q-4.11,3-7.62,10.73Z"/><path class="cls-1" d="M707,50.4H727.8v8.49a50.15,50.15,0,0,1,12.81-8.3,31.08,31.08,0,0,1,11.75-2.33,28.44,28.44,0,0,1,20.91,8.61q7.22,7.31,7.22,21.62v54.75H759.93V97q0-14.83-1.33-19.7A13.48,13.48,0,0,0,754,69.85a13,13,0,0,0-8.16-2.55A15.32,15.32,0,0,0,735,71.52a22.6,22.6,0,0,0-6.27,11.67q-.9,3.89-.91,16.81v33.24H707Z"/><path class="cls-1" d="M812.46,19.86h20.79V50.4h12.33V68.29H833.25v65H812.46V68.29H801.8V50.4h10.66Z"/><path class="cls-1" d="M874.16,16.29a12.74,12.74,0,0,1,9.38,3.95,13.18,13.18,0,0,1,3.91,9.6,13,13,0,0,1-3.87,9.48,12.6,12.6,0,0,1-9.27,3.92,12.73,12.73,0,0,1-9.45-4A13.39,13.39,0,0,1,861,29.53a12.78,12.78,0,0,1,3.87-9.36A12.71,12.71,0,0,1,874.16,16.29Z"/><rect class="cls-1" x="863.77" y="50.4" width="20.79" height="82.84"/><path class="cls-1" d="M913,18.42h20.78V84.55L964.34,50.4h26.11L954.76,90.1l40,43.14h-25.8L933.73,95.06v38.18H913Z"/><rect class="cls-1" x="107.1" y="34.93" width="6.37" height="18.2"/><rect class="cls-1" x="123.67" y="34.16" width="6.37" height="14.23"/><path class="cls-1" d="M30.83,55A23.23,23.23,0,0,0,10.41,67.13h10.8C26,63,32.94,61.8,38,67.13H49.39C44.93,61.09,38.24,55,30.83,55Z"/><path class="cls-1" d="M46.25,78.11c-14.89,31.15-41,4.6-25-11H10.41c-8.47,14.76,3.24,34.68,20.42,34.23,13.28,0,24.24-19.72,24.24-23.21,0-1.54-2.14-6.25-5.68-11H38A40.52,40.52,0,0,1,46.25,78.11Zm.4-.91Z"/><path class="cls-1" d="M189.62,34.71V117A28.62,28.62,0,0,1,161,145.54H148.89v-28H90.94v28H78.81A28.62,28.62,0,0,1,50.22,117V91.08h91.87V41.62H97.74V69.41H50.22V34.71a27.43,27.43,0,0,1,.19-3.29,27.09,27.09,0,0,1,.71-3.84c.1-.41.22-.82.34-1.21a2.13,2.13,0,0,1,.09-.3c.07-.21.13-.4.2-.59s.14-.4.21-.59.16-.44.25-.65.18-.43.26-.64a29.35,29.35,0,0,1,2.6-4.82l0-.05c.26-.37.53-.75.81-1.12s.47-.61.7-.91.57-.67.86-1,.56-.63.86-.93l0,0a4.53,4.53,0,0,1,.49-.49,29.23,29.23,0,0,1,3.4-2.84c.32-.24.66-.46,1-.68s.77-.49,1.17-.72a23.78,23.78,0,0,1,2.29-1.21l.75-.34a27.84,27.84,0,0,1,3.35-1.21c.44-.13.88-.24,1.33-.35a6.19,6.19,0,0,1,.65-.15,28.86,28.86,0,0,1,3.87-.57l.56,0h.28c.43,0,.87,0,1.31,0H161c.43,0,.87,0,1.3,0h.28l.56,0a29.25,29.25,0,0,1,3.88.57c.22,0,.43.09.65.15.45.11.88.22,1.32.35a27.23,27.23,0,0,1,3.35,1.21l.75.34a25.19,25.19,0,0,1,2.3,1.21c.39.23.78.47,1.16.72s.69.44,1,.68a29.23,29.23,0,0,1,3.91,3.36q.45.45.87.93c.29.32.57.66.85,1l.71.91c.28.37.54.75.8,1.12l0,.05a28.61,28.61,0,0,1,2.6,4.82l.27.64.24.65c.08.19.15.39.22.59l.19.59c0,.09.06.19.1.3.11.39.23.8.34,1.21a28.56,28.56,0,0,1,.7,3.84A27.42,27.42,0,0,1,189.62,34.71Z"/><path class="cls-1" d="M184.76,18.78H55.07A28.59,28.59,0,0,1,78.8,6.12H161A28.59,28.59,0,0,1,184.76,18.78Z"/><path class="cls-1" d="M189.43,31.43H50.4a28.29,28.29,0,0,1,4.67-12.65H184.76A28.17,28.17,0,0,1,189.43,31.43Z"/><path class="cls-1" d="M189.63,34.71v9.37H142.09V41.62H97.74v2.46H50.21V34.71a27.43,27.43,0,0,1,.19-3.29h139A27.42,27.42,0,0,1,189.63,34.71Z"/><rect class="cls-1" x="50.21" y="44.08" width="47.54" height="12.66"/><rect class="cls-1" x="142.09" y="44.08" width="47.54" height="12.66"/><rect class="cls-1" x="50.21" y="56.74" width="47.54" height="12.65"/><rect class="cls-1" x="142.09" y="56.74" width="47.54" height="12.65"/></svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg513"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="ExitFullscreen.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview515"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="8.4359982"
inkscape:cx="69.108597"
inkscape:cy="37.458519"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="3832"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs510" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 0,5.8620045 H 5.8615381 L 5.8621526,0 H 4.8849607 L 4.8846152,4.8851478 H 0 Z"
id="path190"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 6.8384615,8.6556325e-4 V 5.8620045 H 12.7 V 4.8851478 H 7.815384 V 8.6556325e-4 Z"
id="path396"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 12.7,6.8388612 H 6.8384615 V 12.7 H 7.815384 V 7.8157173 H 12.7 Z"
id="path396-5"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 5.8615381,12.7 V 6.8388612 H 0 V 7.8157173 H 4.8846152 V 12.7 Z"
id="path396-1"
sodipodi:nodetypes="ccccccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg513"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="Fullscreen.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview515"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="8.4359982"
inkscape:cx="69.108597"
inkscape:cy="37.458519"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="3832"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs510" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 5.8615386,0 H 0 V 5.8615386 H 0.97692256 V 0.97692327 H 5.8615386 Z"
id="path396-6"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 0,6.8384619 V 12.7 H 5.8615386 V 11.723076 H 0.97692256 V 6.8384619 Z"
id="path396-52"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 6.8384613,12.7 H 12.7 V 6.8384619 H 11.723078 V 11.723076 H 6.8384613 Z"
id="path396-4"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 12.7,5.8615386 V 0 H 6.8384613 V 0.97692327 H 11.723078 V 5.8615386 Z"
id="path396-0"
sodipodi:nodetypes="ccccccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

View File

@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" width="779.07" height="141.73" viewBox="0 0 779.07 141.73"><title>Microsoft365_logo_horiz_c-gray_cmyk_horiz_c-gray_cmyk</title><g id="MS-symbol"><g><path d="M608.2,69.69v.19c5,.58,8.89,2.32,11.76,5.23a15,15,0,0,1,4.32,11,18.7,18.7,0,0,1-6.89,15.15q-6.9,5.75-18.74,5.74a41.61,41.61,0,0,1-9.44-1.08,23,23,0,0,1-7.08-2.62V92.11a25,25,0,0,0,7.56,4,27,27,0,0,0,8.71,1.51q6.44,0,10.19-3a9.94,9.94,0,0,0,3.72-8.14,9.39,9.39,0,0,0-4.41-8.39c-2.95-1.93-7.15-2.89-12.63-2.89h-6V65.61h5.71q7.26,0,11.18-2.7A8.92,8.92,0,0,0,610,55a9,9,0,0,0-3-7.32c-2-1.71-4.92-2.57-8.67-2.56a20.68,20.68,0,0,0-7,1.22,24.17,24.17,0,0,0-6.58,3.67V39.59a28.42,28.42,0,0,1,7.45-2.8,39.73,39.73,0,0,1,9.09-1q9,0,14.82,4.66a14.89,14.89,0,0,1,5.78,12.11,16.73,16.73,0,0,1-3.55,11A18.89,18.89,0,0,1,608.2,69.69Z" fill="#737474"></path><path d="M643,70a12.92,12.92,0,0,1,6.1-5.76,20.9,20.9,0,0,1,9.17-2,20,20,0,0,1,14,5.45q5.88,5.46,5.88,15.84,0,10.95-6.74,17.19a23.27,23.27,0,0,1-16.4,6.24q-11.28,0-17.76-8.38T630.8,75.22q0-19.5,8.61-29.48a27.4,27.4,0,0,1,21.64-10,50.12,50.12,0,0,1,7.24.47,18.64,18.64,0,0,1,5.33,1.49V48.13a26.26,26.26,0,0,0-6-2.26,25.55,25.55,0,0,0-6-.74A16.55,16.55,0,0,0,648,51.65q-5.15,6.52-5.24,18.32Zm.19,13.54a14.92,14.92,0,0,0,3.37,9.92,10.7,10.7,0,0,0,8.55,4A10.54,10.54,0,0,0,663.33,94q3.15-3.47,3.14-9.53,0-6.42-3.08-9.72A10.86,10.86,0,0,0,655,71.47a11.47,11.47,0,0,0-8.57,3.33,11.93,11.93,0,0,0-3.23,8.76Z" fill="#737474"></path><path d="M725.94,84.33q0,10.24-6.86,16.49T700.21,107a35.2,35.2,0,0,1-9.46-1.23,32.77,32.77,0,0,1-7-2.66V92.3a25.77,25.77,0,0,0,7.6,4,24.67,24.67,0,0,0,7.52,1.27q6.87,0,11-3.41A11.33,11.33,0,0,0,714,84.91,10.5,10.5,0,0,0,709.79,76q-4.24-3.18-12.22-3.17c-1.57,0-3.62.07-6.17.21s-4.3.28-5.26.41L688.59,37h34.23v9.86H698.19l-1.13,16.77c1.34-.11,2.38-.16,3.09-.17h3Q714,63.41,720,69T725.94,84.33Z" fill="#737474"></path></g><path d="M239.64,37v68.84h-12V51.87h-.19l-21.36,54h-7.92L176.29,51.87h-.14v54h-11V37h17.14L202,88h.29l20.89-51Zm10,5.23a6.33,6.33,0,0,1,2.09-4.82,7.41,7.41,0,0,1,10.06,0,6.58,6.58,0,0,1,2,4.78A6.21,6.21,0,0,1,261.74,47a7.07,7.07,0,0,1-5,1.92,7,7,0,0,1-5-1.93,6.29,6.29,0,0,1-2.08-4.74Zm12.82,14.27v49.37H250.82V56.48ZM297.7,97.37a15.76,15.76,0,0,0,5.67-1.19A24,24,0,0,0,309.13,93v10.81a23.43,23.43,0,0,1-6.31,2.4,34.86,34.86,0,0,1-7.76.82q-10.89,0-17.7-6.9t-6.82-17.59q0-11.89,7-19.56t19.72-7.8a27.28,27.28,0,0,1,6.61.84,22,22,0,0,1,5.3,2V69.17a23.31,23.31,0,0,0-5.5-3A15.8,15.8,0,0,0,297.9,65a14.55,14.55,0,0,0-11.09,4.46q-4.23,4.47-4.22,12.06t4.05,11.66q4,4.17,11,4.15Zm44.51-41.71a14,14,0,0,1,2.5.19,10,10,0,0,1,1.86.48V68.09a10.17,10.17,0,0,0-2.66-1.27,13.33,13.33,0,0,0-4.25-.6,9.05,9.05,0,0,0-7.23,3.6q-3,3.6-2.95,11.09v24.91H317.9V56.45h11.61v7.77h.2A13.55,13.55,0,0,1,334.48,58,13,13,0,0,1,342.21,55.66Zm5,26.21q0-12.24,6.92-19.39t19.21-7.16q11.57,0,18.07,6.89t6.52,18.63q0,12-6.91,19.11t-18.82,7.1q-11.47,0-18.21-6.74T347.2,81.87Zm12.12-.39q0,7.74,3.5,11.81t10,4.08q6.34,0,9.65-4.08t3.32-12.11q0-8-3.44-12t-9.62-4.07q-6.39,0-9.91,4.25t-3.55,12.14Zm55.89-12a5,5,0,0,0,1.59,3.91q1.59,1.41,7,3.58,7,2.79,9.76,6.26a12.91,12.91,0,0,1,2.8,8.38A13.52,13.52,0,0,1,431,102.75Q425.66,107,416.54,107a35.4,35.4,0,0,1-6.8-.75,30.18,30.18,0,0,1-6.3-1.86V93a28.2,28.2,0,0,0,6.82,3.5,19.93,19.93,0,0,0,6.62,1.3,11.83,11.83,0,0,0,5.8-1.1,4,4,0,0,0,1.87-3.73,5.11,5.11,0,0,0-1.94-4.05,29,29,0,0,0-7.37-3.82q-6.45-2.69-9.13-6a13.21,13.21,0,0,1-2.68-8.55,13.47,13.47,0,0,1,5.3-11q5.31-4.3,13.76-4.31a33.14,33.14,0,0,1,5.8.57,25.88,25.88,0,0,1,5.38,1.49V68.33a25.11,25.11,0,0,0-5.38-2.64,17.89,17.89,0,0,0-6.05-1.11,8.82,8.82,0,0,0-5.16,1.31,4.1,4.1,0,0,0-1.9,3.55Zm26.17,12.43q0-12.24,6.91-19.39t19.2-7.16q11.58,0,18.08,6.89t6.52,18.63q0,12-6.91,19.11t-18.83,7.1q-11.48,0-18.21-6.74t-6.79-18.44Zm12.11-.39q0,7.74,3.51,11.81T467,97.37q6.33,0,9.65-4.08t3.31-12.11q0-8-3.43-12t-9.63-4.07q-6.37,0-9.91,4.25t-3.49,12.14ZM530.64,66H513.29v39.84H501.53V66h-8.26v-9.5h8.26V49.6a17.06,17.06,0,0,1,5.06-12.74,17.82,17.82,0,0,1,13-5,29.06,29.06,0,0,1,3.73.22,15,15,0,0,1,2.88.65v10a13.26,13.26,0,0,0-2-.82,10.54,10.54,0,0,0-3.3-.47,7,7,0,0,0-5.59,2.28q-2,2.28-2,6.74v6h17.3V45.38l11.67-3.55V56.48H554V66H542.26V89.07q0,4.56,1.65,6.43t5.21,1.87a7.66,7.66,0,0,0,2.42-.48A11.92,11.92,0,0,0,554,95.73v9.61a14.24,14.24,0,0,1-3.67,1.15,26.12,26.12,0,0,1-5.07.53q-7.35,0-11-3.92t-3.67-11.78Z" fill="#737474"></path><rect x="15.94" y="14.03" width="54.53" height="54.53" fill="#f05125"></rect><rect x="76.14" y="14.03" width="54.53" height="54.53" fill="#7ebb42"></rect><rect x="15.94" y="74.24" width="54.53" height="54.53" fill="#33a0da"></rect><rect x="76.14" y="74.24" width="54.53" height="54.53" fill="#fdb813"></rect></g></svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

View File

@ -1,16 +1,16 @@
services: services:
techtracker: t3-template:
build: build:
context: ../../ context: ../../../
dockerfile: docker/development/Dockerfile dockerfile: docker/development/Dockerfile
image: with-docker-multi-env-development image: with-docker-multi-env-development
container_name: techtracker container_name: t3-template
networks: networks:
- node_apps - nginx-bridge
ports: #ports:
- '3004:3000' #- '3000:3000'
tty: true tty: true
restart: unless-stopped restart: unless-stopped
networks: networks:
node_apps: nginx-bridge:
external: true external: true

View File

@ -1,16 +1,16 @@
services: services:
techtracker: t3-template:
build: build:
context: ../../ context: ../../../
dockerfile: docker/production/Dockerfile dockerfile: docker/production/Dockerfile
image: with-docker-multi-env-development image: with-docker-multi-env-development
container_name: techtracker container_name: t3-template
networks: networks:
- node_apps - nginx-bridge
ports: #ports:
- '3004:3000' #- '3000:3000'
tty: true tty: true
restart: unless-stopped restart: unless-stopped
networks: networks:
node_apps: nginx-bridge:
external: true external: true

View File

@ -81,12 +81,6 @@ def main():
markdown_text = '\n'.join(markdown_lines) markdown_text = '\n'.join(markdown_lines)
# Write markdown to file
output_file = 'output.md'
with open(output_file, 'w', encoding='utf-8') as f:
f.write(markdown_text)
print(f"\nMarkdown file '{output_file}' has been generated.")
# Copy markdown content to clipboard # Copy markdown content to clipboard
pyperclip.copy(markdown_text) pyperclip.copy(markdown_text)
print("Markdown content has been copied to the clipboard.") print("Markdown content has been copied to the clipboard.")

133
scripts/generate_types Executable file
View File

@ -0,0 +1,133 @@
#!/bin/bash
# Define colors for better output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# Get the project root directory (one level up from scripts/)
PROJECT_ROOT="$(dirname "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)")"
# Clear the screen for better visibility
clear
echo -e "${BOLD}${BLUE}===== Supabase TypeScript Type Generator =====${NC}"
echo
echo -e "${YELLOW}⚠️ IMPORTANT: This script must be run on the server hosting the Supabase Docker container.${NC}"
echo -e "It will not work if you're running it from a different machine, even if connected via VPN."
echo
echo -e "Project root: ${BLUE}${PROJECT_ROOT}${NC}"
echo
# Ask for confirmation
read -p "Are you running this script on the Supabase host server? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${RED}Aborted. Please run this script on the server hosting Supabase.${NC}"
exit 1
fi
# Check for sudo access
if ! sudo -v; then
echo -e "${RED}Error: This script requires sudo privileges.${NC}"
exit 1
fi
# Check if .env file exists in project root
ENV_FILE="${PROJECT_ROOT}/.env"
if [ ! -f "$ENV_FILE" ]; then
echo -e "${RED}Error: .env file not found at ${ENV_FILE}${NC}"
echo -e "Please create a .env file with the following variables:"
echo -e "SUPABASE_DB_HOST, SUPABASE_DB_PORT, SUPABASE_DB_USER, SUPABASE_DB_PASSWORD, SUPABASE_DB_NAME"
exit 1
fi
echo -e "${GREEN}Found .env file at $ENV_FILE${NC}"
# Source the .env file to get environment variables
export $(grep -v '^#' $ENV_FILE | xargs)
# Check if required variables are set
if [ -z "$SUPABASE_DB_HOST" ] || [ -z "$SUPABASE_DB_PORT" ] || [ -z "$SUPABASE_DB_USER" ] || [ -z "$SUPABASE_DB_PASSWORD" ] || [ -z "$SUPABASE_DB_NAME" ]; then
# Try to use default variables if Supabase-specific ones aren't set
if [ -z "$SUPABASE_DB_HOST" ]; then SUPABASE_DB_HOST=${DB_HOST:-localhost}; fi
if [ -z "$SUPABASE_DB_PORT" ]; then SUPABASE_DB_PORT=${DB_PORT:-5432}; fi
if [ -z "$SUPABASE_DB_USER" ]; then SUPABASE_DB_USER=${DB_USER:-postgres}; fi
if [ -z "$SUPABASE_DB_PASSWORD" ]; then SUPABASE_DB_PASSWORD=${DB_PASSWORD}; fi
if [ -z "$SUPABASE_DB_NAME" ]; then SUPABASE_DB_NAME=${DB_NAME:-postgres}; fi
# Check again after trying defaults
if [ -z "$SUPABASE_DB_HOST" ] || [ -z "$SUPABASE_DB_PORT" ] || [ -z "$SUPABASE_DB_USER" ] || [ -z "$SUPABASE_DB_PASSWORD" ] || [ -z "$SUPABASE_DB_NAME" ]; then
echo -e "${RED}Error: Missing required environment variables${NC}"
echo -e "Please ensure your .env file contains:"
echo -e "SUPABASE_DB_HOST, SUPABASE_DB_PORT, SUPABASE_DB_USER, SUPABASE_DB_PASSWORD, SUPABASE_DB_NAME"
echo -e "Or the equivalent DB_* variables"
exit 1
fi
fi
# Check if supabase CLI is installed for the sudo user
echo -e "${YELLOW}Checking if Supabase CLI is installed...${NC}"
if ! sudo npx supabase --version &>/dev/null; then
echo -e "${YELLOW}Supabase CLI not found. Installing...${NC}"
sudo npm install -g supabase
if [ $? -ne 0 ]; then
echo -e "${RED}Failed to install Supabase CLI. Please install it manually:${NC}"
echo -e "sudo npm install -g supabase"
exit 1
fi
echo -e "${GREEN}Supabase CLI installed successfully.${NC}"
else
echo -e "${GREEN}Supabase CLI is already installed.${NC}"
fi
echo -e "${YELLOW}Generating Supabase TypeScript types...${NC}"
# Construct the database URL from environment variables
DB_URL="postgres://$SUPABASE_DB_USER:$SUPABASE_DB_PASSWORD@$SUPABASE_DB_HOST:$SUPABASE_DB_PORT/$SUPABASE_DB_NAME"
# Determine the output directory (relative to project root)
OUTPUT_DIR="${PROJECT_ROOT}/utils/supabase"
if [ ! -d "$OUTPUT_DIR" ]; then
echo -e "${YELLOW}Output directory $OUTPUT_DIR not found. Creating...${NC}"
mkdir -p "$OUTPUT_DIR"
fi
# Create a temporary file for the output
TEMP_FILE=$(mktemp)
# Run the Supabase CLI command with sudo
echo -e "${YELLOW}Running Supabase CLI to generate types...${NC}"
sudo -E npx supabase gen types typescript \
--db-url "$DB_URL" \
--schema public > "$TEMP_FILE" 2>&1
# Check if the command was successful
if [ $? -eq 0 ] && [ -s "$TEMP_FILE" ] && ! grep -q "Error" "$TEMP_FILE"; then
# Move the temp file to the final destination
mv "$TEMP_FILE" "$OUTPUT_DIR/types.ts"
echo -e "${GREEN}✓ TypeScript types successfully generated at $OUTPUT_DIR/types.ts${NC}"
# Show the first few lines to confirm it looks right
echo -e "${YELLOW}Preview of generated types:${NC}"
head -n 10 "$OUTPUT_DIR/types.ts"
echo -e "${YELLOW}...${NC}"
else
echo -e "${RED}✗ Failed to generate TypeScript types${NC}"
echo -e "${RED}Error output:${NC}"
cat "$TEMP_FILE"
rm "$TEMP_FILE"
exit 1
fi
# Clear sensitive environment variables
unset SUPABASE_DB_PASSWORD
unset DB_URL
echo -e "${GREEN}${BOLD}Type generation complete!${NC}"
echo -e "You can now use these types in your Next.js application."
echo -e "Import them with: ${BLUE}import { Database } from '@/utils/supabase/types'${NC}"

View File

@ -3,16 +3,65 @@
* for Docker builds. * for Docker builds.
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output: 'standalone', output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
serverExternalPackages: ['require-in-the-middle'],
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
//turbopack: {
//rules: {
//'*.svg': {
//loaders: ['@svgr/webpack'],
//as: '*.js',
//},
//},
//},
}; };
export default config; const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: 'gib',
project: 't3-supabase-template',
sentryUrl: process.env.SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
export default withSentryConfig(config, sentryConfig);

View File

@ -3,47 +3,59 @@
* for Docker builds. * for Docker builds.
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output: 'standalone', output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
serverExternalPackages: ['require-in-the-middle'],
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
//turbopack: {
//rules: {
//'*.svg': {
//loaders: ['@svgr/webpack'],
//as: '*.js',
//},
//},
//},
}; };
export default config; const sentryConfig = {
/** // For all available options, see:
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful // https://www.npmjs.com/package/@sentry/webpack-plugin#options
* for Docker builds. org: 'gib',
*/ project: 't3-supabase-template',
//await import("./src/env.js"); sentryUrl: process.env.SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
//const cspHeader = ` export default withSentryConfig(config, sentryConfig);
//default-src 'self';
//script-src 'self' 'unsafe-eval' 'unsafe-inline';
//style-src 'self' 'unsafe-inline';
//img-src 'self' blob: data:;
//font-src 'self';
//object-src 'none';
//base-uri 'self';
//form-action 'self';
//frame-ancestors 'none';
//upgrade-insecure-requests;
//`
//[>* @type {import("next").NextConfig} <]
//const config = {
//async headers() {
//return [
//{
//source: "/(.*)",
//headers: [
//{
//key: "Content-Security-Policy",
//value: cspHeader.replace(/\n/g, ''),
//},
//],
//},
//];
//},
//};
//export default config;

View File

@ -1,7 +1,7 @@
git pull git pull
mv ~/Documents/Web/Tech_Tracker_Web/next.config.js ~/Documents/Web/Tech_Tracker_Web/scripts/next.config.default.js mv ./next.config.js ./scripts/next.config.default.js
cp ~/Documents/Web/Tech_Tracker_Web/scripts/next.config.build.js ~/Documents/Web/Tech_Tracker_Web/next.config.js cp ./scripts/next.config.build.js ./next.config.js
sudo docker compose -f docker/development/compose.yaml down sudo docker compose -f ./scripts/docker/development/compose.yaml down
sudo docker compose -f docker/development/compose.yaml build sudo docker compose -f ./scripts/docker/development/compose.yaml build
sudo docker compose -f docker/development/compose.yaml up -d sudo docker compose -f ./scripts/docker/development/compose.yaml up -d
cp ~/Documents/Web/Tech_Tracker_Web/scripts/next.config.default.js ~/Documents/Web/Tech_Tracker_Web/next.config.js cp ./scripts/next.config.default.js ./next.config.js

View File

@ -1,26 +0,0 @@
// This file configures the initialization of Sentry on the client.
// The config you add here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: 'https://c73de96b2ba3248b5a22dd156b05b334@sentry.gbrown.org/4',
// Add optional integrations for additional features
integrations: [Sentry.replayIntegration()],
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@ -1,16 +0,0 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: 'https://c73de96b2ba3248b5a22dd156b05b334@sentry.gbrown.org/4',
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@ -5,7 +5,7 @@
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
Sentry.init({ Sentry.init({
dsn: 'https://c73de96b2ba3248b5a22dd156b05b334@sentry.gbrown.org/4', 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,

View File

@ -0,0 +1,43 @@
'use server';
import 'server-only';
import { createServerClient } from '@/utils/supabase';
import { type EmailOtpType } from '@supabase/supabase-js';
import { type NextRequest } from 'next/server';
import { redirect } from 'next/navigation';
export const GET = async (request: NextRequest) => {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const token_hash = searchParams.get('token');
const type = searchParams.get('type') as EmailOtpType | null;
const redirectTo = searchParams.get('redirect_to') ?? '/';
const supabase = await createServerClient();
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
console.error('OAuth error:', error);
return redirect(`/sign-in?error=${encodeURIComponent(error.message)}`);
}
return redirect(redirectTo);
}
if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
if (type === 'signup' || type === 'magiclink' || type === 'email')
return redirect('/');
if (type === 'recovery' || type === 'email_change')
return redirect('/profile');
if (type === 'invite') return redirect('/sign-up');
}
return redirect(
`/?error=${encodeURIComponent(error?.message || 'Unknown error')}`,
);
}
return redirect('/');
};

View File

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

View File

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

View File

@ -0,0 +1,123 @@
'use client';
import { useAuth } from '@/components/context/auth';
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';
const ProfilePage = () => {
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>
);
};
export default ProfilePage;

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/auth';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
import { Separator } from '@/components/ui';
import { SignInWithMicrosoft } from '@/components/default/auth/SignInWithMicrosoft';
import { SignInWithApple } from '@/components/default/auth/SignInWithApple';
const formSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
});
const Login = () => {
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 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>
);
};
export default Login;

View File

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

View File

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

@ -1,9 +0,0 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
// A faulty API route to test Sentry's error monitoring
export function GET() {
throw new Error('Sentry Example API Route Error');
return NextResponse.json({ data: 'Testing Sentry Error...' });
}

View File

@ -1,35 +0,0 @@
import { type EmailOtpType } from '@supabase/supabase-js';
import { type NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/utils/supabase/server';
// Creating a handler to a GET request to route /auth/confirm
export const GET = async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get('token_hash');
const type = searchParams.get('type') as EmailOtpType | null;
const next = '/account';
// Create redirect link without the secret token
const redirectTo = request.nextUrl.clone();
redirectTo.pathname = next;
redirectTo.searchParams.delete('token_hash');
redirectTo.searchParams.delete('type');
if (token_hash && type) {
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
redirectTo.searchParams.delete('next');
return NextResponse.redirect(redirectTo);
}
}
// return the user to an error page with some instructions
redirectTo.pathname = '/error';
return NextResponse.redirect(redirectTo);
};

View File

@ -1,4 +0,0 @@
const ErrorPage = () => {
return <p>Sorry, something went wrong</p>;
};
export default ErrorPage;

View File

@ -1,27 +1,80 @@
'use client'; 'use client';
import '@/styles/globals.css';
import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme';
import { AuthProvider } from '@/components/context/auth';
import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer';
import { Button, Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error'; import NextError from 'next/error';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Geist } from 'next/font/google';
export default function GlobalError({ const geist = Geist({
error, subsets: ['latin'],
}: { variable: '--font-geist-sans',
});
type GlobalErrorProps = {
error: Error & { digest?: string }; error: Error & { digest?: string };
}) { reset?: () => void;
};
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => { useEffect(() => {
Sentry.captureException(error); Sentry.captureException(error);
}, [error]); }, [error]);
return ( return (
<html> <html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
<body
className={cn('bg-background text-foreground font-sans antialiased')}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'>
<Navigation />
<div
className='flex flex-col gap-20 max-w-5xl
p-5 w-full items-center'
>
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try again</Button>
)}
</div>
</div>
<Footer />
</main>
<Toaster />
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
return (
<html lang='en'>
<body> <body>
{/* `NextError` is the default Next.js error page component. Its type {/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a does not expose status codes for errors, we simply pass 0 to render a
generic error message. */} generic error message. */}
<NextError statusCode={0} /> <NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try again</Button>
)}
</body> </body>
</html> </html>
); );
} };
export default GlobalError;

View File

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

View File

@ -1,25 +1,95 @@
'use server'; 'use server';
import LoginForm from '@/components/auth/LoginForm';
import { createClient } from '@/utils/supabase/server'; import { FetchDataSteps } from '@/components/default/tutorial';
import { InfoIcon } from 'lucide-react';
import { getUser } from '@/lib/actions';
import type { User } from '@/utils/supabase';
import { TestSentryCard } from '@/components/default/sentry';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from '@/components/ui';
import {
SignInSignUp,
SignInWithApple,
SignInWithMicrosoft
} from '@/components/default/auth';
const HomePage = async () => { const HomePage = async () => {
const supabase = await createClient(); const response = await getUser();
const { if (!response.success || !response.data) {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return ( return (
<div <main className='w-full items-center justify-center'>
className='flex flex-col items-center <div className='flex flex-col p-5 items-center justify-center space-y-6'>
justify-center md:min-h-[70vh]' <Card className='md:min-w-2xl'>
> <CardHeader className='flex flex-col items-center'>
<LoginForm /> <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>
<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 ( return (
<div className='flex flex-col items-center justify-center'> <div className='flex-1 w-full flex flex-col gap-12'>
<h1>Hello, {session.user.email}</h1> <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> </div>
); );
}; };

View File

@ -1,85 +0,0 @@
'use client';
import Head from 'next/head';
import * as Sentry from '@sentry/nextjs';
export default function Page() {
return (
<div>
<Head>
<title>Sentry Onboarding</title>
<meta name='description' content='Test Sentry for your Next.js app!' />
</Head>
<main
style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
<h1 style={{ fontSize: '4rem', margin: '14px 0' }}>
<svg
style={{
height: '1em',
}}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 200 44'
>
<path
fill='currentColor'
d='M124.32,28.28,109.56,9.22h-3.68V34.77h3.73V15.19l15.18,19.58h3.26V9.22h-3.73ZM87.15,23.54h13.23V20.22H87.14V12.53h14.93V9.21H83.34V34.77h18.92V31.45H87.14ZM71.59,20.3h0C66.44,19.06,65,18.08,65,15.7c0-2.14,1.89-3.59,4.71-3.59a12.06,12.06,0,0,1,7.07,2.55l2-2.83a14.1,14.1,0,0,0-9-3c-5.06,0-8.59,3-8.59,7.27,0,4.6,3,6.19,8.46,7.52C74.51,24.74,76,25.78,76,28.11s-2,3.77-5.09,3.77a12.34,12.34,0,0,1-8.3-3.26l-2.25,2.69a15.94,15.94,0,0,0,10.42,3.85c5.48,0,9-2.95,9-7.51C79.75,23.79,77.47,21.72,71.59,20.3ZM195.7,9.22l-7.69,12-7.64-12h-4.46L186,24.67V34.78h3.84V24.55L200,9.22Zm-64.63,3.46h8.37v22.1h3.84V12.68h8.37V9.22H131.08ZM169.41,24.8c3.86-1.07,6-3.77,6-7.63,0-4.91-3.59-8-9.38-8H154.67V34.76h3.8V25.58h6.45l6.48,9.2h4.44l-7-9.82Zm-10.95-2.5V12.6h7.17c3.74,0,5.88,1.77,5.88,4.84s-2.29,4.86-5.84,4.86Z M29,2.26a4.67,4.67,0,0,0-8,0L14.42,13.53A32.21,32.21,0,0,1,32.17,40.19H27.55A27.68,27.68,0,0,0,12.09,17.47L6,28a15.92,15.92,0,0,1,9.23,12.17H4.62A.76.76,0,0,1,4,39.06l2.94-5a10.74,10.74,0,0,0-3.36-1.9l-2.91,5a4.54,4.54,0,0,0,1.69,6.24A4.66,4.66,0,0,0,4.62,44H19.15a19.4,19.4,0,0,0-8-17.31l2.31-4A23.87,23.87,0,0,1,23.76,44H36.07a35.88,35.88,0,0,0-16.41-31.8l4.67-8a.77.77,0,0,1,1.05-.27c.53.29,20.29,34.77,20.66,35.17a.76.76,0,0,1-.68,1.13H40.6q.09,1.91,0,3.81h4.78A4.59,4.59,0,0,0,50,39.43a4.49,4.49,0,0,0-.62-2.28Z'
></path>
</svg>
</h1>
<p>Get started by sending us a sample error:</p>
<button
type='button'
style={{
padding: '12px',
cursor: 'pointer',
backgroundColor: '#AD6CAA',
borderRadius: '4px',
border: 'none',
color: 'white',
fontSize: '14px',
margin: '18px',
}}
onClick={async () => {
await Sentry.startSpan(
{
name: 'Example Frontend Span',
op: 'test',
},
async () => {
const res = await fetch('/api/sentry-example-api');
if (!res.ok) {
throw new Error('Sentry Example Frontend Error');
}
},
);
}}
>
Throw error!
</button>
<p>
Next, look for the error on the{' '}
<a href='https://sentry.gbrown.org/organizations/gib/issues/?project=4'>
Issues Page
</a>
.
</p>
<p style={{ marginTop: '24px' }}>
For more information, see{' '}
<a href='https://docs.sentry.io/platforms/javascript/guides/nextjs/'>
https://docs.sentry.io/platforms/javascript/guides/nextjs/
</a>
</p>
</main>
</div>
);
}

View File

@ -1,102 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { getImageUrl } from '@/server/actions/image';
import { useAuth } from '@/components/context/Auth';
const AvatarDropdown = () => {
const router = useRouter();
const { user, signOut } = useAuth();
const [pfp, setPfp] = useState<string>('/images/default_user_pfp.png');
const [loading, setLoading] = useState(true);
useEffect(() => {
const downloadImage = async (path: string) => {
try {
setLoading(true);
const url = await getImageUrl('avatars', path);
console.log(url);
setPfp(url);
} catch (error) {
console.error(
'Error downloading image:',
error instanceof Error ? error.message : error,
);
} finally {
setLoading(false);
}
};
if (user?.avatar_url) {
try {
downloadImage(user.avatar_url).catch((error) => {
console.error('Error downloading image:', error);
});
} catch (error) {
console.error('Error: ', error);
}
}
}, [user]);
const getInitials = (fullName: string | undefined): string => {
if (!fullName || fullName.trim() === '' || fullName === 'undefined')
return 'NA';
const nameParts = fullName.trim().split(' ');
const firstInitial = nameParts[0]?.charAt(0).toUpperCase() ?? 'N';
if (nameParts.length === 1) return 'NA';
const lastIntitial =
nameParts[nameParts.length - 1]?.charAt(0).toUpperCase() ?? 'A';
return firstInitial + lastIntitial;
};
// Handle sign out
const handleSignOut = async () => {
await signOut();
router.push('/');
};
// Show nothing while loading
if (loading) {
return <div className='animate-pulse h-8 w-8 rounded-full bg-gray-300' />;
}
// If no session, return empty div
if (!session) return <div />;
return (
<div className='m-auto mt-1'>
<DropdownMenu>
<DropdownMenuTrigger>
<Image
src={pfp}
alt={getInitials(user?.full_name) ?? 'NA'}
width={40}
height={40}
className='rounded-full border-2
border-muted-foreground m-auto mr-1 md:mr-2
max-w-[35px] sm:max-w-[40px]'
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{user?.full_name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button onClick={handleSignOut} className='w-full text-left'>
Sign Out
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
export default AvatarDropdown;

View File

@ -1,158 +0,0 @@
'use client';
import { login, signup } from '@/server/actions/auth';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Form,
FormField,
FormControl,
FormDescription,
FormLabel,
FormMessage,
FormItem,
} from '@/components/ui/form';
import { useRef } from 'react';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import MicrosoftSignIn from '@/components/auth/microsoft/SignIn';
import AppleSignIn from '@/components/auth/apple/SignIn';
const formSchema = z.object({
fullName: z.string().optional(),
email: z.string().email('Must be a valid email!'),
password: z.string().min(8, 'Must be at least 8 characters!'),
});
const LoginForm = () => {
const formRef = useRef<HTMLFormElement>(null);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
fullName: '',
email: '',
password: '',
},
});
// Create wrapper functions for the server actions
const handleLogin = async () => {
if (await form.trigger()) {
const formData = new FormData(formRef.current!);
await login(formData);
}
};
const handleSignup = async () => {
if (await form.trigger()) {
const formData = new FormData(formRef.current!);
await signup(formData);
}
};
return (
<Card
className='flex flex-col items-center justify-center
bg-gradient-to-br from-background via-zinc-50 to-gray-50
dark:via-gray-950 dark:to-slate-950'
>
<CardHeader className='flex items-center justify-center'>
<CardTitle>Sign In</CardTitle>
<CardDescription>Log in or create an account.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form ref={formRef} className='space-y-4'>
<FormField
control={form.control}
name='fullName'
render={({ field }) => (
<FormItem>
<FormLabel htmlFor='fullName'>Full Name</FormLabel>
<FormControl>
<Input id='fullName' type='text' {...field} />
</FormControl>
<FormDescription>
Full name is only required when signing up.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel htmlFor='email'>Email</FormLabel>
<FormControl>
<Input id='email' type='email' {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input id='password' type='password' {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex gap-4 justify-center items-center'>
<Button
type='button'
className='w-5/12 font-semibold bg-gradient-to-r
from-primary via-primary to-primarydark
hover:from-primary hover:via-primarydark hover:to-primarydark'
onClick={handleLogin}
>
Log in
</Button>
<Button
type='button'
className='w-5/12 font-semibold bg-gradient-to-l
from-primary via-primary to-primarydark
hover:from-primary hover:via-primarydark hover:to-primarydark'
onClick={handleSignup}
>
Sign up
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className='flex flex-col items-center justify-center w-full'>
<div className='flex flex-row items-center justify-between w-5/6'>
<Separator className='w-5/12 h-0.5 rounded-3xl' />
<p className='text-center text-muted-foreground font-semibold'>or</p>
<Separator className='w-5/12 h-0.5 rounded-3xl' />
</div>
<div className='m-1'>
<MicrosoftSignIn />
<div className='my-2'>
<AppleSignIn />
</div>
</div>
</CardFooter>
</Card>
);
};
export default LoginForm;

View File

@ -1,22 +0,0 @@
import { Button } from '@/components/ui/button';
import Image from 'next/image';
const AppleSignIn = () => {
return (
<Button
className='flex flex-row items-center justify-center
dark:bg-white bg-slate-950 m-1 dark:hover:bg-slate-200
hover:bg-slate-700 w-full my-auto'
>
<Image
src='/images/apple_black.svg'
alt='Apple Logo'
width={16}
height={16}
className='invert dark:invert-0'
/>
<p className='font-semibold dark:text-slate-950'>Sign in with Apple</p>
</Button>
);
};
export default AppleSignIn;

View File

@ -1,23 +0,0 @@
import { Button } from '@/components/ui/button';
import Image from 'next/image';
const MicrosoftSignIn = () => {
return (
<Button
className='flex flex-row items-center justify-center
dark:bg-white bg-slate-950 m-1 dark:hover:bg-slate-200
hover:bg-slate-700 w-full'
>
<Image
src='/images/microsoft_logo.png'
alt='Microsoft Logo'
width={20}
height={20}
/>
<p className='font-semibold dark:text-slate-950'>
Sign in with Microsoft
</p>
</Button>
);
};
export default MicrosoftSignIn;

View File

@ -1,161 +0,0 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { createClient } from '@/utils/supabase/client';
import type { Session, User as SupabaseUser } from '@supabase/supabase-js';
import type { User } from '@/lib/types';
import { useRouter } from 'next/navigation';
interface AuthContextType {
session: Session | null;
supabaseUser: SupabaseUser | null;
user: User | null;
loading: boolean;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const supabase = createClient();
const router = useRouter();
const [session, setSession] = useState<Session | null>(null);
const [supabaseUser, setSupabaseUser] = useState<SupabaseUser | null>(null);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// Function to fetch user profile data
const fetchUserProfile = async (userId: string) => {
try {
const { data: userData, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (error) {
console.error('Error fetching user profile:', error);
return null;
}
return userData as User;
} catch (error) {
console.error('Error in fetchUserProfile:', error);
return null;
}
};
useEffect(() => {
// Function to fetch authenticated user and session
async function fetchAuthUser() {
try {
setLoading(true);
// Get authenticated user - this validates with the server
const { data: { user: authUser }, error: userError } = await supabase.auth.getUser();
if (userError) {
console.error('Error fetching authenticated user:', userError);
setSupabaseUser(null);
setSession(null);
setUser(null);
setLoading(false);
return;
}
setSupabaseUser(authUser);
// If we have an authenticated user, also get the session
if (authUser) {
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError) {
console.error('Error fetching session:', sessionError);
} else {
setSession(session);
}
// Fetch user profile data
const profileData = await fetchUserProfile(authUser.id);
setUser(profileData);
} else {
setSession(null);
setUser(null);
}
} catch (error) {
console.error('Error in fetchAuthUser:', error);
} finally {
setLoading(false);
}
}
// Initial fetch
fetchAuthUser().catch((error) => console.error(error));
// Set up auth state change listener
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
console.log('Auth state changed:', event);
setLoading(true);
if (session) {
// Get authenticated user to validate the session
const { data: { user: authUser }, error } = await supabase.auth.getUser();
if (error || !authUser) {
console.error('Error validating user after auth state change:', error);
setSupabaseUser(null);
setSession(null);
setUser(null);
} else {
setSupabaseUser(authUser);
setSession(session);
// Fetch user profile data
const profileData = await fetchUserProfile(authUser.id);
setUser(profileData);
}
} else {
setSupabaseUser(null);
setSession(null);
setUser(null);
}
setLoading(false);
// Force a router refresh to update server components
if (event === 'SIGNED_IN' || event === 'SIGNED_OUT') {
router.refresh();
}
}
);
return () => {
subscription.unsubscribe();
};
}, [supabase, router]);
const signOut = async () => {
await supabase.auth.signOut();
router.push('/');
};
return (
<AuthContext.Provider value={{
session,
supabaseUser,
user,
loading,
signOut
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -1,61 +0,0 @@
'use client';
import React, { createContext, useContext, useState } from 'react';
import Image from 'next/image';
import type { ReactNode } from 'react';
interface TVModeContextProps {
tvMode: boolean;
toggleTVMode: () => void;
}
const TVModeContext = createContext<TVModeContextProps | undefined>(undefined);
export const TVModeProvider = ({ children }: { children: ReactNode }) => {
const [tvMode, setTVMode] = useState(false);
const toggleTVMode = () => {
setTVMode((prev) => !prev);
};
return (
<TVModeContext.Provider value={{ tvMode, toggleTVMode }}>
{children}
</TVModeContext.Provider>
);
};
export const useTVMode = () => {
const context = useContext(TVModeContext);
if (!context) {
throw new Error('useTVMode must be used within a TVModeProvider');
}
return context;
};
type TVToggleProps = {
width?: number;
height?: number;
};
export const TVToggle = ({ width = 25, height = 25 }: TVToggleProps) => {
const { tvMode, toggleTVMode } = useTVMode();
return (
<button onClick={toggleTVMode} className='mr-4 mt-1'>
{tvMode ? (
<Image
src='/images/exit_fullscreen.svg'
alt='Exit TV Mode'
width={width}
height={height}
/>
) : (
<Image
src='/images/fullscreen.svg'
alt='Enter TV Mode'
width={width}
height={height}
/>
)}
</button>
);
};

View File

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

View File

@ -3,7 +3,7 @@ import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui';
export const ThemeProvider = ({ export const ThemeProvider = ({
children, children,
@ -20,7 +20,12 @@ export const ThemeProvider = ({
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}; };
export const ThemeToggle = () => { export interface ThemeToggleProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: number;
}
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);
@ -30,8 +35,8 @@ export const ThemeToggle = () => {
if (!mounted) { if (!mounted) {
return ( return (
<Button variant='outline' size='icon'> <Button variant='outline' size='icon' {...props}>
<span className='h-[1.2rem] w-[1.2rem]' /> <span style={{ height: `${size}rem`, width: `${size}rem` }} />
</Button> </Button>
); );
} }
@ -42,9 +47,21 @@ export const ThemeToggle = () => {
}; };
return ( return (
<Button variant='outline' size='icon' onClick={toggleTheme}> <Button
<Sun className='h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' /> variant='outline'
<Moon className='absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' /> size='icon'
className='cursor-pointer'
onClick={toggleTheme}
{...props}
>
<Sun
style={{ height: `${size}rem`, width: `${size}rem` }}
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
/>
<Moon
style={{ height: `${size}rem`, width: `${size}rem` }}
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
/>
<span className='sr-only'>Toggle theme</span> <span className='sr-only'>Toggle theme</span>
</Button> </Button>
); );

View File

@ -0,0 +1,25 @@
export type Message =
| { success: string }
| { error: string }
| { message: string };
export const StatusMessage = ({ message }: { message: Message }) => {
return (
<div
className='flex flex-col gap-2 w-full max-w-md
text-sm bg-accent rounded-md p-2 px-4'
>
{'success' in message && (
<div className='dark:text-green-500 text-green-700'>
{message.success}
</div>
)}
{'error' in message && (
<div className='text-destructive'>{message.error}</div>
)}
{'message' in message && (
<div className='text-foreground'>{message.message}</div>
)}
</div>
);
};

View File

@ -0,0 +1,39 @@
'use client';
import { Button } from '@/components/ui';
import { type ComponentProps } from 'react';
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';
type Props = ComponentProps<typeof Button> & {
disabled?: boolean;
pendingText?: string;
};
export const SubmitButton = ({
children,
disabled = false,
pendingText = 'Submitting...',
...props
}: Props) => {
const { pending } = useFormStatus();
return (
<Button
className='cursor-pointer'
type='submit'
aria-disabled={pending}
disabled={disabled}
{...props}
>
{pending || disabled ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{pendingText}
</>
) : (
children
)}
</Button>
);
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export * from './SignInSignUp';
export * from './SignInWithApple';
export * from './SignInWithMicrosoft';

View File

@ -0,0 +1,20 @@
'use server';
const Footer = () => {
return (
<footer className='w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'>
<p>
Powered by{' '}
<a
href='https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs'
target='_blank'
className='font-bold hover:underline'
rel='noreferrer'
>
Supabase
</a>
</p>
</footer>
);
};
export default Footer;

View File

@ -0,0 +1,5 @@
export {
StatusMessage,
type Message,
} from '@/components/default/StatusMessage';
export { SubmitButton } from '@/components/default/SubmitButton';

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/auth';
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

@ -0,0 +1,22 @@
'use server';
import { getProfile } from '@/lib/actions';
import AvatarDropdown from './AvatarDropdown';
import { SignInSignUp } from '@/components/default/auth';
const NavigationAuth = async () => {
try {
const profile = await getProfile();
return profile.success ? (
<div className='flex items-center gap-4'>
<AvatarDropdown />
</div>
) : (
<SignInSignUp />
);
} catch (error) {
console.error(`Error getting profile: ${error as string}`);
return <SignInSignUp />;
}
};
export default NavigationAuth;

View File

@ -0,0 +1,40 @@
'use server';
import Link from 'next/link';
import { Button } from '@/components/ui';
import NavigationAuth from './auth';
import { ThemeToggle } from '@/components/context/theme';
import Image from 'next/image';
const Navigation = () => {
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'>T3 Supabase Template</h1>
</Link>
<div className='flex items-center gap-2'>
<Button asChild>
<Link href='https://git.gbrown.org/gib/T3-Template'>
Go to Git Repo
</Link>
</Button>
</div>
</div>
<div className='flex items-center gap-2'>
<ThemeToggle />
<NavigationAuth />
</div>
</div>
</nav>
);
};
export default Navigation;

View File

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

View File

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

View File

@ -0,0 +1,150 @@
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@/components/ui';
import { SubmitButton } from '@/components/default';
import { useState } from 'react';
import { type Result } from '@/lib/actions';
import { StatusMessage } from '@/components/default';
const formSchema = z
.object({
password: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});
type ResetPasswordFormProps = {
onSubmit: (formData: FormData) => Promise<Result<null>>;
message?: string;
};
export const ResetPasswordForm = ({
onSubmit,
message,
}: ResetPasswordFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const [statusMessage, setStatusMessage] = useState(message ?? '');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
password: '',
confirmPassword: '',
},
});
const handleUpdatePassword = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true);
try {
// Convert form values to FormData for your server action
const formData = new FormData();
formData.append('password', values.password);
formData.append('confirmPassword', values.confirmPassword);
const result = await onSubmit(formData);
if (result?.success) {
setStatusMessage('Password updated successfully!');
form.reset(); // Clear the form on success
} else {
setStatusMessage('Error: Unable to update password!');
}
} catch (error) {
setStatusMessage(
error instanceof Error ? error.message : 'Password was not updated!',
);
} finally {
setIsLoading(false);
}
};
return (
<div>
<CardHeader className='pb-5'>
<CardTitle className='text-2xl'>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleUpdatePassword)}
className='space-y-6'
>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input type='password' {...field} />
</FormControl>
<FormDescription>
Enter your new password. Must be at least 8 characters.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input type='password' {...field} />
</FormControl>
<FormDescription>
Please re-enter your new password to confirm.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{statusMessage && (
<div
className={`text-sm text-center ${
statusMessage.includes('Error') ||
statusMessage.includes('failed')
? 'text-destructive'
: 'text-green-600'
}`}
>
{statusMessage}
</div>
)}
<div className='flex justify-center'>
<SubmitButton
disabled={isLoading}
pendingText='Updating Password...'
>
Update Password
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</div>
);
};

View File

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

View File

@ -0,0 +1,4 @@
export * from './AvatarUpload';
export * from './ProfileForm';
export * from './ResetPasswordForm';
export * from './SignOut';

View File

@ -0,0 +1,126 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import { useState, useEffect } from 'react';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from '@/components/ui';
import Link from 'next/link';
import { CheckCircle, MessageCircleWarning } from 'lucide-react';
class SentryExampleFrontendError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = 'SentryExampleFrontendError';
}
}
export const TestSentryCard = () => {
const [hasSentError, setHasSentError] = useState(false);
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
const checkConnectivity = async () => {
console.log('Checking Sentry SDK connectivity...');
const result = await Sentry.diagnoseSdkConnectivity();
setIsConnected(result !== 'sentry-unreachable');
};
checkConnectivity().catch((error) => {
console.error('Error trying to connect to Sentry: ', error);
});
}, []);
const createError = async () => {
await Sentry.startSpan(
{
name: 'Example Frontend Span',
op: 'test',
},
async () => {
const res = await fetch('/api/sentry/example');
if (!res.ok) {
setHasSentError(true);
throw new SentryExampleFrontendError(
'This error is raised in our TestSentry component on the main page.',
);
}
},
);
};
return (
<Card>
<CardHeader>
<div className='flex flex-row my-auto space-x-4'>
<svg
height='40'
width='40'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M21.85 2.995a3.698 3.698 0 0 1 1.353 1.354l16.303 28.278a3.703 3.703 0 0 1-1.354 5.053 3.694 3.694 0 0 1-1.848.496h-3.828a31.149 31.149 0 0 0 0-3.09h3.815a.61.61 0 0 0 .537-.917L20.523 5.893a.61.61 0 0 0-1.057 0l-3.739 6.494a28.948 28.948 0 0 1 9.63 10.453 28.988 28.988 0 0 1 3.499 13.78v1.542h-9.852v-1.544a19.106 19.106 0 0 0-2.182-8.85 19.08 19.08 0 0 0-6.032-6.829l-1.85 3.208a15.377 15.377 0 0 1 6.382 12.484v1.542H3.696A3.694 3.694 0 0 1 0 34.473c0-.648.17-1.286.494-1.849l2.33-4.074a8.562 8.562 0 0 1 2.689 1.536L3.158 34.17a.611.611 0 0 0 .538.917h8.448a12.481 12.481 0 0 0-6.037-9.09l-1.344-.772 4.908-8.545 1.344.77a22.16 22.16 0 0 1 7.705 7.444 22.193 22.193 0 0 1 3.316 10.193h3.699a25.892 25.892 0 0 0-3.811-12.033 25.856 25.856 0 0 0-9.046-8.796l-1.344-.772 5.269-9.136a3.698 3.698 0 0 1 3.2-1.849c.648 0 1.285.17 1.847.495Z'
fill='currentcolor'
/>
</svg>
<CardTitle className='text-3xl my-auto'>Test Sentry</CardTitle>
</div>
<CardDescription className='text-[1.0rem]'>
Click the button below & view the sample error on{' '}
<Link
href={`${process.env.NEXT_PUBLIC_SENTRY_URL}`}
className='text-accent-foreground underline hover:text-primary'
>
the Sentry website
</Link>
. Navigate to the {"'"}Issues{"'"} page & you should see the sample
error!
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-row gap-4 my-auto'>
<Button
type='button'
onClick={createError}
className='cursor-pointer text-md my-auto py-6'
>
<span>Throw Sample Error</span>
</Button>
{hasSentError ? (
<div className='rounded-md bg-green-500/80 dark:bg-green-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
<CheckCircle size={30} className='my-auto' />
<p className='text-lg'>Sample error was sent to Sentry!</p>
</div>
) : !isConnected ? (
<div className='rounded-md bg-red-600/50 dark:bg-red-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
<MessageCircleWarning size={40} className='my-auto' />
<p>
Wait! The Sentry SDK is not able to reach Sentry right now -
this may be due to an adblocker. For more information, see{' '}
<Link
href='https://docs.sentry.io/platforms/javascript/guides/nextjs/troubleshooting/#the-sdk-is-not-sending-any-data'
className='text-accent-foreground underline hover:text-primary'
>
the troubleshooting guide.
</Link>
</p>
</div>
) : (
<div className='success_placeholder' />
)}
</div>
<Separator className='my-4 bg-accent' />
<p className='description'>
Warning! Sometimes Adblockers will prevent errors from being sent to
Sentry.
</p>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1 @@
export { TestSentryCard } from './TestSentry';

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More