wipe out old repo & replace with template
33
.env.example
@ -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
|
||||||
|
@ -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
@ -0,0 +1,2 @@
|
|||||||
|
public-hoist-pattern[]=*eslint*
|
||||||
|
public-hoist-pattern[]=*prettier*
|
43
README.md
@ -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
|
|
||||||
```
|
```
|
||||||
|
@ -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
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
@ -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
@ -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('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
```
|
|
88
package.json
@ -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
5
pnpm-workspace.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@sentry/cli'
|
||||||
|
- '@tailwindcss/oxide'
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
@ -1,5 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
BIN
public/appicon/icon-114x114.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
public/appicon/icon-120x120.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
public/appicon/icon-144x144.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
public/appicon/icon-152x152.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
public/appicon/icon-180x180.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
public/appicon/icon-36x36.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
public/appicon/icon-48x48.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
public/appicon/icon-57x57.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
public/appicon/icon-60x60.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
public/appicon/icon-72x72.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
public/appicon/icon-76x76.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
public/appicon/icon-96x96.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
public/appicon/icon-precomposed.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
public/appicon/icon.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 3.6 KiB |
BIN
public/favicon.ico
Executable file → Normal file
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon.png
Normal file
After Width: | Height: | Size: 13 KiB |
19
public/icons/apple.svg
Normal 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 |
2
public/icons/microsoft.svg
Normal 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 |
@ -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 |
@ -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 |
Before Width: | Height: | Size: 126 KiB |
@ -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 |
@ -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 |
Before Width: | Height: | Size: 16 KiB |
@ -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 |
Before Width: | Height: | Size: 260 KiB |
@ -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 |
Before Width: | Height: | Size: 98 KiB |
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 386 KiB |
@ -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
|
@ -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
|
@ -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
@ -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}"
|
@ -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);
|
||||||
|
@ -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;
|
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
|
||||||
});
|
|
@ -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,
|
|
||||||
});
|
|
@ -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,
|
||||||
|
43
src/app/(auth-pages)/auth/callback/route.ts
Normal 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('/');
|
||||||
|
};
|
39
src/app/(auth-pages)/auth/success/page.tsx
Normal 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;
|
129
src/app/(auth-pages)/forgot-password/page.tsx
Normal 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'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;
|
123
src/app/(auth-pages)/profile/page.tsx
Normal 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;
|
171
src/app/(auth-pages)/sign-in/page.tsx
Normal 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'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;
|
210
src/app/(auth-pages)/sign-up/page.tsx
Normal 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;
|
16
src/app/(sentry)/api/sentry/example/route.ts
Normal 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...' });
|
||||||
|
}
|
@ -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...' });
|
|
||||||
}
|
|
@ -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);
|
|
||||||
};
|
|
@ -1,4 +0,0 @@
|
|||||||
const ErrorPage = () => {
|
|
||||||
return <p>Sorry, something went wrong</p>;
|
|
||||||
};
|
|
||||||
export default ErrorPage;
|
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
195
src/components/context/auth.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
'use client';
|
||||||
|
import React, {
|
||||||
|
type ReactNode,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
getProfile,
|
||||||
|
getSignedUrl,
|
||||||
|
getUser,
|
||||||
|
updateProfile as updateProfileAction,
|
||||||
|
} from '@/lib/hooks';
|
||||||
|
import { type User, type Profile, createClient } from '@/utils/supabase';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
type AuthContextType = {
|
||||||
|
user: User | null;
|
||||||
|
profile: Profile | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
updateProfile: (data: {
|
||||||
|
full_name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
|
||||||
|
refreshUserData: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
|
const fetchUserData = useCallback(
|
||||||
|
async (showLoading = true) => {
|
||||||
|
if (fetchingRef.current) return;
|
||||||
|
fetchingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only show loading for initial load or manual refresh
|
||||||
|
if (showLoading) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResponse = await getUser();
|
||||||
|
const profileResponse = await getProfile();
|
||||||
|
|
||||||
|
if (!userResponse.success || !profileResponse.success) {
|
||||||
|
setUser(null);
|
||||||
|
setProfile(null);
|
||||||
|
setAvatarUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(userResponse.data);
|
||||||
|
setProfile(profileResponse.data);
|
||||||
|
|
||||||
|
// Get avatar URL if available
|
||||||
|
if (profileResponse.data.avatar_url) {
|
||||||
|
const avatarResponse = await getSignedUrl({
|
||||||
|
bucket: 'avatars',
|
||||||
|
url: profileResponse.data.avatar_url,
|
||||||
|
});
|
||||||
|
if (avatarResponse.success) {
|
||||||
|
setAvatarUrl(avatarResponse.data);
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Auth fetch error: ',
|
||||||
|
error instanceof Error
|
||||||
|
? `${error.message}`
|
||||||
|
: 'Failed to load user data!',
|
||||||
|
);
|
||||||
|
if (!isInitialized) {
|
||||||
|
toast.error('Failed to load user data!');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
|
fetchingRef.current = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isInitialized],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
// Initial fetch with loading
|
||||||
|
fetchUserData(true).catch((error) => {
|
||||||
|
console.error('💥 Initial fetch error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
|
console.log('Auth state change:', event); // Debug log
|
||||||
|
|
||||||
|
if (event === 'SIGNED_IN') {
|
||||||
|
// Background refresh without loading state
|
||||||
|
await fetchUserData(false);
|
||||||
|
} else if (event === 'SIGNED_OUT') {
|
||||||
|
setUser(null);
|
||||||
|
setProfile(null);
|
||||||
|
setAvatarUrl(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
} else if (event === 'TOKEN_REFRESHED') {
|
||||||
|
// Silent refresh - don't show loading
|
||||||
|
await fetchUserData(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [fetchUserData]);
|
||||||
|
|
||||||
|
const updateProfile = useCallback(
|
||||||
|
async (data: {
|
||||||
|
full_name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const result = await updateProfileAction(data);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? 'Failed to update profile');
|
||||||
|
}
|
||||||
|
setProfile(result.data);
|
||||||
|
|
||||||
|
// If avatar was updated, refresh the avatar URL
|
||||||
|
if (data.avatar_url && result.data.avatar_url) {
|
||||||
|
const avatarResponse = await getSignedUrl({
|
||||||
|
bucket: 'avatars',
|
||||||
|
url: result.data.avatar_url,
|
||||||
|
transform: { width: 128, height: 128 },
|
||||||
|
});
|
||||||
|
if (avatarResponse.success) {
|
||||||
|
setAvatarUrl(avatarResponse.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success('Profile updated successfully!');
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile:', error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to update profile',
|
||||||
|
);
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshUserData = useCallback(async () => {
|
||||||
|
await fetchUserData(true); // Manual refresh shows loading
|
||||||
|
}, [fetchUserData]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
avatarUrl,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
updateProfile,
|
||||||
|
refreshUserData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
@ -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>
|
||||||
);
|
);
|
25
src/components/default/StatusMessage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
39
src/components/default/SubmitButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
33
src/components/default/auth/SignInSignUp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
77
src/components/default/auth/SignInWithApple.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
70
src/components/default/auth/SignInWithMicrosoft.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
3
src/components/default/auth/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './SignInSignUp';
|
||||||
|
export * from './SignInWithApple';
|
||||||
|
export * from './SignInWithMicrosoft';
|
20
src/components/default/footer/index.tsx
Normal 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;
|
5
src/components/default/index.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
StatusMessage,
|
||||||
|
type Message,
|
||||||
|
} from '@/components/default/StatusMessage';
|
||||||
|
export { SubmitButton } from '@/components/default/SubmitButton';
|
87
src/components/default/navigation/auth/AvatarDropdown.tsx
Normal 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;
|
22
src/components/default/navigation/auth/index.tsx
Normal 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;
|
40
src/components/default/navigation/index.tsx
Normal 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;
|
112
src/components/default/profile/AvatarUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
100
src/components/default/profile/ProfileForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
150
src/components/default/profile/ResetPasswordForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
34
src/components/default/profile/SignOut.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
4
src/components/default/profile/index.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './AvatarUpload';
|
||||||
|
export * from './ProfileForm';
|
||||||
|
export * from './ResetPasswordForm';
|
||||||
|
export * from './SignOut';
|
126
src/components/default/sentry/TestSentry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
1
src/components/default/sentry/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { TestSentryCard } from './TestSentry';
|
61
src/components/default/tutorial/CodeBlock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
95
src/components/default/tutorial/FetchDataSteps.tsx
Normal 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'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're ready to launch your product to the world! 🚀</p>
|
||||||
|
</TutorialStep>
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
};
|
30
src/components/default/tutorial/TutorialStep.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
3
src/components/default/tutorial/index.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { CodeBlock } from './CodeBlock';
|
||||||
|
export { FetchDataSteps } from './FetchDataSteps';
|
||||||
|
export { TutorialStep } from './TutorialStep';
|