Update Convex with no payload to be just like convex with payload but without payload

This commit is contained in:
Gabriel Brown
2026-06-21 15:35:42 -05:00
parent 13b8b36c4c
commit fba73a92ce
130 changed files with 15637 additions and 32018 deletions
+12 -1
View File
@@ -1,14 +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 = ({
const ProfileLayout = async ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
if (!(await isAuthenticatedNextjs())) redirect('/sign-in');
return <>{children}</>;
};
export default ProfileLayout;
+2 -2
View File
@@ -159,7 +159,7 @@ const SignIn = () => {
};
const handleVerifyEmail = async (
values: z.infer<typeof verifyEmailFormSchema>,
_values: z.infer<typeof verifyEmailFormSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
@@ -194,7 +194,7 @@ const SignIn = () => {
<FormField
control={verifyEmailForm.control}
name='code'
render={({ field }) => (
render={({ field: _field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
+10 -10
View File
@@ -1,12 +1,12 @@
import { CTA, Features, Hero, TechStack } from '@/components/landing';
export default function Home() {
return (
<main className='flex min-h-screen flex-col'>
<Hero />
<Features />
<TechStack />
<CTA />
</main>
);
}
const Home = () => (
<main className='flex min-h-screen flex-col'>
<Hero />
<Features />
<TechStack />
<CTA />
</main>
);
export default Home;
+5 -1
View File
@@ -2,7 +2,7 @@
@import 'tw-animate-css';
@import '@gib/tailwind-config/theme';
@source '../../../../packages/ui/src/*.{ts,tsx}';
@source '../../../../../packages/ui/src/*.{ts,tsx}';
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));
@@ -23,7 +23,11 @@
* {
@apply border-border;
}
html {
@apply bg-background;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}
-1
View File
@@ -10,7 +10,6 @@ export const CTA = () => (
everything pre-configured.
</p>
{/* Quick Start Command */}
<div className='mt-12'>
<p className='text-muted-foreground mb-3 text-sm font-medium'>
Quick Start
@@ -60,7 +60,6 @@ const features = [
export const Features = () => (
<section id='features' className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
{/* Section Header */}
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Everything You Need to Ship Fast
@@ -71,7 +70,6 @@ export const Features = () => (
</p>
</div>
{/* Features Grid */}
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-3'>
{features.map((feature) => (
<Card key={feature.title} className='border-border/40'>
+20 -69
View File
@@ -9,16 +9,16 @@ const kanitSans = Kanit({
weight: ['400', '500', '600', '700'],
});
const highlights = ['TypeScript', 'Self-Hosted', 'Real-time', 'Auth Included'];
export const Hero = () => (
<section className='container mx-auto px-4 py-24 md:py-32 lg:py-40'>
<div className='mx-auto flex max-w-5xl flex-col items-center gap-8 text-center'>
{/* Badge */}
<div className='border-border/40 bg-muted/50 inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium'>
<span className='mr-2'>🚀</span>
<span>Production-ready monorepo template</span>
</div>
{/* Heading */}
<h1 className='from-foreground to-foreground/70 bg-linear-to-br bg-clip-text text-4xl font-bold tracking-tight text-transparent sm:text-5xl md:text-6xl lg:text-7xl'>
Build Full-Stack Apps with{' '}
<span
@@ -28,14 +28,12 @@ export const Hero = () => (
</span>
</h1>
{/* Description */}
<p className='text-muted-foreground max-w-2xl text-lg md:text-xl'>
A Turborepo starter with Next.js, Expo, and self-hosted Convex. Ship web
and mobile apps faster with shared code, type-safe backend, and complete
control over your infrastructure.
</p>
{/* CTA Buttons */}
<div className='flex flex-col gap-3 sm:flex-row'>
<Button size='lg' variant='outline' asChild>
<Link
@@ -54,72 +52,25 @@ export const Hero = () => (
</Button>
</div>
{/* Features Quick List */}
<div className='text-muted-foreground mt-8 flex flex-wrap items-center justify-center gap-6 text-sm'>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>TypeScript</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Self-Hosted</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Real-time</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Auth Included</span>
</div>
{highlights.map((highlight) => (
<div key={highlight} className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>{highlight}</span>
</div>
))}
</div>
</div>
</section>
@@ -29,7 +29,7 @@ const techStack = [
technologies: [
{ name: 'Turborepo', description: 'High-performance build system' },
{ name: 'TypeScript', description: 'Type-safe development' },
{ name: 'Bun', description: 'Fast package manager & runtime' },
{ name: 'Bun', description: 'Fast package manager and runtime' },
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
{ name: 'Docker', description: 'Containerized deployment' },
],
@@ -40,7 +40,6 @@ 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'>
{/* Section Header */}
<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
@@ -51,7 +50,6 @@ export const TechStack = () => (
</p>
</div>
{/* Tech Stack Grid */}
<div className='grid gap-12 md:grid-cols-3'>
{techStack.map((stack) => (
<div key={stack.category}>
@@ -21,9 +21,9 @@ import {
Input,
} from '@gib/ui';
interface AvatarUploadProps {
type AvatarUploadProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
}
};
const dataUrlToBlob = async (
dataUrl: string,
@@ -1,17 +1,54 @@
'use client';
import type { ComponentProps } from 'react';
import { Kanit } from 'next/font/google';
import Image from 'next/image';
import Link from 'next/link';
import { Coffee, Server, Wrench } from 'lucide-react';
import { useConvexAuth, useQuery } from 'convex/react';
import { Coffee, Server, User, Wrench } from 'lucide-react';
import { api } from '@gib/backend/convex/_generated/api.js';
import type { NavItem } from './navigation';
import { Controls } from './controls';
import { DesktopNavigation, MobileNavigation } from './navigation';
const kanitSans = Kanit({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
});
export default function Header(headerProps: ComponentProps<'header'>) {
const Header = (headerProps: ComponentProps<'header'>) => {
const { isAuthenticated } = useConvexAuth();
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
const navItems: NavItem[] = [
{
href: '/#features',
icon: Wrench,
label: 'Features',
},
{
href: '/#tech-stack',
icon: Server,
label: 'Stack',
},
{
href: 'https://git.gbrown.org/gib/convex-monorepo',
icon: Coffee,
label: 'Repository',
external: true,
},
];
if (user?.isAdmin) {
navItems.push({
href: '/admin',
icon: User,
label: 'Admin',
external: true,
});
}
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'
@@ -28,45 +65,24 @@ export default function Header(headerProps: ComponentProps<'header'>) {
alt='Convex Monorepo'
width={50}
height={50}
className='w-15 invert dark:invert-0'
className='w-10 invert lg:w-15 dark:invert-0'
/>
<span
className={`mb-3 hidden font-extrabold lg:inline lg:text-5xl ${kanitSans.className}`}
className={`hidden font-extrabold sm:mb-1 sm:text-lg md:inline lg:mb-3 lg:text-4xl xl:text-5xl ${kanitSans.className}`}
>
convex monorepo
convex-monorepo
</span>
</Link>
{/* Navigation */}
<nav className='hidden items-center gap-6 text-base font-medium md:flex'>
<Link
href='/#features'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Wrench width={18} height={18} />
Features
</Link>
<Link
href='/#tech-stack'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Server width={18} height={18} />
Stack
</Link>
<Link
href='https://git.gbrown.org/gib/convex-monorepo'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Coffee width={20} height={20} />
Repository
</Link>
</nav>
<DesktopNavigation items={navItems} />
{/* Controls (Theme + Auth) */}
<Controls />
<div className='flex items-center gap-2'>
<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 '@gib/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-2xl 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-xl'>
<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 };
+1 -1
View File
@@ -19,7 +19,7 @@ Sentry.init({
tracesSampleRate: 1,
enableLogs: true,
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 1.0,
replaysSessionSampleRate: 0.5,
replaysOnErrorSampleRate: 1.0,
debug: false,
});
+38 -14
View File
@@ -4,7 +4,8 @@ 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 }>();
const bannedIPs = new Set<string>();
// 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 = [
@@ -72,6 +73,36 @@ const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
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');
@@ -104,13 +135,8 @@ const updateIPAttempts = (ip: string): boolean => {
attempts.lastAttempt = now;
if (attempts.count > MAX_ATTEMPTS) {
bannedIPs.add(ip);
bannedIPs.set(ip, Date.now() + BAN_DURATION);
ipAttempts.delete(ip);
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
@@ -130,17 +156,13 @@ const update404Attempts = (ip: string): boolean => {
attempts.lastAttempt = now;
if (attempts.count > MAX_404_ATTEMPTS) {
bannedIPs.add(ip);
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)`,
);
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
@@ -148,12 +170,14 @@ const update404Attempts = (ip: string): boolean => {
};
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 (bannedIPs.has(ip)) {
if (isIPBanned(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
@@ -183,7 +207,7 @@ export const handle404Response = (
): NextResponse | null => {
const ip = getClientIP(request);
if (bannedIPs.has(ip)) {
if (isIPBanned(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
+21 -5
View File
@@ -1,19 +1,36 @@
import { convexAuthNextjsMiddleware } from '@convex-dev/auth/nextjs/server';
export default convexAuthNextjsMiddleware();
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
'/((?!.*\\..*|_next).*)',
'/',
'/(api)(.*)',
],
};
/**
import { banSuspiciousIPs } from '@/lib/proxy/ban-sus-ips';
import {
convexAuthNextjsMiddleware,
createRouteMatcher,
nextjsMiddlewareRedirect,
} from '@convex-dev/auth/nextjs/server';
const isSignInPage = createRouteMatcher(['/sign-in']);
const isProtectedRoute = createRouteMatcher(['/profile']);
const isProtectedRoute = createRouteMatcher([
'/profile(.*)',
'/dashboard(.*)',
'/admin-panel(.*)',
]);
export default convexAuthNextjsMiddleware(
async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/');
return nextjsMiddlewareRedirect(request, '/profile');
}
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/sign-in');
@@ -23,8 +40,6 @@ export default convexAuthNextjsMiddleware(
);
export const config = {
// The following matcher runs middleware on all routes
// except static assets.
matcher: [
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
'/((?!.*\\..*|_next).*)',
@@ -32,3 +47,4 @@ export const config = {
'/(api)(.*)',
],
};
**/