Initial commit for project Spoon!
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const AgentsPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const requests = useQuery(api.agentRequests.listRecent, { limit: 50 }) ?? [];
|
||||
const createRequest = useMutation(api.agentRequests.create);
|
||||
const [spoonId, setSpoonId] = useState('');
|
||||
const [targetBranch, setTargetBranch] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!spoonId) {
|
||||
toast.error('Choose a Spoon first.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createRequest({
|
||||
spoonId: spoonId as Id<'spoons'>,
|
||||
prompt,
|
||||
targetBranch: targetBranch || undefined,
|
||||
});
|
||||
setPrompt('');
|
||||
setTargetBranch('');
|
||||
toast.success('Agent request queued.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue agent request.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Agents</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Queue prompt-driven work for future AI merge request automation.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Request work</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Spoon</Label>
|
||||
<Select value={spoonId} onValueChange={setSpoonId}>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder='Choose a Spoon' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{spoons.map((spoon) => (
|
||||
<SelectItem key={spoon._id} value={spoon._id}>
|
||||
{spoon.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='targetBranch'>Target branch</Label>
|
||||
<Input
|
||||
id='targetBranch'
|
||||
value={targetBranch}
|
||||
placeholder='feature/my-change'
|
||||
onChange={(event) => setTargetBranch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='prompt'>Prompt</Label>
|
||||
<Textarea
|
||||
id='prompt'
|
||||
value={prompt}
|
||||
required
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' disabled={submitting || !spoons.length}>
|
||||
{submitting ? 'Queueing...' : 'Queue request'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent requests</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{requests.length ? (
|
||||
<div className='space-y-3'>
|
||||
{requests.map((request) => (
|
||||
<div key={request._id} className='border-border border p-4'>
|
||||
<p className='font-medium'>{request.prompt}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{request.status.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>
|
||||
Agent requests will appear here after you create a Spoon and
|
||||
queue work.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentsPage;
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { MetricCard } from '@/components/dashboard/metric-card';
|
||||
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { Bot, GitBranch, GitPullRequest, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
const DashboardPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||
const agentRequests =
|
||||
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
|
||||
const activeSpoons = spoons.filter(
|
||||
(spoon) => spoon.status === 'active',
|
||||
).length;
|
||||
const needsReview = syncRuns.filter(
|
||||
(run) => run.status === 'needs_review',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Dashboard</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor managed forks, upstream activity, and queued agent work.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons/new'>Create Spoon</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<MetricCard
|
||||
label='Total Spoons'
|
||||
value={spoons.length}
|
||||
note='Managed forks'
|
||||
icon={GitBranch}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Active Spoons'
|
||||
value={activeSpoons}
|
||||
note='Ready for checks'
|
||||
icon={GitPullRequest}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Needs review'
|
||||
value={needsReview}
|
||||
note='Upstream updates'
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Agent requests'
|
||||
value={agentRequests.length}
|
||||
note='Queued and recent'
|
||||
icon={Bot}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
|
||||
{spoons.length ? (
|
||||
spoons
|
||||
.slice(0, 3)
|
||||
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-6'>
|
||||
<p className='font-medium'>No Spoons yet</p>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
Create a manual Spoon record to start shaping your fork
|
||||
maintenance dashboard.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Recent activity</h2>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>Upstream checks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{syncRuns.length ? (
|
||||
<div className='space-y-3'>
|
||||
{syncRuns.map((run) => (
|
||||
<div
|
||||
key={run._id}
|
||||
className='border-border border p-3 text-sm'
|
||||
>
|
||||
<p className='font-medium'>
|
||||
{run.kind.replaceAll('_', ' ')}
|
||||
</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{run.status.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Scheduled upstream checks will appear here once provider
|
||||
automation is connected.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AppShell } from '@/components/app-shell/app-shell';
|
||||
|
||||
const Layout = ({ children }: { children: ReactNode }) => (
|
||||
<AppShell>{children}</AppShell>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NewSpoonForm } from '@/components/spoons/new-spoon-form';
|
||||
|
||||
const NewSpoonPage = () => (
|
||||
<main className='space-y-6'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>New Spoon</h1>
|
||||
<p className='text-muted-foreground mt-2 max-w-2xl'>
|
||||
Create a provider-neutral managed fork record. This does not call a Git
|
||||
provider yet; it prepares the dashboard surface for future automation.
|
||||
</p>
|
||||
</div>
|
||||
<NewSpoonForm />
|
||||
</main>
|
||||
);
|
||||
|
||||
export default NewSpoonPage;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
const SpoonsPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>My Spoons</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Managed forks you want to keep close to their upstream projects.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons/new'>New Spoon</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{spoons.length ? (
|
||||
<div className='grid gap-4 xl:grid-cols-2'>
|
||||
{spoons.map((spoon) => (
|
||||
<SpoonCard key={spoon._id} spoon={spoon} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-8'>
|
||||
<p className='text-lg font-medium'>No managed forks yet</p>
|
||||
<p className='text-muted-foreground mt-2 max-w-xl'>
|
||||
Add your first Spoon manually. Provider-backed forking can build
|
||||
on this same record later.
|
||||
</p>
|
||||
<Button className='mt-5' asChild>
|
||||
<Link href='/spoons/new'>Create Spoon</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoonsPage;
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const UpdatesPage = () => {
|
||||
const runs = useQuery(api.syncRuns.listRecent, { limit: 50 }) ?? [];
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Updates</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Upstream checks, merge attempts, and AI reviews will appear here.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-3 md:flex-row'>
|
||||
<Select defaultValue='all'>
|
||||
<SelectTrigger className='w-full md:w-48'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All statuses</SelectItem>
|
||||
<SelectItem value='needs_review'>Needs review</SelectItem>
|
||||
<SelectItem value='conflict'>Conflict</SelectItem>
|
||||
<SelectItem value='clean'>Clean</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select defaultValue='all'>
|
||||
<SelectTrigger className='w-full md:w-64'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All Spoons</SelectItem>
|
||||
{spoons.map((spoon) => (
|
||||
<SelectItem key={spoon._id} value={spoon._id}>
|
||||
{spoon.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent sync runs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{runs.length ? (
|
||||
<div className='space-y-3'>
|
||||
{runs.map((run) => (
|
||||
<div key={run._id} className='border-border border p-4'>
|
||||
<p className='font-medium'>{run.kind.replaceAll('_', ' ')}</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{run.status.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>
|
||||
Scheduled upstream checks will appear here once provider
|
||||
connections and workers are added.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatesPage;
|
||||
@@ -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;
|
||||
@@ -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 '@spoon/backend/types';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
SubmitButton,
|
||||
} from '@spoon/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;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { isAuthenticatedNextjs } from '@convex-dev/auth/nextjs/server';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: 'Profile',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ProfileLayout = async ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) => {
|
||||
if (!(await isAuthenticatedNextjs())) redirect('/sign-in');
|
||||
return <>{children}</>;
|
||||
};
|
||||
export default ProfileLayout;
|
||||
@@ -0,0 +1,51 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
AvatarUpload,
|
||||
ProfileHeader,
|
||||
ResetPasswordForm,
|
||||
SignOutForm,
|
||||
UserInfoForm,
|
||||
} from '@/components/layout/auth/profile';
|
||||
import { preloadQuery } from 'convex/nextjs';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Card, Separator } from '@spoon/ui';
|
||||
|
||||
const Profile = async () => {
|
||||
const preloadedUser = await preloadQuery(api.auth.getUser, {});
|
||||
const preloadedUserProvider = await preloadQuery(
|
||||
api.auth.getUserProvider,
|
||||
{},
|
||||
);
|
||||
return (
|
||||
<main className='container mx-auto px-4 py-12 md:py-16'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
{/* Page Header */}
|
||||
<div className='mb-8 text-center'>
|
||||
<h1 className='mb-2 text-3xl font-bold tracking-tight sm:text-4xl'>
|
||||
Your Profile
|
||||
</h1>
|
||||
<p className='text-muted-foreground'>
|
||||
Manage your personal information and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Card */}
|
||||
<Card className='border-border/40'>
|
||||
<ProfileHeader />
|
||||
<AvatarUpload preloadedUser={preloadedUser} />
|
||||
<Separator className='my-6' />
|
||||
<UserInfoForm
|
||||
preloadedUser={preloadedUser}
|
||||
preloadedProvider={preloadedUserProvider}
|
||||
/>
|
||||
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
|
||||
<Separator className='my-6' />
|
||||
<SignOutForm />
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
export default Profile;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,464 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AuthentikSignInButton } 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 '@spoon/backend/types';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const signInFormSchema = z.object({
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
password: z.string().regex(PASSWORD_REGEX, {
|
||||
message: 'Incorrect password. Does not meet requirements.',
|
||||
}),
|
||||
});
|
||||
|
||||
const signUpFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(2, {
|
||||
message: 'Name must be at least 2 characters.',
|
||||
}),
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
password: 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.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match!',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
const verifyEmailFormSchema = z.object({
|
||||
code: z.string({ message: 'Invalid code.' }),
|
||||
});
|
||||
|
||||
const SignIn = () => {
|
||||
const { signIn } = useAuthActions();
|
||||
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
|
||||
'signIn',
|
||||
);
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
|
||||
resolver: zodResolver(signInFormSchema),
|
||||
defaultValues: { email: '', password: '' },
|
||||
});
|
||||
|
||||
const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({
|
||||
resolver: zodResolver(signUpFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
|
||||
resolver: zodResolver(verifyEmailFormSchema),
|
||||
defaultValues: { code },
|
||||
});
|
||||
|
||||
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
formData.append('password', values.password);
|
||||
formData.append('flow', flow);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/dashboard'));
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
toast.error('Error signing in.');
|
||||
} finally {
|
||||
signInForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignUp = async (values: z.infer<typeof signUpFormSchema>) => {
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
formData.append('password', values.password);
|
||||
formData.append('flow', flow);
|
||||
formData.append('name', values.name);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (values.confirmPassword !== values.password)
|
||||
throw new ConvexError('Passwords do not match.');
|
||||
await signIn('password', formData).then(() => {
|
||||
setEmail(values.email);
|
||||
setFlow('email-verification');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
toast.error('Error signing up.');
|
||||
} finally {
|
||||
signUpForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async (
|
||||
_values: z.infer<typeof verifyEmailFormSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
formData.append('flow', flow);
|
||||
formData.append('email', email);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/dashboard'));
|
||||
} catch (error) {
|
||||
console.error('Error verifying email:', error);
|
||||
toast.error('Error verifying email.');
|
||||
} finally {
|
||||
verifyEmailForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (flow === 'email-verification') {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='bg-card/25 min-h-[720px] w-md p-4'>
|
||||
<CardContent>
|
||||
<div className='mb-6 text-center'>
|
||||
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
||||
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
||||
</div>
|
||||
<Form {...verifyEmailForm}>
|
||||
<form
|
||||
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={verifyEmailForm.control}
|
||||
name='code'
|
||||
render={({ field: _field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
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 email.
|
||||
</FormDescription>
|
||||
<div className='flex w-full flex-col items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='mx-auto w-2/3 text-xl font-semibold'
|
||||
>
|
||||
Verify Email
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='mt-4 text-center'>
|
||||
<button
|
||||
onClick={() => setFlow('signUp')}
|
||||
className='text-muted-foreground text-sm hover:underline'
|
||||
>
|
||||
Back to Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='bg-card/25 min-h-[720px] w-md p-4'>
|
||||
<Tabs
|
||||
defaultValue={flow}
|
||||
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
|
||||
className='flex-col items-center'
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value='signIn'
|
||||
className='cursor-pointer px-6 py-2 text-2xl font-bold'
|
||||
>
|
||||
Sign In
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='signUp'
|
||||
className='cursor-pointer px-6 py-2 text-2xl font-bold'
|
||||
>
|
||||
Sign Up
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value='signIn'
|
||||
className='flex min-h-[560px] flex-row items-center'
|
||||
>
|
||||
<Card className='bg-card/50 min-w-xs py-10 sm:min-w-sm'>
|
||||
<CardContent>
|
||||
<Form {...signInForm}>
|
||||
<form
|
||||
onSubmit={signInForm.handleSubmit(handleSignIn)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={signInForm.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>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signInForm.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex justify-between'>
|
||||
<FormLabel className='text-xl'>Password</FormLabel>
|
||||
<Link href='/forgot-password'>
|
||||
Forgot Password?
|
||||
</Link>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing in...'
|
||||
className='mx-auto w-2/3 text-xl font-semibold'
|
||||
>
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex justify-center'>
|
||||
<div className='mx-auto my-2.5 flex w-1/4 flex-row items-center justify-center'>
|
||||
<Separator className='mr-3 py-0.5' />
|
||||
<span className='text-lg font-semibold'>or</span>
|
||||
<Separator className='ml-3 py-0.5' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<AuthentikSignInButton />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='signUp'>
|
||||
<Card className='bg-card/50 min-w-xs sm:min-w-sm'>
|
||||
<CardContent>
|
||||
<Form {...signUpForm}>
|
||||
<form
|
||||
onSubmit={signUpForm.handleSubmit(handleSignUp)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='Full Name'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex w-full flex-col items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.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>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>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={signUpForm.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='Signing Up...'
|
||||
className='mx-auto w-2/3 text-xl font-semibold'
|
||||
>
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='my-auto flex w-2/3 justify-center'>
|
||||
<div className='my-2.5 flex w-1/3 flex-row items-center'>
|
||||
<Separator className='mr-3 py-0.5' />
|
||||
<span className='text-lg font-semibold'>or</span>
|
||||
<Separator className='ml-3 py-0.5' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<AuthentikSignInButton type='signUp' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SignIn;
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import NextError from 'next/error';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
|
||||
import '@/app/styles.css';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Footer from '@/components/layout/footer';
|
||||
import Header from '@/components/layout/header';
|
||||
import { ConvexClientProvider } from '@/components/providers';
|
||||
import { env } from '@/env';
|
||||
import { generateMetadata } from '@/lib/metadata';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import PlausibleProvider from 'next-plausible';
|
||||
|
||||
import { Button, ThemeProvider, Toaster } from '@spoon/ui';
|
||||
|
||||
export const metadata: Metadata = generateMetadata();
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: 'white' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: 'black' },
|
||||
],
|
||||
};
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-sans',
|
||||
});
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
|
||||
interface GlobalErrorProps {
|
||||
error: Error & { digest?: string };
|
||||
reset?: () => void;
|
||||
}
|
||||
|
||||
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
return (
|
||||
<PlausibleProvider
|
||||
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
|
||||
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
|
||||
>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
<main className='flex min-h-screen flex-col items-center'>
|
||||
<Header />
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
)}
|
||||
<Toaster />
|
||||
<Footer />
|
||||
</main>
|
||||
<main className='flex min-h-[90vh] flex-col items-center'>
|
||||
<Toaster />
|
||||
</main>
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</PlausibleProvider>
|
||||
);
|
||||
};
|
||||
export default GlobalError;
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { env } from '@/env';
|
||||
|
||||
import '@/app/styles.css';
|
||||
|
||||
import Footer from '@/components/layout/footer';
|
||||
import Header from '@/components/layout/header';
|
||||
import { ConvexClientProvider } from '@/components/providers';
|
||||
import { generateMetadata } from '@/lib/metadata';
|
||||
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
|
||||
import PlausibleProvider from 'next-plausible';
|
||||
|
||||
import { ThemeProvider, Toaster } from '@spoon/ui';
|
||||
|
||||
export const metadata: Metadata = generateMetadata();
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: 'white' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: 'black' },
|
||||
],
|
||||
};
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-sans',
|
||||
});
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
|
||||
const RootLayout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return (
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<PlausibleProvider
|
||||
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
|
||||
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
|
||||
>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
<div className='flex min-h-screen flex-col'>
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
<Toaster />
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</PlausibleProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
);
|
||||
};
|
||||
export default RootLayout;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Agents, CTA, Features, Hero, Workflow } from '@/components/landing';
|
||||
|
||||
const Home = () => (
|
||||
<main className='flex min-h-screen flex-col'>
|
||||
<Hero />
|
||||
<Workflow />
|
||||
<Features />
|
||||
<Agents />
|
||||
<CTA />
|
||||
</main>
|
||||
);
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,33 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import '@spoon/tailwind-config/theme';
|
||||
|
||||
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@custom-variant light (&:where(.light, .light *));
|
||||
@custom-variant auto (&:where(.auto, .auto *));
|
||||
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
@media (width >= --theme(--breakpoint-sm)) {
|
||||
max-width: none;
|
||||
}
|
||||
@media (width >= 1400px) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
@apply bg-background;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user