Switching to tabs over spaces!
This commit is contained in:
@ -1,25 +1,25 @@
|
||||
export type Message =
|
||||
| { success: string }
|
||||
| { error: string }
|
||||
| { message: string };
|
||||
| { 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
|
||||
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>
|
||||
);
|
||||
>
|
||||
{'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>
|
||||
);
|
||||
};
|
||||
|
@ -6,34 +6,34 @@ import { useFormStatus } from 'react-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type Props = ComponentProps<typeof Button> & {
|
||||
disabled?: boolean;
|
||||
pendingText?: string;
|
||||
disabled?: boolean;
|
||||
pendingText?: string;
|
||||
};
|
||||
|
||||
export const SubmitButton = ({
|
||||
children,
|
||||
disabled = false,
|
||||
pendingText = 'Submitting...',
|
||||
...props
|
||||
children,
|
||||
disabled = false,
|
||||
pendingText = 'Submitting...',
|
||||
...props
|
||||
}: Props) => {
|
||||
const { pending } = useFormStatus();
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -6,28 +6,28 @@ 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'];
|
||||
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',
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -10,68 +10,70 @@ 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'];
|
||||
className?: ComponentProps<'div'>['className'];
|
||||
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
||||
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||
};
|
||||
|
||||
export const SignInWithApple = ({
|
||||
className = 'my-4',
|
||||
buttonSize = 'default',
|
||||
buttonVariant = 'default',
|
||||
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 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 handleSignInWithApple = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setStatusMessage('');
|
||||
setIsSigningIn(true);
|
||||
|
||||
const result = await signInWithApple();
|
||||
const result = await signInWithApple();
|
||||
|
||||
if (result?.success && result.data) {
|
||||
// Redirect to Apple OAuth page
|
||||
window.location.href = result.data;
|
||||
} else {
|
||||
setStatusMessage(`Error signing in with Apple!`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
await refreshUserData();
|
||||
router.push('');
|
||||
}
|
||||
};
|
||||
if (result?.success && result.data) {
|
||||
// Redirect to Apple OAuth page
|
||||
window.location.href = result.data;
|
||||
} else {
|
||||
setStatusMessage(`Error signing in with Apple!`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
await refreshUserData();
|
||||
router.push('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSignInWithApple} className={className}>
|
||||
<SubmitButton
|
||||
size={buttonSize}
|
||||
variant={buttonVariant}
|
||||
className='w-full cursor-pointer'
|
||||
disabled={isLoading || isSigningIn}
|
||||
pendingText='Redirecting...'
|
||||
type='submit'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Image
|
||||
src='/icons/apple.svg'
|
||||
alt='Apple logo'
|
||||
className='invert-75 dark:invert-25'
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
<p className='text-[1.0rem]'>Sign In with Apple</p>
|
||||
</div>
|
||||
</SubmitButton>
|
||||
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
||||
</form>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -9,62 +9,64 @@ 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'];
|
||||
className?: ComponentProps<'div'>['className'];
|
||||
buttonSize?: VariantProps<typeof buttonVariants>['size'];
|
||||
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||
};
|
||||
|
||||
export const SignInWithMicrosoft = ({
|
||||
className = 'my-4',
|
||||
buttonSize = 'default',
|
||||
buttonVariant = 'default',
|
||||
className = 'my-4',
|
||||
buttonSize = 'default',
|
||||
buttonVariant = 'default',
|
||||
}: SignInWithMicrosoftProps) => {
|
||||
const { isLoading } = useAuth();
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
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 handleSignInWithMicrosoft = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setStatusMessage('');
|
||||
setIsSigningIn(true);
|
||||
|
||||
const result = await signInWithMicrosoft();
|
||||
const result = await signInWithMicrosoft();
|
||||
|
||||
if (result?.success && result.data) {
|
||||
// Redirect to Microsoft OAuth page
|
||||
window.location.href = result.data;
|
||||
} else {
|
||||
setStatusMessage(`Error: Could not sign in with Microsoft!`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
if (result?.success && result.data) {
|
||||
// Redirect to Microsoft OAuth page
|
||||
window.location.href = result.data;
|
||||
} else {
|
||||
setStatusMessage(`Error: Could not sign in with Microsoft!`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSignInWithMicrosoft} className={className}>
|
||||
<SubmitButton
|
||||
size={buttonSize}
|
||||
variant={buttonVariant}
|
||||
className='w-full cursor-pointer'
|
||||
disabled={isLoading || isSigningIn}
|
||||
pendingText='Redirecting...'
|
||||
type='submit'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Image
|
||||
src='/icons/microsoft.svg'
|
||||
alt='Microsoft logo'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
|
||||
</div>
|
||||
</SubmitButton>
|
||||
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
||||
</form>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -1,20 +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>
|
||||
);
|
||||
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;
|
||||
|
@ -1,5 +1,5 @@
|
||||
export {
|
||||
StatusMessage,
|
||||
type Message,
|
||||
StatusMessage,
|
||||
type Message,
|
||||
} from '@/components/default/StatusMessage';
|
||||
export { SubmitButton } from '@/components/default/SubmitButton';
|
||||
|
@ -2,15 +2,15 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@ -18,70 +18,70 @@ import { signOut } from '@/lib/actions';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
const AvatarDropdown = () => {
|
||||
const { profile, avatarUrl, isLoading, refreshUserData } = useAuth();
|
||||
const router = useRouter();
|
||||
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 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();
|
||||
};
|
||||
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>
|
||||
);
|
||||
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;
|
||||
|
@ -5,18 +5,18 @@ 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 />;
|
||||
}
|
||||
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;
|
||||
|
@ -7,35 +7,42 @@ import { ThemeToggle, TVToggle } from '@/components/context';
|
||||
import Image from 'next/image';
|
||||
|
||||
const Navigation = () => {
|
||||
return (
|
||||
<nav
|
||||
className='w-full flex justify-center
|
||||
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
|
||||
>
|
||||
<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'>
|
||||
<TVToggle />
|
||||
<ThemeToggle />
|
||||
<NavigationAuth />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
>
|
||||
<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'>
|
||||
<TVToggle />
|
||||
<ThemeToggle />
|
||||
<NavigationAuth />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
export default Navigation;
|
||||
|
@ -1,112 +1,112 @@
|
||||
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
||||
import { useAuth } from '@/components/context';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
CardContent,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
CardContent,
|
||||
} from '@/components/ui';
|
||||
import { Loader2, Pencil, Upload, User } from 'lucide-react';
|
||||
|
||||
type AvatarUploadProps = {
|
||||
onAvatarUploaded: (path: string) => Promise<void>;
|
||||
onAvatarUploaded: (path: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
||||
const { profile, avatarUrl } = useAuth();
|
||||
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
|
||||
const { profile, avatarUrl } = useAuth();
|
||||
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
const handleAvatarClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
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 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();
|
||||
};
|
||||
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
|
||||
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
|
||||
>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -2,99 +2,108 @@ 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,
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '@/components/context';
|
||||
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(),
|
||||
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>;
|
||||
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
|
||||
const { profile, isLoading } = useAuth();
|
||||
const { profile, isLoading } = useAuth();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
full_name: profile?.full_name ?? '',
|
||||
email: profile?.email ?? '',
|
||||
},
|
||||
});
|
||||
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]);
|
||||
// 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);
|
||||
};
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
<div className='flex justify-center'>
|
||||
<SubmitButton
|
||||
disabled={isLoading}
|
||||
pendingText='Saving...'
|
||||
>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
|
@ -2,18 +2,18 @@ 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,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { SubmitButton } from '@/components/default';
|
||||
import { useState } from 'react';
|
||||
@ -21,127 +21,135 @@ 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'],
|
||||
});
|
||||
.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;
|
||||
onSubmit: (formData: FormData) => Promise<Result<null>>;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = ({
|
||||
onSubmit,
|
||||
message,
|
||||
onSubmit,
|
||||
message,
|
||||
}: ResetPasswordFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState(message ?? '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState(message ?? '');
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
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 &&
|
||||
(statusMessage.includes('Error') ||
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ message: statusMessage }} />
|
||||
))}
|
||||
<div className='flex justify-center'>
|
||||
<SubmitButton
|
||||
disabled={isLoading}
|
||||
pendingText='Updating Password...'
|
||||
>
|
||||
Update Password
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</div>
|
||||
);
|
||||
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 &&
|
||||
(statusMessage.includes('Error') ||
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage
|
||||
message={{ error: statusMessage }}
|
||||
/>
|
||||
) : (
|
||||
<StatusMessage
|
||||
message={{ message: statusMessage }}
|
||||
/>
|
||||
))}
|
||||
<div className='flex justify-center'>
|
||||
<SubmitButton
|
||||
disabled={isLoading}
|
||||
pendingText='Updating Password...'
|
||||
>
|
||||
Update Password
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -7,28 +7,28 @@ import { useAuth } from '@/components/context';
|
||||
import { signOut } from '@/lib/actions';
|
||||
|
||||
export const SignOut = () => {
|
||||
const { isLoading, refreshUserData } = useAuth();
|
||||
const router = useRouter();
|
||||
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
|
||||
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>
|
||||
);
|
||||
disabled={isLoading}
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign Out
|
||||
</SubmitButton>
|
||||
</CardHeader>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -3,124 +3,132 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Separator,
|
||||
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';
|
||||
}
|
||||
constructor(message: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'SentryExampleFrontendError';
|
||||
}
|
||||
}
|
||||
|
||||
export const TestSentryCard = () => {
|
||||
const [hasSentError, setHasSentError] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(true);
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
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.',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -4,58 +4,58 @@ 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>
|
||||
<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>
|
||||
<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 [icon, setIcon] = useState(CopyIcon);
|
||||
|
||||
const copy = async () => {
|
||||
await navigator?.clipboard?.writeText(code);
|
||||
setIcon(CheckIcon);
|
||||
setTimeout(() => setIcon(CopyIcon), 2000);
|
||||
};
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -44,52 +44,52 @@ export default function Page() {
|
||||
`.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>
|
||||
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='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>
|
||||
);
|
||||
<TutorialStep title='Build in a weekend and scale to millions!'>
|
||||
<p>You're ready to launch your product to the world! 🚀</p>
|
||||
</TutorialStep>
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
|
@ -1,30 +1,30 @@
|
||||
import { Checkbox } from '@/components/ui';
|
||||
|
||||
export const TutorialStep = ({
|
||||
title,
|
||||
children,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
Reference in New Issue
Block a user