Made great progress on monorepo & auth for next. Very happy with work!

This commit is contained in:
2026-01-12 11:55:15 -06:00
parent 72f11f0b02
commit 321fecb5e1
58 changed files with 1266 additions and 222 deletions

View File

@@ -1 +1 @@
[["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21"],{"key":"22","value":"23"},{"key":"24","value":"25"},{"key":"26","value":"27"},{"key":"28","value":"29"},{"key":"30","value":"31"},{"key":"32","value":"33"},{"key":"34","value":"35"},{"key":"36","value":"37"},{"key":"38","value":"39"},{"key":"40","value":"41"},{"key":"42","value":"43"},{"key":"44","value":"45"},{"key":"46","value":"47"},{"key":"48","value":"49"},{"key":"50","value":"51"},{"key":"52","value":"53"},{"key":"54","value":"55"},{"key":"56","value":"57"},{"key":"58","value":"59"},{"key":"60","value":"61"},{"key":"62","value":"63"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/package.json",{"size":2249,"mtime":1766222924000,"hash":"64","data":"65"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/index.ts",{"size":28,"mtime":1768155639384,"hash":"66","data":"67"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/metro.config.js",{"size":511,"mtime":1768155639442,"hash":"68","data":"69"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/index.tsx",{"size":5019,"mtime":1768155639734,"hash":"70","data":"71"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/turbo.json",{"size":163,"mtime":1766222924000,"hash":"72","data":"73"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/postcss.config.js",{"size":66,"mtime":1768155639501,"hash":"74","data":"75"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eas.json",{"size":567,"mtime":1766222924000,"hash":"76","data":"77"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.expo-shared/assets.json",{"size":155,"mtime":1766222924000,"hash":"78","data":"79"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/_layout.tsx",{"size":927,"mtime":1768155639581,"hash":"80","data":"81"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/nativewind-env.d.ts",{"size":246,"mtime":1766222924000,"hash":"82","data":"83"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/auth.ts",{"size":398,"mtime":1768155639911,"hash":"84","data":"85"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/post/[id].tsx",{"size":757,"mtime":1768155639785,"hash":"86","data":"87"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/styles.css",{"size":90,"mtime":1768155639819,"hash":"88","data":"89"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/session-store.ts",{"size":272,"mtime":1768155639965,"hash":"90","data":"91"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-dark.png",{"size":19633,"mtime":1766222924000,"hash":"92"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eslint.config.mts",{"size":275,"mtime":1768155639360,"hash":"93","data":"94"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/api.tsx",{"size":1326,"mtime":1768155639881,"hash":"95","data":"96"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/base-url.ts",{"size":880,"mtime":1768155639943,"hash":"97","data":"98"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/app.config.ts",{"size":1333,"mtime":1768155639275,"hash":"99","data":"100"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-light.png",{"size":19133,"mtime":1766222924000,"hash":"101"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/tsconfig.json",{"size":387,"mtime":1766228480000,"hash":"102","data":"103"},"d8763702c14cdc382dcfb84f6f9a068f",{"hashOfOptions":"104"},"11cdbef6afa001cd39bc187041ca6865",{"hashOfOptions":"105"},"dbe97bcde588a81538bbcd6a9befdddd",{"hashOfOptions":"106"},"1c10eb388cf5dcbc87d2d63770a227f1",{"hashOfOptions":"107"},"c7d4dcf839dfeaa02e0407adfd5e47a6",{"hashOfOptions":"108"},"b7edffce093c4c84092cc93f3dc208ef",{"hashOfOptions":"109"},"a3c1487f8318513ae7c156acc857fde2",{"hashOfOptions":"110"},"0f7f54c7161b8403d3bc42d91f59cd91",{"hashOfOptions":"111"},"8e407b4b1b0c0bd9c862a00243344be3",{"hashOfOptions":"112"},"d4d589c153ac8b5e7bf0fb130a5b5a7d",{"hashOfOptions":"113"},"cecbed1604a530a7cc099fecddddd76c",{"hashOfOptions":"114"},"4fcefde979d34a7339f7a266d3ec931b",{"hashOfOptions":"115"},"52a1d72379b952dd802f47e1865bd0da",{"hashOfOptions":"116"},"1bc3e15a40c117eecc51294886ea9b38",{"hashOfOptions":"117"},"1e8ac0d261e95efb19d290ffcf70ce36","1c1710ce3de3ce02e8054cc3787c8579",{"hashOfOptions":"118"},"5ff899a601102659dcbd2900e415ce8b",{"hashOfOptions":"119"},"dd2007a211e323deabb3f7fa7d16313f",{"hashOfOptions":"120"},"4f49c6df7733f874fbe72b4e20b3092b",{"hashOfOptions":"121"},"863da15dbd856008b7c24077ca746d91","6937fb7370f1e17491df649888d6ecc9",{"hashOfOptions":"122"},"2742538293","3367041120","3963086909","3173464737","3850474653","3346849767","2045418596","3048291867","3525769784","4000169781","471581913","1427279653","2305419495","818733105","2587128410","1262173017","408469422","2836740315","2315460658"] [["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22"],{"key":"23","value":"24"},{"key":"25","value":"26"},{"key":"27","value":"28"},{"key":"29","value":"30"},{"key":"31","value":"32"},{"key":"33","value":"34"},{"key":"35","value":"36"},{"key":"37","value":"38"},{"key":"39","value":"40"},{"key":"41","value":"42"},{"key":"43","value":"44"},{"key":"45","value":"46"},{"key":"47","value":"48"},{"key":"49","value":"50"},{"key":"51","value":"52"},{"key":"53","value":"54"},{"key":"55","value":"56"},{"key":"57","value":"58"},{"key":"59","value":"60"},{"key":"61","value":"62"},{"key":"63","value":"64"},{"key":"65","value":"66"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/package.json",{"size":2249,"mtime":1766222924000,"hash":"67","data":"68"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/index.ts",{"size":28,"mtime":1768155639000,"hash":"69","data":"70"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/metro.config.js",{"size":511,"mtime":1768155639000,"hash":"71","data":"72"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/index.tsx",{"size":5019,"mtime":1768155639000,"hash":"73","data":"74"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/turbo.json",{"size":163,"mtime":1766222924000,"hash":"75","data":"76"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/postcss.config.js",{"size":66,"mtime":1768155639000,"hash":"77","data":"78"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.cache/.prettiercache",{"size":4787,"mtime":1768171236840,"hash":"79"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eas.json",{"size":567,"mtime":1766222924000,"hash":"80","data":"81"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.expo-shared/assets.json",{"size":155,"mtime":1766222924000,"hash":"82","data":"83"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/_layout.tsx",{"size":927,"mtime":1768155639000,"hash":"84","data":"85"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/nativewind-env.d.ts",{"size":246,"mtime":1766222924000,"hash":"86","data":"87"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/auth.ts",{"size":398,"mtime":1768155639000,"hash":"88","data":"89"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/post/[id].tsx",{"size":757,"mtime":1768155639000,"hash":"90","data":"91"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/styles.css",{"size":90,"mtime":1768155639000,"hash":"92","data":"93"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/session-store.ts",{"size":272,"mtime":1768155639000,"hash":"94","data":"95"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-dark.png",{"size":19633,"mtime":1766222924000,"hash":"96"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eslint.config.mts",{"size":275,"mtime":1768155639000,"hash":"97","data":"98"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/api.tsx",{"size":1326,"mtime":1768155639000,"hash":"99","data":"100"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/base-url.ts",{"size":880,"mtime":1768155639000,"hash":"101","data":"102"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/app.config.ts",{"size":1333,"mtime":1768155639000,"hash":"103","data":"104"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-light.png",{"size":19133,"mtime":1766222924000,"hash":"105"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/tsconfig.json",{"size":387,"mtime":1766228480000,"hash":"106","data":"107"},"d8763702c14cdc382dcfb84f6f9a068f",{"hashOfOptions":"108"},"11cdbef6afa001cd39bc187041ca6865",{"hashOfOptions":"109"},"dbe97bcde588a81538bbcd6a9befdddd",{"hashOfOptions":"110"},"1c10eb388cf5dcbc87d2d63770a227f1",{"hashOfOptions":"111"},"c7d4dcf839dfeaa02e0407adfd5e47a6",{"hashOfOptions":"112"},"b7edffce093c4c84092cc93f3dc208ef",{"hashOfOptions":"113"},"75e4e9158c89e7a7bc04e94fd2366743","a3c1487f8318513ae7c156acc857fde2",{"hashOfOptions":"114"},"0f7f54c7161b8403d3bc42d91f59cd91",{"hashOfOptions":"115"},"8e407b4b1b0c0bd9c862a00243344be3",{"hashOfOptions":"116"},"d4d589c153ac8b5e7bf0fb130a5b5a7d",{"hashOfOptions":"117"},"cecbed1604a530a7cc099fecddddd76c",{"hashOfOptions":"118"},"4fcefde979d34a7339f7a266d3ec931b",{"hashOfOptions":"119"},"52a1d72379b952dd802f47e1865bd0da",{"hashOfOptions":"120"},"1bc3e15a40c117eecc51294886ea9b38",{"hashOfOptions":"121"},"1e8ac0d261e95efb19d290ffcf70ce36","1c1710ce3de3ce02e8054cc3787c8579",{"hashOfOptions":"122"},"5ff899a601102659dcbd2900e415ce8b",{"hashOfOptions":"123"},"dd2007a211e323deabb3f7fa7d16313f",{"hashOfOptions":"124"},"4f49c6df7733f874fbe72b4e20b3092b",{"hashOfOptions":"125"},"863da15dbd856008b7c24077ca746d91","6937fb7370f1e17491df649888d6ecc9",{"hashOfOptions":"126"},"2742538293","3367041120","3963086909","3173464737","3850474653","3346849767","2045418596","3048291867","3525769784","4000169781","471581913","1427279653","2305419495","818733105","2587128410","1262173017","408469422","2836740315","2315460658"]

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,6 @@
import { withSentryConfig } from '@sentry/nextjs'; import { withSentryConfig } from '@sentry/nextjs';
import { withPlausibleProxy } from 'next-plausible'; import { withPlausibleProxy } from 'next-plausible';
import { env } from './src/env.js'; import { env } from './src/env.js';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Forgot Password',
};
};
const ForgotPasswordLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default ForgotPasswordLayout;

View File

@@ -0,0 +1,300 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { PASSWORD_MAX, PASSWORD_MIN } from '@gib/backend/types';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
SubmitButton,
} from '@gib/ui';
const forgotPasswordSchema = z.object({
email: z.email({ message: 'Invalid email.' }),
});
const resetVerificationSchema = z
.object({
code: z.string({ message: 'Invalid code.' }),
newPassword: z
.string()
.min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
})
.max(PASSWORD_MAX, {
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string().min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
}),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const ForgotPassword = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'reset' | 'reset-verification'>('reset');
const [email, setEmail] = useState<string>('');
const [loading, setLoading] = useState(false);
const [code, setCode] = useState<string>('');
const router = useRouter();
const forgotPasswordForm = useForm<z.infer<typeof forgotPasswordSchema>>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: { email },
});
const resetVerificationForm = useForm<
z.infer<typeof resetVerificationSchema>
>({
resolver: zodResolver(resetVerificationSchema),
defaultValues: { code, newPassword: '', confirmPassword: '' },
});
const handleForgotPasswordSubmit = async (
values: z.infer<typeof forgotPasswordSchema>,
) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('reset-verification');
});
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
forgotPasswordForm.reset();
setLoading(false);
}
};
const handleResetVerificationSubmit = async (
values: z.infer<typeof resetVerificationSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('newPassword', values.newPassword);
formData.append('email', email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData);
router.push('/');
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
resetVerificationForm.reset();
setLoading(false);
}
};
return (
<div className="flex flex-col items-center">
<Card className="bg-card/25 min-h-[400px] w-sm p-4 lg:w-md">
<CardHeader className="flex flex-col items-center gap-4">
{flow === 'reset' ? (
<>
<CardTitle className="text-2xl font-bold">
Forgot Password
</CardTitle>
<CardDescription>
Enter your email address and we will send you a link to reset
your password.
</CardDescription>
</>
) : (
<>
<CardTitle className="text-2xl font-bold">
Reset Password
</CardTitle>
<CardDescription>
Enter your code and new password and we will reset your
password.
</CardDescription>
</>
)}
</CardHeader>
<CardContent>
<Card className="bg-card/50">
<CardContent>
{flow === 'reset' ? (
<Form {...forgotPasswordForm}>
<form
onSubmit={forgotPasswordForm.handleSubmit(
handleForgotPasswordSubmit,
)}
className="flex flex-col space-y-4"
>
<FormField
control={forgotPasswordForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xl">Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="you@example.com"
{...field}
/>
</FormControl>
<div className="flex w-full flex-col items-center">
<FormMessage className="w-5/6 text-center" />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText="Sending Email..."
className="mx-auto w-2/3 text-xl font-semibold"
>
Send Email
</SubmitButton>
</form>
</Form>
) : (
<Form {...resetVerificationForm}>
<form
onSubmit={resetVerificationForm.handleSubmit(
handleResetVerificationSubmit,
)}
className="flex flex-col space-y-4"
>
<FormField
control={resetVerificationForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xl">Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your
phone.
</FormDescription>
<div className="flex w-full flex-col items-center">
<FormMessage className="w-5/6 text-center" />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xl">
New Password
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your password"
{...field}
/>
</FormControl>
<div className="flex w-full flex-col items-center">
<FormMessage className="w-5/6 text-center" />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xl">
Confirm Passsword
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm your password"
{...field}
/>
</FormControl>
<div className="flex w-full flex-col items-center">
<FormMessage className="w-5/6 text-center" />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText="Resetting Password..."
className="mx-auto w-2/3 text-xl font-semibold"
>
Reset Password
</SubmitButton>
</form>
</Form>
)}
</CardContent>
</Card>
</CardContent>
</Card>
</div>
);
};
export default ForgotPassword;

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Profile',
};
};
const ProfileLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default ProfileLayout;

View File

@@ -0,0 +1,29 @@
'use server';
import {
AvatarUpload,
ProfileHeader,
ResetPasswordForm,
SignOutForm,
UserInfoForm,
} from '@/components/layout/auth/profile';
import { preloadQuery } from 'convex/nextjs';
import { api } from '@gib/backend/convex/_generated/api.js';
import { Card, Separator } from '@gib/ui';
const Profile = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser);
return (
<Card className="mx-auto mb-8 max-w-xl min-w-xs sm:min-w-md">
<ProfileHeader preloadedUser={preloadedUser} />
<AvatarUpload preloadedUser={preloadedUser} />
<Separator />
<UserInfoForm preloadedUser={preloadedUser} />
<ResetPasswordForm preloadedUser={preloadedUser} />
<Separator />
<SignOutForm />
</Card>
);
};
export default Profile;

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Sign In',
};
};
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default SignInLayout;

View File

@@ -1,12 +1,17 @@
'use client'; 'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import Link from 'next/link';
import { useAuthActions } from '@convex-dev/auth/react';
import { useRouter } from 'next/navigation';
import { ConvexError } from 'convex/values';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { GibsAuthSignInButton } from '@/components/layout/auth/buttons';
import { useAuthActions } from '@convex-dev/auth/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ConvexError } from 'convex/values';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { PASSWORD_MAX, PASSWORD_MIN, PASSWORD_REGEX } from '@gib/backend/types';
import { import {
Card, Card,
CardContent, CardContent,
@@ -20,8 +25,8 @@ import {
Input, Input,
InputOTP, InputOTP,
InputOTPGroup, InputOTPGroup,
InputOTPSlot,
InputOTPSeparator, InputOTPSeparator,
InputOTPSlot,
Separator, Separator,
SubmitButton, SubmitButton,
Tabs, Tabs,
@@ -29,11 +34,6 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from '@gib/ui'; } from '@gib/ui';
import { toast } from 'sonner';
import {
GibsAuthSignInButton,
} from '@/components/layout/auth/buttons';
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@gib/backend/types';
const signInFormSchema = z.object({ const signInFormSchema = z.object({
email: z.email({ email: z.email({
@@ -179,24 +179,24 @@ const SignIn = () => {
if (flow === 'email-verification') { if (flow === 'email-verification') {
return ( return (
<div className='flex flex-col items-center'> <div className="flex flex-col items-center">
<Card className='p-4 bg-card/25 min-h-[720px] w-md'> <Card className="bg-card/25 min-h-[720px] w-md p-4">
<CardContent> <CardContent>
<div className='text-center mb-6'> <div className="mb-6 text-center">
<h2 className='text-2xl font-bold'>Verify Your Email</h2> <h2 className="text-2xl font-bold">Verify Your Email</h2>
<p className='text-muted-foreground'>We sent a code to {email}</p> <p className="text-muted-foreground">We sent a code to {email}</p>
</div> </div>
<Form {...verifyEmailForm}> <Form {...verifyEmailForm}>
<form <form
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)} onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
className='flex flex-col space-y-8' className="flex flex-col space-y-8"
> >
<FormField <FormField
control={verifyEmailForm.control} control={verifyEmailForm.control}
name='code' name="code"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-xl'>Code</FormLabel> <FormLabel className="text-xl">Code</FormLabel>
<FormControl> <FormControl>
<InputOTP <InputOTP
maxLength={6} maxLength={6}
@@ -217,25 +217,25 @@ const SignIn = () => {
<FormDescription> <FormDescription>
Please enter the one-time password sent to your email. Please enter the one-time password sent to your email.
</FormDescription> </FormDescription>
<div className='flex flex-col w-full items-center'> <div className="flex w-full flex-col items-center">
<FormMessage className='w-5/6 text-center' /> <FormMessage className="w-5/6 text-center" />
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
<SubmitButton <SubmitButton
disabled={loading} disabled={loading}
pendingText='Signing Up...' pendingText="Signing Up..."
className='text-xl font-semibold w-2/3 mx-auto' className="mx-auto w-2/3 text-xl font-semibold"
> >
Verify Email Verify Email
</SubmitButton> </SubmitButton>
</form> </form>
</Form> </Form>
<div className='text-center mt-4'> <div className="mt-4 text-center">
<button <button
onClick={() => setFlow('signUp')} onClick={() => setFlow('signUp')}
className='text-sm text-muted-foreground hover:underline' className="text-muted-foreground text-sm hover:underline"
> >
Back to Sign Up Back to Sign Up
</button> </button>
@@ -247,207 +247,204 @@ const SignIn = () => {
} }
return ( return (
<div className='flex flex-col items-center'> <div className="flex flex-col items-center">
<Card className='p-4 bg-card/25 min-h-[720px] w-md'> <Card className="bg-card/25 min-h-[720px] w-md p-4">
<Tabs <Tabs
defaultValue={flow} defaultValue={flow}
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')} onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
className='items-center' className="items-center"
> >
<TabsList className='py-6'> <TabsList className="py-6">
<TabsTrigger <TabsTrigger
value='signIn' value="signIn"
className='p-6 text-2xl font-bold cursor-pointer' className="cursor-pointer p-6 text-2xl font-bold"
> >
Sign In Sign In
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='signUp' value="signUp"
className='p-6 text-2xl font-bold cursor-pointer' className="cursor-pointer p-6 text-2xl font-bold"
> >
Sign Up Sign Up
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='signIn'> <TabsContent value="signIn">
<Card className='min-w-xs sm:min-w-sm bg-card/50'> <Card className="bg-card/50 min-w-xs sm:min-w-sm">
<CardContent> <CardContent>
<Form {...signInForm}> <Form {...signInForm}>
<form <form
onSubmit={signInForm.handleSubmit(handleSignIn)} onSubmit={signInForm.handleSubmit(handleSignIn)}
className='flex flex-col space-y-8' className="flex flex-col space-y-8"
> >
<FormField <FormField
control={signInForm.control} control={signInForm.control}
name='email' name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-xl'>Email</FormLabel> <FormLabel className="text-xl">Email</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='email' type="email"
placeholder='you@example.com' placeholder="you@example.com"
{...field} {...field}
/> />
</FormControl> </FormControl>
<div className='flex flex-col w-full items-center'> <div className="flex w-full flex-col items-center">
<FormMessage className='w-5/6 text-center' /> <FormMessage className="w-5/6 text-center" />
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={signInForm.control} control={signInForm.control}
name='password' name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className='flex justify-between'> <div className="flex justify-between">
<FormLabel className='text-xl'>Password</FormLabel> <FormLabel className="text-xl">Password</FormLabel>
<Link href='/forgot-password'> <Link href="/forgot-password">
Forgot Password? Forgot Password?
</Link> </Link>
</div> </div>
<FormControl> <FormControl>
<Input <Input
type='password' type="password"
placeholder='Your password' placeholder="Your password"
{...field} {...field}
/> />
</FormControl> </FormControl>
<div className='flex flex-col w-full items-center'> <div className="flex w-full flex-col items-center">
<FormMessage className='w-5/6 text-center' /> <FormMessage className="w-5/6 text-center" />
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
<SubmitButton <SubmitButton
disabled={loading} disabled={loading}
pendingText='Signing in...' pendingText="Signing in..."
className='text-xl font-semibold w-2/3 mx-auto' className="mx-auto w-2/3 text-xl font-semibold"
> >
Sign In Sign In
</SubmitButton> </SubmitButton>
</form> </form>
</Form> </Form>
<div className='flex justify-center'> <div className="flex justify-center">
<div <div className="mx-auto my-2.5 flex w-1/4 flex-row items-center justify-center">
className='flex flex-row items-center <Separator className="mr-3 py-0.5" />
my-2.5 mx-auto justify-center w-1/4' <span className="text-lg font-semibold">or</span>
> <Separator className="ml-3 py-0.5" />
<Separator className='py-0.5 mr-3' />
<span className='font-semibold text-lg'>or</span>
<Separator className='py-0.5 ml-3' />
</div> </div>
</div> </div>
<div className='flex justify-center mt-3'> <div className="mt-3 flex justify-center">
<GibsAuthSignInButton /> <GibsAuthSignInButton />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value='signUp'> <TabsContent value="signUp">
<Card className='min-w-xs sm:min-w-sm bg-card/50'> <Card className="bg-card/50 min-w-xs sm:min-w-sm">
<CardContent> <CardContent>
<Form {...signUpForm}> <Form {...signUpForm}>
<form <form
onSubmit={signUpForm.handleSubmit(handleSignUp)} onSubmit={signUpForm.handleSubmit(handleSignUp)}
className='flex flex-col space-y-8' className="flex flex-col space-y-8"
> >
<FormField <FormField
control={signUpForm.control} control={signUpForm.control}
name='name' name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-xl'>Name</FormLabel> <FormLabel className="text-xl">Name</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='text' type="text"
placeholder='Full Name' placeholder="Full Name"
{...field} {...field}
/> />
</FormControl> </FormControl>
<div className='flex flex-col w-full items-center'> <div className="flex w-full flex-col items-center">
<FormMessage className='w-5/6 text-center' /> <FormMessage className="w-5/6 text-center" />
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={signUpForm.control} control={signUpForm.control}
name='email' name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-xl'>Email</FormLabel> <FormLabel className="text-xl">Email</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='email' type="email"
placeholder='you@example.com' placeholder="you@example.com"
{...field} {...field}
/> />
</FormControl> </FormControl>
<div className='flex flex-col w-full items-center'> <div className="flex w-full flex-col items-center">
<FormMessage className='w-5/6 text-center' /> <FormMessage className="w-5/6 text-center" />
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={signUpForm.control} control={signUpForm.control}
name='password' name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-xl'>Password</FormLabel> <FormLabel className="text-xl">Password</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='password' type="password"
placeholder='Your password' placeholder="Your password"
{...field} {...field}
/> />
</FormControl> </FormControl>
<div className='flex flex-col w-full items-center'> <div className="flex w-full flex-col items-center">
<FormMessage className='w-5/6 text-center' /> <FormMessage className="w-5/6 text-center" />
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={signUpForm.control} control={signUpForm.control}
name='confirmPassword' name="confirmPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-xl'> <FormLabel className="text-xl">
Confirm Passsword Confirm Passsword
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type='password' type="password"
placeholder='Confirm your password' placeholder="Confirm your password"
{...field} {...field}
/> />
</FormControl> </FormControl>
<div className='flex flex-col w-full items-center'> <div className="flex w-full flex-col items-center">
<FormMessage className='w-5/6 text-center' /> <FormMessage className="w-5/6 text-center" />
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
<SubmitButton <SubmitButton
disabled={loading} disabled={loading}
pendingText='Signing Up...' pendingText="Signing Up..."
className='text-xl font-semibold w-2/3 mx-auto' className="mx-auto w-2/3 text-xl font-semibold"
> >
Sign Up Sign Up
</SubmitButton> </SubmitButton>
</form> </form>
</Form> </Form>
<div className='flex my-auto justify-center w-2/3'> <div className="my-auto flex w-2/3 justify-center">
<div className='flex flex-row w-1/3 items-center my-2.5'> <div className="my-2.5 flex w-1/3 flex-row items-center">
<Separator className='py-0.5 mr-3' /> <Separator className="mr-3 py-0.5" />
<span className='font-semibold text-lg'>or</span> <span className="text-lg font-semibold">or</span>
<Separator className='py-0.5 ml-3' /> <Separator className="ml-3 py-0.5" />
</div> </div>
</div> </div>
<div className='flex justify-center mt-3'> <div className="mt-3 flex justify-center">
<GibsAuthSignInButton type='signUp' /> <GibsAuthSignInButton type="signUp" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,10 +1,12 @@
'use client'; 'use server';
const Home = () => { const Home = async () => {
return ( return (
<div className="flex min-h-screen items-center justify-center"> <main
className='flex min-h-screen items-center justify-center'
>
Hello! Hello!
</div> </main>
); );
}; };

View File

@@ -1,9 +1,10 @@
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useAuthActions } from '@convex-dev/auth/react'; import { useAuthActions } from '@convex-dev/auth/react';
import type { buttonVariants } from '@gib/ui'; import type { buttonVariants } from '@gib/ui';
import { Button } from '@gib/ui'; import { Button } from '@gib/ui';
import type { ComponentProps } from 'react';
import type { VariantProps } from 'class-variance-authority';
interface Props { interface Props {
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> & buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
@@ -11,7 +12,7 @@ interface Props {
asChild?: boolean; asChild?: boolean;
}; };
type?: 'signIn' | 'signUp'; type?: 'signIn' | 'signUp';
}; }
export const GibsAuthSignInButton = ({ export const GibsAuthSignInButton = ({
buttonProps, buttonProps,
@@ -20,22 +21,16 @@ export const GibsAuthSignInButton = ({
const { signIn } = useAuthActions(); const { signIn } = useAuthActions();
return ( return (
<Button <Button
size='lg' size="lg"
onClick={() => signIn('authentik')} onClick={() => signIn('authentik')}
className='text-lg font-semibold' className="text-lg font-semibold"
{...buttonProps} {...buttonProps}
> >
<div className='flex flex-col my-auto space-x-1'> <div className="my-auto flex flex-col space-x-1">
<p> <p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with</p>
{
type === 'signIn'
? 'Sign In'
: 'Sign Up'
} with
</p>
<Image <Image
src={'/misc/auth/gibs_auth_wide_header.png'} src={'/misc/auth/gibs_auth_wide_header.png'}
className='' className=""
alt="Gib's Auth" alt="Gib's Auth"
width={100} width={100}
height={100} height={100}

View File

@@ -0,0 +1,221 @@
'use client';
import type { Preloaded } from 'convex/react';
import type { ChangeEvent } from 'react';
import { useRef, useState } from 'react';
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
import { api } from '@gib/backend/convex/_generated/api.js';
import {
Avatar,
AvatarImage,
BasedAvatar,
Button,
CardContent,
ImageCrop,
ImageCropApply,
ImageCropContent,
Input,
} from '@gib/ui';
interface AvatarUploadProps {
preloadedUser: Preloaded<typeof api.auth.getUser>;
}
const dataUrlToBlob = async (
dataUrl: string,
): Promise<{ blob: Blob; type: string }> => {
const re = /^data:([^;,]+)[;,]/;
const m = re.exec(dataUrl);
const type = m?.[1] ?? 'image/png';
const res = await fetch(dataUrl);
const blob = await res.blob();
return { blob, type };
};
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const user = usePreloadedQuery(preloadedUser);
const [isUploading, setIsUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [croppedImage, setCroppedImage] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUser = useMutation(api.auth.updateUser);
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image } : 'skip',
);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file.');
if (inputRef.current) inputRef.current.value = '';
return;
}
setSelectedFile(file);
setCroppedImage(null);
};
const handleReset = () => {
setSelectedFile(null);
setCroppedImage(null);
if (inputRef.current) inputRef.current.value = '';
};
const handleSave = async () => {
if (!croppedImage) {
toast.error('Please apply a crop first.');
return;
}
setIsUploading(true);
try {
const { blob, type } = await dataUrlToBlob(croppedImage);
const postUrl = await generateUploadUrl();
const result = await fetch(postUrl, {
method: 'POST',
headers: { 'Content-Type': type },
body: blob,
});
if (!result.ok) {
const msg = await result.text().catch(() => 'Upload failed.');
throw new Error(msg);
}
const uploadResponse = (await result.json()) as {
storageId: Id<'_storage'>;
};
await updateUser({ image: uploadResponse.storageId });
toast.success('Profile picture updated.');
handleReset();
} catch (error) {
console.error('Upload failed:', error);
toast.error('Upload failed. Please try again.');
} finally {
setIsUploading(false);
}
};
return (
<CardContent>
<div className="flex flex-col items-center gap-4">
{/* Current avatar + trigger (hidden when cropping) */}
{!selectedFile && (
<div
className="group relative cursor-pointer"
onClick={() => inputRef.current?.click()}
>
<BasedAvatar
src={currentImageUrl ?? undefined}
fullName={user?.name}
className="h-42 w-42 text-6xl font-semibold"
userIconProps={{ size: 100 }}
/>
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/0 transition-all group-hover:bg-black/50">
<Upload
className="text-white opacity-0 transition-opacity group-hover:opacity-100"
size={24}
/>
</div>
<div className="absolute inset-1 flex items-end justify-end transition-all">
<Pencil
className="text-white opacity-100 transition-opacity group-hover:opacity-0"
size={24}
/>
</div>
</div>
)}
{/* File input (hidden) */}
<Input
ref={inputRef}
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
disabled={isUploading}
/>
{/* Crop UI */}
{selectedFile && !croppedImage && (
<div className="flex flex-col items-center gap-3">
<ImageCrop
aspect={1}
circularCrop
file={selectedFile}
maxImageSize={3 * 1024 * 1024} // 3MB guard
onCrop={setCroppedImage}
>
<ImageCropContent className="max-w-sm" />
<div className="flex items-center gap-2">
<ImageCropApply />
<Button
onClick={handleReset}
size="icon"
type="button"
variant="ghost"
>
<XIcon className="size-4" />
</Button>
</div>
</ImageCrop>
</div>
)}
{/* Cropped preview + actions */}
{croppedImage && (
<div className="flex flex-col items-center gap-3">
<Avatar className="h-42 w-42">
<AvatarImage alt="Cropped preview" src={croppedImage} />
</Avatar>
<div className="flex items-center gap-1">
<Button
onClick={handleSave}
disabled={isUploading}
className="px-4"
>
{isUploading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Saving...
</span>
) : (
'Save Avatar'
)}
</Button>
<Button
onClick={handleReset}
size="icon"
type="button"
className="hover:dark:bg-accent bg-red-400/80 hover:text-red-800/80 dark:bg-red-500/30 hover:dark:text-red-300/60"
variant="secondary"
>
<XIcon className="size-4" />
</Button>
</div>
</div>
)}
{/* Uploading indicator */}
{isUploading && !croppedImage && (
<div className="mt-2 flex items-center text-sm text-gray-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading...
</div>
)}
</div>
</CardContent>
);
};

View File

@@ -0,0 +1,27 @@
'use client';
import type { Preloaded } from 'convex/react';
import { usePreloadedQuery } from 'convex/react';
import type { api } from '@gib/backend/convex/_generated/api.js';
import { CardDescription, CardHeader, CardTitle } from '@gib/ui';
interface ProfileCardProps {
preloadedUser: Preloaded<typeof api.auth.getUser>;
}
const ProfileHeader = ({ preloadedUser }: ProfileCardProps) => {
const user = usePreloadedQuery(preloadedUser);
return (
<CardHeader className="pb-2">
<CardTitle className="text-2xl">
{user?.name ?? user?.email ?? 'Your Profile'}
</CardTitle>
<CardDescription>
Manage your personal information &amp; how it appears to others.
</CardDescription>
</CardHeader>
);
};
export { ProfileHeader };

View File

@@ -0,0 +1,5 @@
export { AvatarUpload } from './avatar-upload';
export { ProfileHeader } from './header';
export { ResetPasswordForm } from './reset-password';
export { SignOutForm } from './sign-out';
export { UserInfoForm } from './user-info';

View File

@@ -0,0 +1,189 @@
'use client';
import type { Preloaded } from 'convex/react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction, usePreloadedQuery } from 'convex/react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { api } from '@gib/backend/convex/_generated/api.js';
import { PASSWORD_MAX, PASSWORD_MIN, PASSWORD_REGEX } from '@gib/backend/types';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
SubmitButton,
} from '@gib/ui';
const formSchema = z
.object({
currentPassword: z.string().regex(PASSWORD_REGEX, {
message: 'Incorrect current password. Does not meet requirements.',
}),
newPassword: z
.string()
.min(PASSWORD_MIN, {
message: 'New password must be at least 8 characters.',
})
.max(PASSWORD_MAX, {
message: 'New password must be less than 100 characters.',
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string(),
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: 'New password must be different from current password.',
path: ['newPassword'],
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});
type ResetFormProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const [loading, setLoading] = useState(false);
const changePassword = useAction(api.auth.updateUserPassword);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
setLoading(true);
try {
const result = await changePassword({
currentPassword: values.currentPassword,
newPassword: values.newPassword,
});
if (result.success) {
form.reset();
toast.success('Password updated successfully.');
}
} catch (error) {
console.error('Error updating password:', error);
toast.error('Error updating password.');
} finally {
setLoading(false);
}
};
// TO DO: Make a function to get provider type from user.
return (
//user.provider !== 'password'
!user?.email
) ? (
<div />
) : (
<>
<Separator />
<CardHeader>
<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(handleSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Enter your current password.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
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>
)}
/>
<div className="flex justify-center">
<SubmitButton
className="w-2/3 text-[1.0rem] lg:w-1/3"
disabled={loading}
pendingText="Updating Password..."
>
Update Password
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};

View File

@@ -0,0 +1,22 @@
'use client';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { SubmitButton } from '@gib/ui';
export const SignOutForm = () => {
const { signOut } = useAuthActions();
const router = useRouter();
return (
<div className="flex justify-center">
<SubmitButton
className="w-5/6 cursor-pointer text-[1.0rem] font-semibold hover:bg-red-700/60 lg:w-2/3 dark:hover:bg-red-300/80"
onClick={() => void signOut().then(() => router.push('/sign-in'))}
>
Sign Out
</SubmitButton>
</div>
);
};

View File

@@ -0,0 +1,154 @@
'use client';
import type { Preloaded } from 'convex/react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, usePreloadedQuery } from 'convex/react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { api } from '@gib/backend/convex/_generated/api.js';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
SubmitButton,
} from '@gib/ui';
const formSchema = z.object({
name: z
.string()
.trim()
.min(5, {
message: 'Full name is required & must be at least 5 characters.',
})
.max(50, {
message: 'Full name must be less than 50 characters.',
}),
email: z.email({
message: 'Please enter a valid email address.',
}),
});
type UserInfoFormProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const [loading, setLoading] = useState(false);
const updateUser = useMutation(api.auth.updateUser);
const initialValues = useMemo<z.infer<typeof formSchema>>(
() => ({
name: user?.name ?? '',
email: user?.email ?? '',
}),
[user?.name, user?.email],
);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: initialValues,
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
const patch: Partial<{
name: string;
email: string;
lunchTime: string;
automaticLunch: boolean;
}> = {};
if (name !== (user.name ?? '')) patch.name = name;
if (email !== (user.email ?? '')) patch.email = email;
if (Object.keys(patch).length === 0) return;
setLoading(true);
try {
await updateUser(patch);
form.reset(patch);
toast.success('Profile updated successfully.');
} catch (error) {
console.error(error);
toast.error('Error updating profile.');
} finally {
setLoading(false);
}
};
return (
<>
<CardHeader>
<CardTitle className="text-2xl">Account Information</CardTitle>
<CardDescription>Update your account information here.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="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}
disabled={
//user.provider !== 'password'
!user?.email
}
/>
</FormControl>
<FormDescription>
Your email address associated with your account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-5 flex justify-center">
<SubmitButton
className="w-2/3 text-[1.0rem] lg:w-1/3"
disabled={loading}
pendingText="Saving..."
>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};

View File

@@ -1,9 +1,9 @@
"use client"; 'use client';
import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs"; import type { ReactNode } from 'react';
import { ConvexReactClient } from "convex/react";
import type { ReactNode } from "react";
import { env } from '@/env'; import { env } from '@/env';
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
import { ConvexReactClient } from 'convex/react';
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL); const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);

View File

@@ -1,9 +1,9 @@
import { banSuspiciousIPs } from '@/lib/middleware/ban-sus-ips';
import { import {
convexAuthNextjsMiddleware, convexAuthNextjsMiddleware,
createRouteMatcher, createRouteMatcher,
nextjsMiddlewareRedirect, nextjsMiddlewareRedirect,
} from '@convex-dev/auth/nextjs/server'; } from '@convex-dev/auth/nextjs/server';
import { banSuspiciousIPs } from '@/lib/middleware/ban-sus-ips';
const isSignInPage = createRouteMatcher(['/sign-in']); const isSignInPage = createRouteMatcher(['/sign-in']);
const isProtectedRoute = createRouteMatcher(['/', '/profile']); const isProtectedRoute = createRouteMatcher(['/', '/profile']);

File diff suppressed because one or more lines are too long

View File

@@ -9,9 +9,11 @@
*/ */
import type * as auth from "../auth.js"; import type * as auth from "../auth.js";
import type * as crons from "../crons.js";
import type * as custom_auth_index from "../custom/auth/index.js"; import type * as custom_auth_index from "../custom/auth/index.js";
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js"; import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js"; import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
import type * as files from "../files.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as utils from "../utils.js"; import type * as utils from "../utils.js";
@@ -31,9 +33,11 @@ import type {
*/ */
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
auth: typeof auth; auth: typeof auth;
crons: typeof crons;
"custom/auth/index": typeof custom_auth_index; "custom/auth/index": typeof custom_auth_index;
"custom/auth/providers/password": typeof custom_auth_providers_password; "custom/auth/providers/password": typeof custom_auth_providers_password;
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend; "custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
files: typeof files;
http: typeof http; http: typeof http;
utils: typeof utils; utils: typeof utils;
}>; }>;

View File

@@ -14,10 +14,7 @@ import { action, mutation, query } from './_generated/server';
import { Password, validatePassword } from './custom/auth'; import { Password, validatePassword } from './custom/auth';
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [ providers: [Authentik({ allowDangerousEmailAccountLinking: true }), Password],
Authentik({ allowDangerousEmailAccountLinking: true }),
Password,
],
}); });
const getUserById = async ( const getUserById = async (
@@ -39,9 +36,8 @@ const isSignedIn = async (ctx: QueryCtx): Promise<Doc<'users'> | null> => {
export const getUser = query({ export const getUser = query({
args: { userId: v.optional(v.id('users')) }, args: { userId: v.optional(v.id('users')) },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await isSignedIn(ctx); const userId = args.userId ?? (await getAuthUserId(ctx));
const userId = args.userId ?? user?._id; if (!userId) return null;
if (!userId) throw new ConvexError('Not authenticated or no ID provided.');
return getUserById(ctx, userId); return getUserById(ctx, userId);
}, },
}); });

View File

@@ -0,0 +1,23 @@
import { cronJobs } from 'convex/server';
import { api } from './_generated/api';
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
const crons = cronJobs();
/* Example cron jobs
crons.cron(
// Run at 7:00 AM CST / 8:00 AM CDT
// Only on weekdays
'Schedule Automatic Lunches',
'0 13 * * 1-5',
api.statuses.automaticLunch,
);
crons.cron(
// Run at 4:00 PM CST / 5:00 PM CDT
// Only on weekdays
'End of shift (weekdays 5pm CT)',
'0 22 * * 1-5',
api.statuses.endOfShiftUpdate,
);
*/
export default crons;

View File

@@ -1,5 +1,6 @@
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password'; import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
import { ConvexError } from 'convex/values'; import { ConvexError } from 'convex/values';
import { UseSendOTP, UseSendOTPPasswordReset } from '..'; import { UseSendOTP, UseSendOTPPasswordReset } from '..';
import { DataModel } from '../../../_generated/dataModel'; import { DataModel } from '../../../_generated/dataModel';

View File

@@ -0,0 +1,18 @@
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import { mutation, query } from './_generated/server';
export const generateUploadUrl = mutation(async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return await ctx.storage.generateUploadUrl();
});
export const getImageUrl = query({
args: { storageId: v.id('_storage') },
handler: async (ctx, { storageId }) => {
const url = await ctx.storage.getUrl(storageId);
return url ?? null;
},
});

View File

@@ -1,6 +1,6 @@
import { authTables } from '@convex-dev/auth/server';
import { defineSchema, defineTable } from 'convex/server'; import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values'; import { v } from 'convex/values';
import { authTables } from '@convex-dev/auth/server';
const applicationTables = { const applicationTables = {
// Users contains name image & email. // Users contains name image & email.
@@ -10,8 +10,7 @@ const applicationTables = {
profiles: defineTable({ profiles: defineTable({
userId: v.id('users'), userId: v.id('users'),
theme_preference: v.optional(v.string()), theme_preference: v.optional(v.string()),
}) }).index('userId', ['userId']),
.index('userId', ['userId'])
}; };
export default defineSchema({ export default defineSchema({
@@ -29,8 +28,8 @@ export default defineSchema({
phoneVerificationTime: v.optional(v.number()), phoneVerificationTime: v.optional(v.number()),
isAnonymous: v.optional(v.boolean()), isAnonymous: v.optional(v.boolean()),
}) })
.index("email", ["email"]) .index('email', ['email'])
.index('name', ['name']) .index('name', ['name'])
.index("phone", ["phone"]), .index('phone', ['phone']),
...applicationTables, ...applicationTables,
}); });

View File

@@ -7,7 +7,9 @@
"author": "Gib", "author": "Gib",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
"./types" : "./types/index.ts" "./convex": "./convex/",
"./convex/*": "./convex/*",
"./types": "./types/index.ts"
}, },
"scripts": { "scripts": {
"dev": "bun with-env convex dev", "dev": "bun with-env convex dev",

View File

@@ -1,17 +1,16 @@
#!/usr/bin/env node #!/usr/bin/env node
import { exportJWK, exportPKCS8, generateKeyPair } from 'jose';
import { exportJWK, exportPKCS8, generateKeyPair } from "jose"; const keys = await generateKeyPair('RS256', {
const keys = await generateKeyPair("RS256", {
extractable: true, extractable: true,
}); });
const privateKey = await exportPKCS8(keys.privateKey); const privateKey = await exportPKCS8(keys.privateKey);
const publicKey = await exportJWK(keys.publicKey); const publicKey = await exportJWK(keys.publicKey);
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] }); const jwks = JSON.stringify({ keys: [{ use: 'sig', ...publicKey }] });
process.stdout.write( process.stdout.write(
`JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, " ")}"`, `JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, ' ')}"`,
); );
process.stdout.write("\n"); process.stdout.write('\n');
process.stdout.write(`JWKS=${jwks}`); process.stdout.write(`JWKS=${jwks}`);
process.stdout.write("\n"); process.stdout.write('\n');

View File

@@ -1,5 +1 @@
export { export { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from './auth';
PASSWORD_MIN,
PASSWORD_MAX,
PASSWORD_REGEX,
} from './auth';

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { cn, AvatarImage } from '@gib/ui';
import * as AvatarPrimitive from '@radix-ui/react-avatar'; import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
import { AvatarImage, cn } from '@gib/ui';
type BasedAvatarProps = ComponentProps<typeof AvatarPrimitive.Root> & { type BasedAvatarProps = ComponentProps<typeof AvatarPrimitive.Root> & {
src?: string | null; src?: string | null;
fullName?: string | null; fullName?: string | null;

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui';
import * as ProgressPrimitive from '@radix-ui/react-progress'; import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@gib/ui';
type BasedProgressProps = React.ComponentProps< type BasedProgressProps = React.ComponentProps<
typeof ProgressPrimitive.Root typeof ProgressPrimitive.Root
> & { > & {

View File

@@ -1,9 +1,10 @@
import type { VariantProps } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority';
import { cn } from '@gib/ui';
const buttonVariants = cva( const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui'; import { cn } from '@gib/ui';
function Card({ className, ...props }: React.ComponentProps<'div'>) { function Card({ className, ...props }: React.ComponentProps<'div'>) {

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react'; import { CheckIcon } from 'lucide-react';
import { cn } from '@gib/ui';
function Checkbox({ function Checkbox({
className, className,
...props ...props

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui';
import { Drawer as DrawerPrimitive } from 'vaul'; import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@gib/ui';
function Drawer({ function Drawer({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) { }: React.ComponentProps<typeof DrawerPrimitive.Root>) {

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@gib/ui';
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {

View File

@@ -2,9 +2,10 @@
import type { VariantProps } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { cn, Label, Separator } from '@gib/ui';
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority';
import { cn, Label, Separator } from '@gib/ui';
export function FieldSet({ export function FieldSet({
className, className,
...props ...props

View File

@@ -1,9 +1,8 @@
'use client'; 'use client';
import type * as LabelPrimitive from '@radix-ui/react-label';
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form'; import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
import * as React from 'react'; import * as React from 'react';
import { cn, Label } from '@gib/ui';
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { import {
Controller, Controller,
@@ -12,6 +11,8 @@ import {
useFormState, useFormState,
} from 'react-hook-form'; } from 'react-hook-form';
import { cn, Label } from '@gib/ui';
const Form = FormProvider; const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui';
import { OTPInput, OTPInputContext } from 'input-otp'; import { OTPInput, OTPInputContext } from 'input-otp';
import { MinusIcon } from 'lucide-react'; import { MinusIcon } from 'lucide-react';
import { cn } from '@gib/ui';
function InputOTP({ function InputOTP({
className, className,
containerClassName, containerClassName,

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui'; import { cn } from '@gib/ui';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) { function Input({ className, type, ...props }: React.ComponentProps<'input'>) {

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui';
import * as LabelPrimitive from '@radix-ui/react-label'; import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@gib/ui';
function Label({ function Label({
className, className,
...props ...props

View File

@@ -1,12 +1,13 @@
import type * as React from 'react'; import type * as React from 'react';
import type { Button } from '@gib/ui';
import { cn, buttonVariants } from '@gib/ui';
import { import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
MoreHorizontalIcon, MoreHorizontalIcon,
} from 'lucide-react'; } from 'lucide-react';
import type { Button } from '@gib/ui';
import { buttonVariants, cn } from '@gib/ui';
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return ( return (
<nav <nav

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import type * as React from 'react'; import type * as React from 'react';
import { cn } from '@gib/ui';
import * as ProgressPrimitive from '@radix-ui/react-progress'; import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@gib/ui';
function Progress({ function Progress({
className, className,
value, value,

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import type * as React from 'react'; import type * as React from 'react';
import { cn } from '@gib/ui';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@gib/ui';
function ScrollArea({ function ScrollArea({
className, className,
children, children,

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@gib/ui';
import * as SeparatorPrimitive from '@radix-ui/react-separator'; import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@gib/ui';
function Separator({ function Separator({
className, className,
orientation = 'horizontal', orientation = 'horizontal',

View File

@@ -17,11 +17,12 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { cn, Button } from '@gib/ui';
import { CropIcon, RotateCcwIcon } from 'lucide-react'; import { CropIcon, RotateCcwIcon } from 'lucide-react';
import { Slot } from 'radix-ui'; import { Slot } from 'radix-ui';
import ReactCrop, { centerCrop, makeAspectCrop } from 'react-image-crop'; import ReactCrop, { centerCrop, makeAspectCrop } from 'react-image-crop';
import { Button, cn } from '@gib/ui';
import 'react-image-crop/dist/ReactCrop.css'; import 'react-image-crop/dist/ReactCrop.css';
const centerAspectCrop = ( const centerAspectCrop = (
@@ -110,7 +111,7 @@ interface ImageCropContextType {
onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void; onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void;
applyCrop: () => Promise<void>; applyCrop: () => Promise<void>;
resetCrop: () => void; resetCrop: () => void;
}; }
const ImageCropContext = createContext<ImageCropContextType | null>(null); const ImageCropContext = createContext<ImageCropContextType | null>(null);

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useTheme } from 'next-themes';
import type { ToasterProps } from 'sonner'; import type { ToasterProps } from 'sonner';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner'; import { Toaster as Sonner } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {

View File

@@ -1,4 +1,5 @@
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { cn } from '@gib/ui'; import { cn } from '@gib/ui';
type Message = { success: string } | { error: string } | { message: string }; type Message = { success: string } | { error: string } | { message: string };
@@ -7,7 +8,7 @@ interface StatusMessageProps {
message: Message; message: Message;
containerProps?: ComponentProps<'div'>; containerProps?: ComponentProps<'div'>;
textProps?: ComponentProps<'div'>; textProps?: ComponentProps<'div'>;
}; }
export const StatusMessage = ({ export const StatusMessage = ({
message, message,

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { cn, Button } from '@gib/ui';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useFormStatus } from 'react-dom'; import { useFormStatus } from 'react-dom';
import { Button, cn } from '@gib/ui';
export type SubmitButtonProps = Omit< export type SubmitButtonProps = Omit<
ComponentProps<typeof Button>, ComponentProps<typeof Button>,
'type' | 'aria-disabled' 'type' | 'aria-disabled'

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import type * as React from 'react'; import type * as React from 'react';
import { cn } from '@gib/ui';
import * as SwitchPrimitive from '@radix-ui/react-switch'; import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@gib/ui';
function Switch({ function Switch({
className, className,
...props ...props

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type * as React from 'react'; import type * as React from 'react';
import { cn } from '@gib/ui'; import { cn } from '@gib/ui';
function Table({ className, ...props }: React.ComponentProps<'table'>) { function Table({ className, ...props }: React.ComponentProps<'table'>) {

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import type * as React from 'react'; import type * as React from 'react';
import { cn } from '@gib/ui';
import * as TabsPrimitive from '@radix-ui/react-tabs'; import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@gib/ui';
function Tabs({ function Tabs({
className, className,
...props ...props

View File

@@ -24,7 +24,7 @@ const ThemeProvider = ({
interface ThemeToggleProps { interface ThemeToggleProps {
size?: number; size?: number;
buttonProps?: Omit<ComponentProps<typeof Button>, 'onClick'>; buttonProps?: Omit<ComponentProps<typeof Button>, 'onClick'>;
}; }
const ThemeToggle = ({ size = 1, buttonProps }: ThemeToggleProps) => { const ThemeToggle = ({ size = 1, buttonProps }: ThemeToggleProps) => {
const { setTheme, resolvedTheme } = useTheme(); const { setTheme, resolvedTheme } = useTheme();

View File

@@ -1 +1 @@
[["1","2","3","4","5"],{"key":"6","value":"7"},{"key":"8","value":"9"},{"key":"10","value":"11"},{"key":"12","value":"13"},{"key":"14","value":"15"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/package.json",{"size":979,"mtime":1766222924000,"hash":"16","data":"17"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/nextjs.ts",{"size":440,"mtime":1768155639188,"hash":"18","data":"19"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/react.ts",{"size":592,"mtime":1768155639239,"hash":"20","data":"21"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/base.ts",{"size":2511,"mtime":1768155639128,"hash":"22","data":"23"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"24","data":"25"},"a5326aca75246da261fd2e354257b45a",{"hashOfOptions":"26"},"25c52c46972131dcc296288599ff108d",{"hashOfOptions":"27"},"2292935ede6baf909f6a0c61486e15da",{"hashOfOptions":"28"},"9e432daa1849e911eb3c69df3d068c6d",{"hashOfOptions":"29"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"30"},"1786406903","2477157100","3372054421","3828872695","459831536"] [["1","2","3","4","5","6"],{"key":"7","value":"8"},{"key":"9","value":"10"},{"key":"11","value":"12"},{"key":"13","value":"14"},{"key":"15","value":"16"},{"key":"17","value":"18"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/package.json",{"size":979,"mtime":1768166330000,"hash":"19","data":"20"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/nextjs.ts",{"size":440,"mtime":1768155639000,"hash":"21","data":"22"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/.cache/.prettiercache",{"size":1132,"mtime":1768171236844,"hash":"23"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/react.ts",{"size":592,"mtime":1768155639000,"hash":"24","data":"25"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/base.ts",{"size":2511,"mtime":1768155639000,"hash":"26","data":"27"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"28","data":"29"},"a5326aca75246da261fd2e354257b45a",{"hashOfOptions":"30"},"25c52c46972131dcc296288599ff108d",{"hashOfOptions":"31"},"26c40ae02d206ffdd279df170ea85cae","2292935ede6baf909f6a0c61486e15da",{"hashOfOptions":"32"},"9e432daa1849e911eb3c69df3d068c6d",{"hashOfOptions":"33"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"34"},"1786406903","2477157100","3372054421","3828872695","459831536"]

View File

@@ -1 +1 @@
[["1","2","3"],{"key":"4","value":"5"},{"key":"6","value":"7"},{"key":"8","value":"9"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"10","data":"11"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/package.json",{"size":607,"mtime":1766222924000,"hash":"12","data":"13"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/index.js",{"size":1170,"mtime":1768155639114,"hash":"14","data":"15"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"16"},"11b634ce56ac720ac9a2860d77fbd2cc",{"hashOfOptions":"17"},"d6f2202018912e47fdd2016f04a4b6eb",{"hashOfOptions":"18"},"1555666866","640383157","507270250"] [["1","2","3","4"],{"key":"5","value":"6"},{"key":"7","value":"8"},{"key":"9","value":"10"},{"key":"11","value":"12"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"13","data":"14"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/package.json",{"size":607,"mtime":1766222924000,"hash":"15","data":"16"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/.cache/.prettiercache",{"size":685,"mtime":1768171236844,"hash":"17"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/index.js",{"size":1170,"mtime":1768155639000,"hash":"18","data":"19"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"20"},"11b634ce56ac720ac9a2860d77fbd2cc",{"hashOfOptions":"21"},"bd15cce9406aeef5526bb5681494acce","d6f2202018912e47fdd2016f04a4b6eb",{"hashOfOptions":"22"},"1555666866","640383157","507270250"]

View File

@@ -1 +1 @@
[["1","2","3","4","5"],{"key":"6","value":"7"},{"key":"8","value":"9"},{"key":"10","value":"11"},{"key":"12","value":"13"},{"key":"14","value":"15"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/package.json",{"size":851,"mtime":1766222924000,"hash":"16","data":"17"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/postcss-config.js",{"size":70,"mtime":1768155639454,"hash":"18","data":"19"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/eslint.config.ts",{"size":143,"mtime":1768155639344,"hash":"20","data":"21"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/theme.css",{"size":6741,"mtime":1766222924000,"hash":"22","data":"23"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"24","data":"25"},"0d22e47f57739db9de04c6f8420d6fb5",{"hashOfOptions":"26"},"9a944fbda06979be39571bd9bd00b0d9",{"hashOfOptions":"27"},"b8fec960cb32340eea62ca1485093e68",{"hashOfOptions":"28"},"5dd421d25d104c47e1ab36df41ed0f7d",{"hashOfOptions":"29"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"30"},"1821576240","2434669165","2442413358","1994519264","1135731447"] [["1","2","3","4","5","6"],{"key":"7","value":"8"},{"key":"9","value":"10"},{"key":"11","value":"12"},{"key":"13","value":"14"},{"key":"15","value":"16"},{"key":"17","value":"18"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/package.json",{"size":851,"mtime":1766222924000,"hash":"19","data":"20"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/postcss-config.js",{"size":70,"mtime":1768155639000,"hash":"21","data":"22"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/eslint.config.ts",{"size":143,"mtime":1768155639000,"hash":"23","data":"24"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/theme.css",{"size":6741,"mtime":1766222924000,"hash":"25","data":"26"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"27","data":"28"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/.cache/.prettiercache",{"size":1160,"mtime":1768171236844,"hash":"29"},"0d22e47f57739db9de04c6f8420d6fb5",{"hashOfOptions":"30"},"9a944fbda06979be39571bd9bd00b0d9",{"hashOfOptions":"31"},"b8fec960cb32340eea62ca1485093e68",{"hashOfOptions":"32"},"5dd421d25d104c47e1ab36df41ed0f7d",{"hashOfOptions":"33"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"34"},"e96b5ac7b7aca2012f7ddbe4daef9e4c","1821576240","2434669165","2442413358","1994519264","1135731447"]