Initial commit for project Spoon!
Build and Push Next App / quality (push) Failing after 45s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown
2026-06-21 17:52:02 -05:00
commit cf7ff2ee4e
268 changed files with 32981 additions and 0 deletions
+141
View File
@@ -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;
+121
View File
@@ -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;
+8
View File
@@ -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;
+49
View File
@@ -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;
+83
View File
@@ -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;
+51
View File
@@ -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;
+464
View File
@@ -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;
+82
View File
@@ -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;
+70
View File
@@ -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;
+13
View File
@@ -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;
+33
View File
@@ -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);
}
}
@@ -0,0 +1,51 @@
'use client';
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Bot, GitBranch, LayoutDashboard, RefreshCw, User } from 'lucide-react';
import { cn } from '@spoon/ui';
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/spoons', label: 'My Spoons', icon: GitBranch },
{ href: '/updates', label: 'Updates', icon: RefreshCw },
{ href: '/agents', label: 'Agents', icon: Bot },
{ href: '/profile', label: 'Profile', icon: User },
];
export const AppShell = ({ children }: { children: ReactNode }) => {
const pathname = usePathname();
return (
<div className='bg-muted/20 flex-1 border-t'>
<div className='container mx-auto grid gap-6 px-4 py-6 lg:grid-cols-[14rem_1fr]'>
<aside className='lg:sticky lg:top-20 lg:self-start'>
<nav className='border-border bg-card flex gap-1 overflow-x-auto border p-2 lg:flex-col'>
{navItems.map(({ href, label, icon: Icon }) => {
const active =
pathname === href ||
(href !== '/dashboard' && pathname.startsWith(href));
return (
<Link
key={href}
href={href}
className={cn(
'hover:bg-muted flex min-w-fit items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
active
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className='size-4' />
{label}
</Link>
);
})}
</nav>
</aside>
<div className='min-w-0'>{children}</div>
</div>
</div>
);
};
+22
View File
@@ -0,0 +1,22 @@
import Link from 'next/link';
import { Utensils } from 'lucide-react';
import { cn } from '@spoon/ui';
export const LogoMark = ({ className }: { className?: string }) => (
<span
className={cn(
'bg-primary text-primary-foreground inline-flex size-9 items-center justify-center rounded-md',
className,
)}
>
<Utensils className='size-5' />
</span>
);
export const SpoonLogo = ({ className }: { className?: string }) => (
<Link href='/' className={cn('flex items-center gap-2', className)}>
<LogoMark />
<span className='text-xl font-semibold tracking-normal'>Spoon</span>
</Link>
);
@@ -0,0 +1,26 @@
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent } from '@spoon/ui';
export const MetricCard = ({
label,
value,
note,
icon: Icon,
}: {
label: string;
value: number | string;
note: string;
icon: LucideIcon;
}) => (
<Card className='shadow-none'>
<CardContent className='p-5'>
<div className='flex items-center justify-between gap-3'>
<p className='text-muted-foreground text-sm'>{label}</p>
<Icon className='text-primary size-4' />
</div>
<p className='mt-3 text-3xl font-semibold'>{value}</p>
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
</CardContent>
</Card>
);
+27
View File
@@ -0,0 +1,27 @@
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import { Button } from '@spoon/ui';
export const CTA = () => (
<section className='container mx-auto px-4 py-20'>
<div className='border-border bg-card flex flex-col items-start justify-between gap-6 border p-8 md:flex-row md:items-center'>
<div>
<h2 className='text-2xl font-semibold tracking-normal'>
Start your first Spoon
</h2>
<p className='text-muted-foreground mt-2 max-w-2xl'>
Create a manual managed fork record today. Provider connections,
scheduled checks, and AI merge request automation can build on the
same foundation.
</p>
</div>
<Button asChild>
<Link href='/spoons/new'>
New Spoon
<ArrowRight className='size-4' />
</Link>
</Button>
</div>
</section>
);
@@ -0,0 +1,142 @@
import {
Bot,
GitMerge,
History,
SearchCheck,
ShieldCheck,
TriangleAlert,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const maintenance = [
{
title: 'Upstream security fixes',
description:
'Track the changes that land upstream so important fixes do not disappear into fork drift.',
icon: ShieldCheck,
},
{
title: 'Conflict detection',
description:
'Make update risk visible before a merge request reaches the fork you actually maintain.',
icon: TriangleAlert,
},
{
title: 'AI-reviewed changes',
description:
'Prepare for agent-assisted analysis that explains whether upstream changes affect your custom work.',
icon: SearchCheck,
},
{
title: 'Merge request history',
description:
'Keep a durable timeline of upstream checks, review outcomes, and merge request decisions.',
icon: History,
},
];
export const Workflow = () => {
const steps = [
'Choose upstream',
'Create a Spoon',
'Customize your fork',
'Track upstream',
'Review and merge updates',
];
return (
<section id='workflow' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto px-4 py-16'>
<div className='mb-10 max-w-2xl'>
<h2 className='text-3xl font-semibold tracking-normal'>
A fork workflow that keeps moving
</h2>
<p className='text-muted-foreground mt-3'>
Spoon starts with a provider-neutral model: upstream project,
managed fork, update checks, and reviewable merge requests.
</p>
</div>
<div className='grid gap-3 md:grid-cols-5'>
{steps.map((step, index) => (
<div key={step} className='border-border bg-card border p-4'>
<p className='text-primary text-sm font-semibold'>
{String(index + 1).padStart(2, '0')}
</p>
<p className='mt-4 text-sm font-medium'>{step}</p>
</div>
))}
</div>
</div>
</section>
);
};
export const Features = () => (
<section id='maintenance' className='container mx-auto px-4 py-20'>
<div className='mb-10 max-w-2xl'>
<h2 className='text-3xl font-semibold tracking-normal'>
Maintenance is the product
</h2>
<p className='text-muted-foreground mt-3'>
The first version establishes the dashboard surfaces and records that
future Git provider integrations and AI review jobs will use.
</p>
</div>
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
{maintenance.map(({ title, description, icon: Icon }) => (
<Card key={title} className='border-border/70 shadow-none'>
<CardHeader>
<Icon className='text-primary size-5' />
<CardTitle className='text-base'>{title}</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground text-sm leading-6'>
{description}
</p>
</CardContent>
</Card>
))}
</div>
</section>
);
export const Agents = () => (
<section id='agents' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto grid gap-8 px-4 py-20 lg:grid-cols-2'>
<div>
<div className='bg-primary/10 text-primary mb-4 flex size-10 items-center justify-center rounded-md'>
<Bot className='size-5' />
</div>
<h2 className='text-3xl font-semibold tracking-normal'>
Agent requests belong next to fork maintenance
</h2>
<p className='text-muted-foreground mt-4 leading-7'>
Spoon is being shaped so a user can ask an agent to implement a
change, open a merge request against the managed fork, and still keep
upstream updates in view. This pass stores those requests without
running automation yet.
</p>
</div>
<div className='border-border bg-card border p-5'>
<div className='mb-4 flex items-center gap-3'>
<GitMerge className='text-primary size-5' />
<p className='font-medium'>Queued agent request</p>
</div>
<p className='text-muted-foreground text-sm leading-6'>
Add a project-specific onboarding flow, open a merge request, and
flag any upstream files this may affect.
</p>
<div className='mt-5 grid gap-2 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Target</span>
<span>feature/onboarding</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Status</span>
<span>Queued</span>
</div>
</div>
</div>
</div>
</section>
);
+125
View File
@@ -0,0 +1,125 @@
'use client';
import Link from 'next/link';
import { useConvexAuth } from 'convex/react';
import {
ArrowRight,
Bot,
CheckCircle2,
GitBranch,
GitPullRequest,
ShieldCheck,
} from 'lucide-react';
import { Badge, Button } from '@spoon/ui';
const previewRows = [
{
name: 'editor-spoon',
upstream: 'upstream/main',
status: 'Clean update',
icon: CheckCircle2,
tone: 'text-emerald-600',
},
{
name: 'billing-fork',
upstream: 'release/2026.06',
status: 'AI review queued',
icon: Bot,
tone: 'text-teal-600',
},
{
name: 'docs-platform',
upstream: 'main',
status: 'Needs review',
icon: GitPullRequest,
tone: 'text-amber-600',
},
];
export const Hero = () => {
const { isAuthenticated } = useConvexAuth();
return (
<section className='container mx-auto px-4 py-16 md:py-24'>
<div className='grid items-center gap-10 lg:grid-cols-[0.92fr_1.08fr]'>
<div className='max-w-3xl'>
<Badge variant='outline' className='mb-5 gap-2'>
<ShieldCheck className='size-3.5 text-emerald-600' />
Self-hostable fork maintenance
</Badge>
<h1 className='max-w-4xl text-4xl font-semibold tracking-normal text-balance sm:text-5xl md:text-6xl'>
Fork freely. Stay close to upstream.
</h1>
<p className='text-muted-foreground mt-6 max-w-2xl text-lg leading-8'>
Spoon helps you customize upstream projects without inheriting the
full maintenance burden. Track drift, review update risk, and keep
managed forks ready for merge requests.
</p>
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
<Button size='lg' asChild>
<Link href={isAuthenticated ? '/dashboard' : '/sign-in'}>
{isAuthenticated ? 'Open dashboard' : 'Start with Spoon'}
<ArrowRight className='size-4' />
</Link>
</Button>
<Button size='lg' variant='outline' asChild>
<Link href='#workflow'>See how it works</Link>
</Button>
</div>
</div>
<div className='border-border bg-card border shadow-sm'>
<div className='border-border flex items-center justify-between border-b px-5 py-4'>
<div>
<p className='text-sm font-medium'>Spoon dashboard</p>
<p className='text-muted-foreground text-xs'>
Upstream status across managed forks
</p>
</div>
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
3 active Spoons
</Badge>
</div>
<div className='grid gap-4 p-5 md:grid-cols-3'>
{[
['Updates', '4', '2 clean'],
['Needs review', '1', 'conflict risk'],
['Agents', '2', 'queued'],
].map(([label, value, note]) => (
<div
key={label}
className='border-border bg-background border p-4'
>
<p className='text-muted-foreground text-xs'>{label}</p>
<p className='mt-2 text-2xl font-semibold'>{value}</p>
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
</div>
))}
</div>
<div className='space-y-3 px-5 pb-5'>
{previewRows.map(({ name, upstream, status, icon: Icon, tone }) => (
<div
key={name}
className='border-border bg-background flex items-center justify-between gap-4 border p-4'
>
<div className='flex items-center gap-3'>
<span className='bg-muted flex size-9 items-center justify-center rounded-md'>
<GitBranch className='size-4' />
</span>
<div>
<p className='text-sm font-medium'>{name}</p>
<p className='text-muted-foreground text-xs'>{upstream}</p>
</div>
</div>
<span className='flex items-center gap-2 text-sm'>
<Icon className={`size-4 ${tone}`} />
{status}
</span>
</div>
))}
</div>
</div>
</div>
</section>
);
};
@@ -0,0 +1,3 @@
export { Hero } from './hero';
export { Agents, Features, Workflow } from './features';
export { CTA } from './cta';
@@ -0,0 +1,75 @@
const techStack = [
{
category: 'Frontend',
technologies: [
{ name: 'Next.js 16', description: 'React framework with App Router' },
{ name: 'Expo 54', description: 'React Native framework' },
{ name: 'React 19', description: 'Latest React with Server Components' },
{
name: 'Tailwind CSS v4',
description: 'Utility-first CSS framework',
},
{ name: 'shadcn/ui', description: 'Beautiful component library' },
],
},
{
category: 'Backend',
technologies: [
{ name: 'Convex', description: 'Self-hosted reactive backend' },
{
name: '@convex-dev/auth',
description: 'Multi-provider authentication',
},
{ name: 'UseSend', description: 'Self-hosted email service' },
{ name: 'File Storage', description: 'Built-in file uploads' },
],
},
{
category: 'Developer Tools',
technologies: [
{ name: 'Turborepo', description: 'High-performance build system' },
{ name: 'TypeScript', description: 'Type-safe development' },
{ name: 'Bun', description: 'Fast package manager and runtime' },
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
{ name: 'Docker', description: 'Containerized deployment' },
],
},
];
export const TechStack = () => (
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Modern Tech Stack
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
Built with the latest and greatest tools for maximum productivity
and performance.
</p>
</div>
<div className='grid gap-12 md:grid-cols-3'>
{techStack.map((stack) => (
<div key={stack.category}>
<h3 className='mb-6 text-xl font-semibold'>{stack.category}</h3>
<ul className='space-y-4'>
{stack.technologies.map((tech) => (
<li key={tech.name}>
<div className='text-foreground font-medium'>
{tech.name}
</div>
<div className='text-muted-foreground text-sm'>
{tech.description}
</div>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
</section>
);
@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'react';
import { useAuthActions } from '@convex-dev/auth/react';
import { KeyRound } from 'lucide-react';
import type { buttonVariants } from '@spoon/ui';
import { Button } from '@spoon/ui';
interface Props {
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
type?: 'signIn' | 'signUp';
}
export const AuthentikSignInButton = ({
buttonProps,
type = 'signIn',
}: Props) => {
const { signIn } = useAuthActions();
return (
<Button
size='lg'
onClick={() => signIn('authentik')}
className='text-lg font-semibold'
{...buttonProps}
>
<div className='my-auto flex flex-row items-center gap-2'>
<KeyRound className='size-5' />
<p>{type === 'signIn' ? 'Continue' : 'Sign up'} with Authentik</p>
</div>
</Button>
);
};
@@ -0,0 +1 @@
export { AuthentikSignInButton } from './gibs-auth';
@@ -0,0 +1,218 @@
'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 '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Avatar,
AvatarImage,
BasedAvatar,
Button,
CardContent,
ImageCrop,
ImageCropApply,
ImageCropContent,
Input,
} from '@spoon/ui';
type 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 as Id<'_storage'> } : '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'>
<Button size='icon' variant='outline'>
<ImageCropApply className='h-full w-full scale-150' />
</Button>
<Button onClick={handleReset} size='icon' variant='destructive'>
<XIcon className='scale-150' />
</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}
variant='secondary'
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'
variant='destructive'
>
<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>
);
};
@@ -0,0 +1,16 @@
'use client';
import { CardDescription, CardHeader, CardTitle } from '@spoon/ui';
const ProfileHeader = () => {
return (
<CardHeader>
<CardTitle className='text-xl'>Account Settings</CardTitle>
<CardDescription>
Update your profile information and manage your account preferences
</CardDescription>
</CardHeader>
);
};
export { ProfileHeader };
@@ -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';
@@ -0,0 +1,195 @@
'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 '@spoon/backend/convex/_generated/api.js';
import {
PASSWORD_MAX,
PASSWORD_MIN,
PASSWORD_REGEX,
} from '@spoon/backend/types';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
SubmitButton,
} from '@spoon/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'],
});
interface ResetFormProps {
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
}
export const ResetPasswordForm = ({ preloadedProvider }: ResetFormProps) => {
const userProvider = usePreloadedQuery(preloadedProvider);
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);
}
};
// Only show password reset for email/password auth users
if (userProvider !== 'email') {
return null;
}
return (
<>
<Separator />
<CardHeader>
<CardTitle>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-4'
>
<FormField
control={form.control}
name='currentPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
placeholder='Enter current password'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='newPassword'
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
placeholder='Enter new password'
/>
</FormControl>
<FormDescription>
Must be at least 8 characters with uppercase, lowercase,
number, and symbol
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
placeholder='Confirm new password'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end pt-2'>
<SubmitButton disabled={loading} pendingText='Updating...'>
Update Password
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};
@@ -0,0 +1,53 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { LogOut } from 'lucide-react';
import {
Button,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@spoon/ui';
export const SignOutForm = () => {
const { signOut } = useAuthActions();
const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false);
const handleSignOut = async () => {
setIsSigningOut(true);
try {
await signOut();
router.push('/');
} catch (error) {
console.error('Sign out error:', error);
setIsSigningOut(false);
}
};
return (
<>
<CardHeader>
<CardTitle>Sign Out</CardTitle>
<CardDescription>
End your current session and return to the home page
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant='destructive'
className='w-full'
onClick={handleSignOut}
disabled={isSigningOut}
>
<LogOut className='mr-2 h-4 w-4' />
{isSigningOut ? 'Signing Out...' : 'Sign Out'}
</Button>
</CardContent>
</>
);
};
@@ -0,0 +1,171 @@
'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 '@spoon/backend/convex/_generated/api.js';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
SubmitButton,
} from '@spoon/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.',
}),
});
interface UserInfoFormProps {
preloadedUser: Preloaded<typeof api.auth.getUser>;
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
}
export const UserInfoForm = ({
preloadedUser,
preloadedProvider,
}: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const userProvider = usePreloadedQuery(preloadedProvider);
const providerMap: Record<string, string> = {
unknown: 'Provider',
authentik: 'Authentik',
};
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>) => {
if (!user) {
toast.error('User not found.');
return;
}
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
const patch: Partial<{
name: string;
email: string;
}> = {};
if (name !== (user.name ?? '')) patch.name = name;
if (email !== (user.email ?? '')) patch.email = email;
if (Object.keys(patch).length === 0) {
toast.info('No changes to save.');
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>Account Information</CardTitle>
<CardDescription>Update your name and email address</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} placeholder='John Doe' />
</FormControl>
<FormDescription>Your public display name</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
type='email'
placeholder='john@example.com'
disabled={userProvider !== 'password'}
/>
</FormControl>
{userProvider === 'password' ? (
<FormDescription>
Your email address for account notifications
</FormDescription>
) : (
<FormDescription>
Email is managed through your{' '}
{providerMap[userProvider ?? 'unknown']} account
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end pt-2'>
<SubmitButton disabled={loading} pendingText='Saving...'>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};
@@ -0,0 +1,90 @@
import Link from 'next/link';
import { SpoonLogo } from '@/components/brand/logo';
export default function Footer() {
return (
<footer className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-12'>
<div className='grid gap-8 md:grid-cols-4'>
<div className='md:col-span-2'>
<SpoonLogo className='mb-4' />
<p className='text-muted-foreground max-w-md text-sm'>
Spoon is a self-hostable fork maintenance dashboard for teams who
want to customize upstream projects without drifting away from
security fixes, product updates, and merge history.
</p>
</div>
<div>
<h4 className='mb-4 text-sm font-semibold'>Product</h4>
<ul className='space-y-2 text-sm'>
<li>
<Link
href='/dashboard'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Dashboard
</Link>
</li>
<li>
<Link
href='/spoons'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Spoons
</Link>
</li>
<li>
<Link
href='/updates'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Updates
</Link>
</li>
</ul>
</div>
<div>
<h4 className='mb-4 text-sm font-semibold'>Workspace</h4>
<ul className='space-y-2 text-sm'>
<li>
<Link
href='/agents'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Agents
</Link>
</li>
<li>
<Link
href='/profile'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Profile
</Link>
</li>
<li>
<Link
href='https://git.gbrown.org/gib/spoon'
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Source
</Link>
</li>
</ul>
</div>
</div>
<div className='border-border/40 text-muted-foreground mt-12 border-t pt-8 text-center text-sm'>
<p>
Self-hostable fork maintenance for teams that stay close to
upstream.
</p>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,91 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
BasedAvatar,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@spoon/ui';
export const AvatarDropdown = () => {
const router = useRouter();
const { isLoading, isAuthenticated } = useConvexAuth();
const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, {});
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
);
if (isLoading) {
return (
<div className='flex items-center gap-2'>
<div className='bg-muted h-8 w-16 animate-pulse rounded-md' />
<div className='bg-muted h-9 w-9 animate-pulse rounded-full' />
</div>
);
}
if (!isAuthenticated) {
return (
<div className='flex items-center gap-2'>
<Button size='sm' asChild>
<Link href='/sign-in'>Sign In</Link>
</Button>
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger>
<BasedAvatar
src={currentImageUrl}
fullName={user?.name}
className='h-9 w-9'
fallbackProps={{ className: 'text-sm font-semibold' }}
userIconProps={{ size: 20 }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{(user?.name ?? user?.email) && (
<>
<DropdownMenuLabel className='text-center font-bold'>
{user.name?.trim() ?? user.email?.trim()}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href='/profile' className='w-full cursor-pointer'>
Edit Profile
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<button
onClick={() =>
void signOut().then(() => {
router.push('/');
})
}
className='w-full cursor-pointer'
>
Sign Out
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,22 @@
'use client';
import type { ThemeToggleProps } from '@spoon/ui';
import { ThemeToggle } from '@spoon/ui';
import { AvatarDropdown } from './AvatarDropdown';
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
return (
<div className='flex items-center gap-3'>
<ThemeToggle
size={1.1}
buttonProps={{
variant: 'ghost',
size: 'sm',
...themeToggleProps?.buttonProps,
}}
/>
<AvatarDropdown />
</div>
);
};
@@ -0,0 +1,71 @@
'use client';
import type { ComponentProps } from 'react';
import Link from 'next/link';
import { SpoonLogo } from '@/components/brand/logo';
import { useConvexAuth } from 'convex/react';
import { Bot, GitBranch, LayoutDashboard, ShieldCheck } from 'lucide-react';
import { Button } from '@spoon/ui';
import type { NavItem } from './navigation';
import { Controls } from './controls';
import { DesktopNavigation, MobileNavigation } from './navigation';
const Header = (headerProps: ComponentProps<'header'>) => {
const { isAuthenticated } = useConvexAuth();
const navItems: NavItem[] = [
{
href: '/#workflow',
icon: GitBranch,
label: 'How it works',
},
{
href: '/#maintenance',
icon: ShieldCheck,
label: 'Maintenance',
},
{
href: '/#agents',
icon: Bot,
label: 'Agents',
},
];
if (isAuthenticated) {
navItems.push({
href: '/dashboard',
icon: LayoutDashboard,
label: 'Dashboard',
});
}
return (
<header
className='border-border/40 bg-background/95 supports-backdrop-filter:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur'
{...headerProps}
>
<div className='container mx-auto flex h-16 items-center justify-between px-4 md:px-6'>
<SpoonLogo />
<DesktopNavigation items={navItems} />
<div className='flex items-center gap-2'>
{isAuthenticated ? (
<Button size='sm' variant='outline' asChild>
<Link href='/spoons'>My Spoons</Link>
</Button>
) : (
<Button size='sm' className='hidden sm:inline-flex' asChild>
<Link href='/sign-in'>Get started</Link>
</Button>
)}
<Controls />
<MobileNavigation items={navItems} />
</div>
</div>
</header>
);
};
export default Header;
@@ -0,0 +1,97 @@
'use client';
import type { LucideIcon } from 'lucide-react';
import Link from 'next/link';
import { ExternalLink, Menu } from 'lucide-react';
import {
Button,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@spoon/ui';
export type NavItem = {
href: string;
icon: LucideIcon;
label: string;
external?: boolean;
};
type NavigationProps = {
items: NavItem[];
};
const DesktopNavigation = ({ items }: NavigationProps) => {
return (
<nav className='hidden items-center gap-4 text-xs font-medium sm:flex md:gap-6 lg:text-base'>
{items.map(({ href, icon: Icon, label, external }) => (
<Link
key={label}
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Icon width={18} height={18} />
{label}
</Link>
))}
</nav>
);
};
const MobileNavigation = ({ items }: NavigationProps) => {
return (
<Sheet>
<SheetTrigger asChild>
<Button
variant='outline'
size='icon-sm'
className='sm:hidden'
aria-label='Open navigation menu'
>
<Menu className='size-4.5' />
<span className='sr-only'>Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side='right' className='w-[min(88vw,22rem)] px-0'>
<SheetHeader className='border-border/60 from-background to-muted/40 border-b bg-linear-to-br from-35% px-5 py-5 text-left'>
<SheetTitle className='text-left text-lg'>Navigation</SheetTitle>
<SheetDescription className='text-left'>
Quick access to the links that collapse out of the header.
</SheetDescription>
</SheetHeader>
<div className='flex flex-col gap-3 px-4 py-5'>
{items.map(({ href, icon: Icon, label, external }) => (
<SheetClose asChild key={label}>
<Link
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className='bg-card hover:bg-muted/70 border-border/60 text-card-foreground flex items-center justify-between rounded-md border px-4 py-3 transition-colors'
>
<span className='flex items-center gap-3'>
<span className='bg-muted text-foreground flex h-9 w-9 items-center justify-center rounded-md'>
<Icon className='size-4.5' />
</span>
<span className='text-sm font-medium'>{label}</span>
</span>
{external ? (
<ExternalLink className='text-muted-foreground size-4' />
) : null}
</Link>
</SheetClose>
))}
</div>
</SheetContent>
</Sheet>
);
};
export { DesktopNavigation, MobileNavigation };
@@ -0,0 +1,14 @@
'use client';
import type { ReactNode } from 'react';
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);
export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
<ConvexAuthNextjsProvider client={convex}>
{children}
</ConvexAuthNextjsProvider>
);
@@ -0,0 +1 @@
export { ConvexClientProvider } from './ConvexClientProvider';
@@ -0,0 +1,245 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation } from 'convex/react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@spoon/ui';
const options = {
provider: ['github', 'gitea', 'gitlab', 'other'],
visibility: ['unknown', 'public', 'private', 'internal'],
maintenanceMode: ['watch', 'auto_pr', 'paused'],
syncCadence: ['daily', 'weekly', 'manual'],
productionRefStrategy: ['default_branch', 'latest_release', 'tag_pattern'],
} as const;
type FormState = {
name: string;
description: string;
provider: (typeof options.provider)[number];
upstreamOwner: string;
upstreamRepo: string;
upstreamDefaultBranch: string;
upstreamUrl: string;
forkOwner: string;
forkRepo: string;
forkUrl: string;
visibility: (typeof options.visibility)[number];
maintenanceMode: (typeof options.maintenanceMode)[number];
syncCadence: (typeof options.syncCadence)[number];
productionRefStrategy: (typeof options.productionRefStrategy)[number];
};
const initialState: FormState = {
name: '',
description: '',
provider: 'github',
upstreamOwner: '',
upstreamRepo: '',
upstreamDefaultBranch: 'main',
upstreamUrl: '',
forkOwner: '',
forkRepo: '',
forkUrl: '',
visibility: 'unknown',
maintenanceMode: 'watch',
syncCadence: 'daily',
productionRefStrategy: 'default_branch',
};
const TextField = ({
id,
label,
value,
onChange,
required,
}: {
id: keyof FormState;
label: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
}) => (
<div className='grid gap-2'>
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
value={value}
required={required}
onChange={(event) => onChange(event.target.value)}
/>
</div>
);
export const NewSpoonForm = () => {
const router = useRouter();
const createManual = useMutation(api.spoons.createManual);
const [form, setForm] = useState<FormState>(initialState);
const [submitting, setSubmitting] = useState(false);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((current) => ({ ...current, [key]: value }));
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
await createManual({
...form,
description: form.description || undefined,
forkOwner: form.forkOwner || undefined,
forkRepo: form.forkRepo || undefined,
forkUrl: form.forkUrl || undefined,
});
toast.success('Spoon created.');
router.push('/spoons');
} catch (error) {
console.error(error);
toast.error('Could not create Spoon.');
} finally {
setSubmitting(false);
}
};
return (
<form
onSubmit={handleSubmit}
className='border-border bg-card grid gap-6 border p-5'
>
<div className='grid gap-4 md:grid-cols-2'>
<TextField
id='name'
label='Spoon name'
value={form.name}
required
onChange={(value) => update('name', value)}
/>
<Select
value={form.provider}
onValueChange={(value) =>
update('provider', value as FormState['provider'])
}
>
<div className='grid gap-2'>
<Label>Provider</Label>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
</div>
<SelectContent>
{options.provider.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='description'>Description</Label>
<Textarea
id='description'
value={form.description}
onChange={(event) => update('description', event.target.value)}
/>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<TextField
id='upstreamOwner'
label='Upstream owner'
value={form.upstreamOwner}
required
onChange={(value) => update('upstreamOwner', value)}
/>
<TextField
id='upstreamRepo'
label='Upstream repository'
value={form.upstreamRepo}
required
onChange={(value) => update('upstreamRepo', value)}
/>
<TextField
id='upstreamDefaultBranch'
label='Upstream default branch'
value={form.upstreamDefaultBranch}
required
onChange={(value) => update('upstreamDefaultBranch', value)}
/>
<TextField
id='upstreamUrl'
label='Upstream URL'
value={form.upstreamUrl}
required
onChange={(value) => update('upstreamUrl', value)}
/>
<TextField
id='forkOwner'
label='Fork owner'
value={form.forkOwner}
onChange={(value) => update('forkOwner', value)}
/>
<TextField
id='forkRepo'
label='Fork repository'
value={form.forkRepo}
onChange={(value) => update('forkRepo', value)}
/>
<TextField
id='forkUrl'
label='Fork URL'
value={form.forkUrl}
onChange={(value) => update('forkUrl', value)}
/>
</div>
<div className='grid gap-4 md:grid-cols-4'>
{(
[
'visibility',
'maintenanceMode',
'syncCadence',
'productionRefStrategy',
] as const
).map((key) => (
<Select
key={key}
value={form[key]}
onValueChange={(value) => update(key, value as never)}
>
<div className='grid gap-2'>
<Label>{key.replace(/([A-Z])/g, ' $1')}</Label>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
</div>
<SelectContent>
{options[key].map((value) => (
<SelectItem key={value} value={value}>
{value.replaceAll('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
<div className='flex justify-end'>
<Button type='submit' disabled={submitting}>
{submitting ? 'Creating...' : 'Create Spoon'}
</Button>
</div>
</form>
);
};
@@ -0,0 +1,43 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const formatDate = (value?: number) =>
value
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
<Card className='shadow-none'>
<CardHeader className='flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='text-lg'>{spoon.name}</CardTitle>
<p className='text-muted-foreground mt-1 text-sm'>
{spoon.upstreamOwner}/{spoon.upstreamRepo}
</p>
</div>
<Badge variant='outline'>{spoon.status.replaceAll('_', ' ')}</Badge>
</CardHeader>
<CardContent className='grid gap-3 text-sm md:grid-cols-2'>
<div>
<p className='text-muted-foreground'>Provider</p>
<p className='font-medium'>{spoon.provider}</p>
</div>
<div>
<p className='text-muted-foreground'>Cadence</p>
<p className='font-medium'>{spoon.syncCadence}</p>
</div>
<div>
<p className='text-muted-foreground'>Fork</p>
<p className='font-medium'>
{spoon.forkOwner && spoon.forkRepo
? `${spoon.forkOwner}/${spoon.forkRepo}`
: 'Not connected'}
</p>
</div>
<div>
<p className='text-muted-foreground'>Last checked</p>
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
</div>
</CardContent>
</Card>
);
+46
View File
@@ -0,0 +1,46 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod/v4';
export const env = createEnv({
server: {
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
SKIP_ENV_VALIDATION: z.boolean().default(false),
SENTRY_AUTH_TOKEN: z.string(),
CI: z.boolean().default(false),
},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_SITE_URL: z.url(),
NEXT_PUBLIC_CONVEX_URL: z.url(),
NEXT_PUBLIC_PLAUSIBLE_URL: z.url(),
NEXT_PUBLIC_SENTRY_DSN: z.string(),
NEXT_PUBLIC_SENTRY_URL: z.string(),
NEXT_PUBLIC_SENTRY_ORG: z.string(),
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG,
NEXT_PUBLIC_SENTRY_PROJECT_NAME:
process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
});
+27
View File
@@ -0,0 +1,27 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import { env } from '@/env';
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
integrations: [
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
Sentry.feedbackIntegration({
colorScheme: 'system',
}),
],
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true,
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
tracesSampleRate: 1,
enableLogs: true,
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.5,
replaysOnErrorSampleRate: 1.0,
debug: false,
});
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
+7
View File
@@ -0,0 +1,7 @@
import type { Instrumentation } from 'next';
import * as Sentry from '@sentry/nextjs';
export const register = async () => await import('./sentry.server.config');
export const onRequestError: Instrumentation.onRequestError = (...args) => {
Sentry.captureRequestError(...args);
};
+127
View File
@@ -0,0 +1,127 @@
import type { Metadata } from 'next';
import * as Sentry from '@sentry/nextjs';
export const generateMetadata = (): Metadata => {
return {
title: {
template: '%s | Spoon',
default: 'Spoon',
},
description: 'Spoon',
applicationName: 'Spoon',
keywords: 'Spoon, Nextjs, Tailwind, TypeScript, React, Gib',
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
creator: 'Gib Brown',
publisher: 'Gib Brown',
formatDetection: {
email: false,
address: false,
telephone: false,
},
robots: {
index: true,
follow: true,
nocache: false,
googleBot: {
index: true,
follow: true,
noimageindex: false,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
icons: {
icon: [
{ url: '/favicon.png', type: 'image/png', sizes: 'any' },
{
url: '/favicon-light.png',
type: 'image/png',
sizes: 'any',
media: '(prefers-color-scheme: dark)',
},
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//},
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//media: '(prefers-color-scheme: dark)',
//},
],
//shortcut: [
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//},
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//media: '(prefers-color-scheme: dark)',
//},
//],
//apple: [
//{
//url: 'appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//},
//{
//url: 'appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//media: '(prefers-color-scheme: dark)',
//},
//],
//other: [
//{
//rel: 'apple-touch-icon-precomposed',
//url: '/appicon/icon-precomposed.png',
//type: 'image/png',
//sizes: '180x180',
//},
//],
},
other: {
...Sentry.getTraceData(),
},
//appleWebApp: {
//title: 'Spoon',
//statusBarStyle: 'black-translucent',
//startupImage: [
//'/icons/apple/splash-768x1004.png',
//{
//url: '/icons/apple/splash-1536x2008.png',
//media: '(device-width: 768px) and (device-height: 1024px)',
//},
//],
//},
verification: {
google: 'google',
yandex: 'yandex',
yahoo: 'yahoo',
},
category: 'technology',
/*
appLinks: {
ios: {
url: 'https://techtracker.gbrown.org/ios',
app_store_id: 'com.gbrown.techtracker',
},
android: {
package: 'https://techtracker.gbrown.org/android',
app_name: 'app_t3_template',
},
web: {
url: 'https://techtracker.gbrown.org',
should_fallback: true,
},
},
*/
};
};
+223
View File
@@ -0,0 +1,223 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// In-memory stores for tracking IPs (use Redis in production)
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
const ip404Attempts = new Map<string, { count: number; lastAttempt: number }>();
// Map of ip -> ban expiry timestamp. Avoids setTimeout closures leaking on hot reload.
const bannedIPs = new Map<string, number>();
// Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [
// Your existing patterns
/web-inf/i,
/\.jsp/i,
/\.php/i,
/puttest/i,
/WEB-INF/i,
/\.xml$/i,
/perl/i,
/xampp/i,
/phpwebgallery/i,
/FileManager/i,
/standalonemanager/i,
/h2console/i,
/WebAdmin/i,
/login_form\.php/i,
/%2e/i,
/%u002e/i,
/\.%00/i,
/\.\./,
/lcgi/i,
// New patterns from your logs
/\/appliance\//i,
/bomgar/i,
/netburner-logo/i,
/\/ui\/images\//i,
/logon_merge/i,
/logon_t\.gif/i,
/login_top\.gif/i,
/theme1\/images/i,
/\.well-known\/acme-challenge\/.*\.jpg$/i,
/\.well-known\/pki-validation\/.*\.jpg$/i,
// Path traversal and system file access patterns
/\/etc\/passwd/i,
/\/etc%2fpasswd/i,
/\/etc%5cpasswd/i,
/\/\/+etc/i,
/\\\\+.*etc/i,
/%2f%2f/i,
/%5c%5c/i,
/\/\/+/,
/\\\\+/,
/%00/i,
/%23/i,
// Encoded path traversal attempts
/%2e%2e/i,
/%252e/i,
/%c0%ae/i,
/%c1%9c/i,
];
// Suspicious HTTP methods
const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH'];
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const MAX_ATTEMPTS = 10; // Max suspicious requests per window
const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
// 404 rate limiting settings
const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes
const MAX_404_ATTEMPTS = 10; // Max 404s before ban
let lastCleanup = Date.now();
const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
// Lazily purge stale entries so Maps don't grow without bound.
// Called on every request but only iterates Maps every CLEANUP_INTERVAL.
const cleanupStaleMaps = () => {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
for (const [ip, data] of ipAttempts.entries()) {
if (now - data.lastAttempt > RATE_LIMIT_WINDOW) ipAttempts.delete(ip);
}
for (const [ip, data] of ip404Attempts.entries()) {
if (now - data.lastAttempt > RATE_404_WINDOW) ip404Attempts.delete(ip);
}
for (const [ip, expiry] of bannedIPs.entries()) {
if (now > expiry) bannedIPs.delete(ip);
}
};
const isIPBanned = (ip: string): boolean => {
const expiry = bannedIPs.get(ip);
if (expiry === undefined) return false;
if (Date.now() > expiry) {
bannedIPs.delete(ip);
return false;
}
return true;
};
const getClientIP = (request: NextRequest): string => {
const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip');
const cfConnectingIP = request.headers.get('cf-connecting-ip');
if (forwarded) return (forwarded.split(',')[0] ?? '').trim();
if (realIP) return realIP;
if (cfConnectingIP) return cfConnectingIP;
return request.headers.get('host') ?? 'unknown';
};
const isPathSuspicious = (pathname: string): boolean => {
return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
};
const isMethodSuspicious = (method: string): boolean => {
return SUSPICIOUS_METHODS.includes(method);
};
const updateIPAttempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ipAttempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
ipAttempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_ATTEMPTS) {
bannedIPs.set(ip, Date.now() + BAN_DURATION);
ipAttempts.delete(ip);
return true;
}
return false;
};
const update404Attempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ip404Attempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_404_WINDOW) {
ip404Attempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_404_ATTEMPTS) {
bannedIPs.set(ip, Date.now() + BAN_DURATION);
ip404Attempts.delete(ip);
console.log(
`🔨 IP ${ip} banned for excessive 404 requests (${attempts.count} in ${RATE_404_WINDOW / 1000}s)`,
);
return true;
}
return false;
};
export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => {
cleanupStaleMaps();
const { pathname } = request.nextUrl;
const method = request.method;
const ip = getClientIP(request);
// Check if IP is already banned
if (isIPBanned(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
const isSuspiciousPath = isPathSuspicious(pathname);
const isSuspiciousMethod = isMethodSuspicious(method);
// Handle suspicious activity
if (isSuspiciousPath || isSuspiciousMethod) {
const shouldBan = updateIPAttempts(ip);
if (shouldBan) {
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
return new NextResponse('Access denied - IP banned. Please fuck off.', {
status: 403,
});
}
return new NextResponse('Not Found', { status: 404 });
}
return null;
};
// Call this function when you detect a 404 response
export const handle404Response = (
request: NextRequest,
): NextResponse | null => {
const ip = getClientIP(request);
if (isIPBanned(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
const shouldBan = update404Attempts(ip);
if (shouldBan) {
return new NextResponse('Access denied - IP banned for excessive 404s.', {
status: 403,
});
}
return null;
};
+36
View File
@@ -0,0 +1,36 @@
import {
convexAuthNextjsMiddleware,
createRouteMatcher,
nextjsMiddlewareRedirect,
} from '@convex-dev/auth/nextjs/server';
const isAuthRoute = createRouteMatcher(['/sign-in', '/forgot-password']);
const isProtectedRoute = createRouteMatcher([
'/dashboard(.*)',
'/spoons(.*)',
'/updates(.*)',
'/agents(.*)',
'/profile(.*)',
]);
export default convexAuthNextjsMiddleware(
async (request, { convexAuth }) => {
const isAuthenticated = await convexAuth.isAuthenticated();
if (isAuthRoute(request) && isAuthenticated) {
return nextjsMiddlewareRedirect(request, '/dashboard');
}
if (isProtectedRoute(request) && !isAuthenticated) {
return nextjsMiddlewareRedirect(request, '/sign-in');
}
},
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
);
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
'/((?!.*\\..*|_next).*)',
'/',
'/(api)(.*)',
],
};
+9
View File
@@ -0,0 +1,9 @@
import { env } from '@/env';
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1,
enableLogs: true,
debug: false,
});