npx command

This commit is contained in:
Leon van Zyl
2025-11-02 08:45:37 +02:00
parent 343b67e36a
commit ec929ab918
74 changed files with 5304 additions and 5 deletions

View File

@@ -0,0 +1,15 @@
import { openai } from "@ai-sdk/openai";
import { streamText, UIMessage, convertToModelMessages } from "ai";
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai(process.env.OPENAI_MODEL || "gpt-5-mini"),
messages: convertToModelMessages(messages),
});
return (
result as unknown as { toUIMessageStreamResponse: () => Response }
).toUIMessageStreamResponse();
}

View File

@@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
type StatusLevel = "ok" | "warn" | "error";
interface DiagnosticsResponse {
timestamp: string;
env: {
POSTGRES_URL: boolean;
BETTER_AUTH_SECRET: boolean;
GOOGLE_CLIENT_ID: boolean;
GOOGLE_CLIENT_SECRET: boolean;
OPENAI_API_KEY: boolean;
NEXT_PUBLIC_APP_URL: boolean;
};
database: {
connected: boolean;
schemaApplied: boolean;
error?: string;
};
auth: {
configured: boolean;
routeResponding: boolean | null;
};
ai: {
configured: boolean;
};
overallStatus: StatusLevel;
}
export async function GET(req: Request) {
const env = {
POSTGRES_URL: Boolean(process.env.POSTGRES_URL),
BETTER_AUTH_SECRET: Boolean(process.env.BETTER_AUTH_SECRET),
GOOGLE_CLIENT_ID: Boolean(process.env.GOOGLE_CLIENT_ID),
GOOGLE_CLIENT_SECRET: Boolean(process.env.GOOGLE_CLIENT_SECRET),
OPENAI_API_KEY: Boolean(process.env.OPENAI_API_KEY),
NEXT_PUBLIC_APP_URL: Boolean(process.env.NEXT_PUBLIC_APP_URL),
} as const;
// Database checks
let dbConnected = false;
let schemaApplied = false;
let dbError: string | undefined;
if (env.POSTGRES_URL) {
try {
const [{ db }, { sql }, schema] = await Promise.all([
import("@/lib/db"),
import("drizzle-orm"),
import("@/lib/schema"),
]);
// Ping DB
await db.execute(sql`select 1`);
dbConnected = true;
try {
// Touch a known table to verify migrations
await db.select().from(schema.user).limit(1);
schemaApplied = true;
} catch {
schemaApplied = false;
}
} catch (err) {
dbConnected = false;
dbError = err instanceof Error ? err.message : "Unknown database error";
}
} else {
dbConnected = false;
schemaApplied = false;
dbError = "POSTGRES_URL is not set";
}
// Auth route check: we consider the route responding if it returns any HTTP response
// for /api/auth/session (status codes in the 2xx-4xx range are acceptable for readiness)
const origin = (() => {
try {
return new URL(req.url).origin;
} catch {
return process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
}
})();
let authRouteResponding: boolean | null = null;
try {
const res = await fetch(`${origin}/api/auth/session`, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
authRouteResponding = res.status >= 200 && res.status < 500;
} catch {
authRouteResponding = false;
}
const authConfigured =
env.BETTER_AUTH_SECRET && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET;
const aiConfigured = env.OPENAI_API_KEY; // We avoid live-calling the AI provider here
const overallStatus: StatusLevel = (() => {
if (!env.POSTGRES_URL || !dbConnected || !schemaApplied) return "error";
if (!authConfigured) return "error";
// AI is optional; warn if not configured
if (!aiConfigured) return "warn";
return "ok";
})();
const body: DiagnosticsResponse = {
timestamp: new Date().toISOString(),
env,
database: {
connected: dbConnected,
schemaApplied,
error: dbError,
},
auth: {
configured: authConfigured,
routeResponding: authRouteResponding,
},
ai: {
configured: aiConfigured,
},
overallStatus,
};
return NextResponse.json(body, {
status: 200,
});
}

View File

@@ -0,0 +1,207 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { Button } from "@/components/ui/button";
import { UserProfile } from "@/components/auth/user-profile";
import { useSession } from "@/lib/auth-client";
import { useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
const H1: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h1 className="mt-2 mb-3 text-2xl font-bold" {...props} />
);
const H2: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h2 className="mt-2 mb-2 text-xl font-semibold" {...props} />
);
const H3: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h3 className="mt-2 mb-2 text-lg font-semibold" {...props} />
);
const Paragraph: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = (
props
) => <p className="mb-3 leading-7 text-sm" {...props} />;
const UL: React.FC<React.HTMLAttributes<HTMLUListElement>> = (props) => (
<ul className="mb-3 ml-5 list-disc space-y-1 text-sm" {...props} />
);
const OL: React.FC<React.OlHTMLAttributes<HTMLOListElement>> = (props) => (
<ol className="mb-3 ml-5 list-decimal space-y-1 text-sm" {...props} />
);
const LI: React.FC<React.LiHTMLAttributes<HTMLLIElement>> = (props) => (
<li className="leading-6" {...props} />
);
const Anchor: React.FC<React.AnchorHTMLAttributes<HTMLAnchorElement>> = (
props
) => (
<a
className="underline underline-offset-2 text-primary hover:opacity-90"
target="_blank"
rel="noreferrer noopener"
{...props}
/>
);
const Blockquote: React.FC<React.BlockquoteHTMLAttributes<HTMLElement>> = (
props
) => (
<blockquote
className="mb-3 border-l-2 border-border pl-3 text-muted-foreground"
{...props}
/>
);
const Code: Components["code"] = ({ children, className, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
if (isInline) {
return (
<code className="rounded bg-muted px-1 py-0.5 text-xs" {...props}>
{children}
</code>
);
}
return (
<pre className="mb-3 w-full overflow-x-auto rounded-md bg-muted p-3">
<code className="text-xs leading-5" {...props}>
{children}
</code>
</pre>
);
};
const HR: React.FC<React.HTMLAttributes<HTMLHRElement>> = (props) => (
<hr className="my-4 border-border" {...props} />
);
const Table: React.FC<React.TableHTMLAttributes<HTMLTableElement>> = (
props
) => (
<div className="mb-3 overflow-x-auto">
<table className="w-full border-collapse text-sm" {...props} />
</div>
);
const TH: React.FC<React.ThHTMLAttributes<HTMLTableCellElement>> = (props) => (
<th
className="border border-border bg-muted px-2 py-1 text-left"
{...props}
/>
);
const TD: React.FC<React.TdHTMLAttributes<HTMLTableCellElement>> = (props) => (
<td className="border border-border px-2 py-1" {...props} />
);
const markdownComponents: Components = {
h1: H1,
h2: H2,
h3: H3,
p: Paragraph,
ul: UL,
ol: OL,
li: LI,
a: Anchor,
blockquote: Blockquote,
code: Code,
hr: HR,
table: Table,
th: TH,
td: TD,
};
type TextPart = { type?: string; text?: string };
type MaybePartsMessage = {
display?: ReactNode;
parts?: TextPart[];
content?: TextPart[];
};
function renderMessageContent(message: MaybePartsMessage): ReactNode {
if (message.display) return message.display;
const parts = Array.isArray(message.parts)
? message.parts
: Array.isArray(message.content)
? message.content
: [];
return parts.map((p, idx) =>
p?.type === "text" && p.text ? (
<ReactMarkdown key={idx} components={markdownComponents}>
{p.text}
</ReactMarkdown>
) : null
);
}
export default function ChatPage() {
const { data: session, isPending } = useSession();
const { messages, sendMessage, status } = useChat();
const [input, setInput] = useState("");
if (isPending) {
return <div className="container mx-auto px-4 py-12">Loading...</div>;
}
if (!session) {
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-3xl mx-auto">
<UserProfile />
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6 pb-4 border-b">
<h1 className="text-2xl font-bold">AI Chat</h1>
<span className="text-sm text-muted-foreground">
Welcome, {session.user.name}!
</span>
</div>
<div className="min-h-[50vh] overflow-y-auto space-y-4 mb-4">
{messages.length === 0 && (
<div className="text-center text-muted-foreground">
Start a conversation with AI
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`p-3 rounded-lg ${
message.role === "user"
? "bg-primary text-primary-foreground ml-auto max-w-[80%]"
: "bg-muted max-w-[80%]"
}`}
>
<div className="text-sm font-medium mb-1">
{message.role === "user" ? "You" : "AI"}
</div>
<div>{renderMessageContent(message as MaybePartsMessage)}</div>
</div>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const text = input.trim();
if (!text) return;
sendMessage({ role: "user", parts: [{ type: "text", text }] });
setInput("");
}}
className="flex gap-2"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="flex-1 p-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
<Button
type="submit"
disabled={!input.trim() || status === "streaming"}
>
Send
</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { useSession } from "@/lib/auth-client";
import { UserProfile } from "@/components/auth/user-profile";
import { Button } from "@/components/ui/button";
import { Lock } from "lucide-react";
import { useDiagnostics } from "@/hooks/use-diagnostics";
import Link from "next/link";
export default function DashboardPage() {
const { data: session, isPending } = useSession();
const { isAiReady, loading: diagnosticsLoading } = useDiagnostics();
if (isPending) {
return (
<div className="flex justify-center items-center h-screen">
Loading...
</div>
);
}
if (!session) {
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-3xl mx-auto text-center">
<div className="mb-8">
<Lock className="w-16 h-16 mx-auto mb-4 text-muted-foreground" />
<h1 className="text-2xl font-bold mb-2">Protected Page</h1>
<p className="text-muted-foreground mb-6">
You need to sign in to access the dashboard
</p>
</div>
<UserProfile />
</div>
</div>
);
}
return (
<div className="container mx-auto p-6">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Dashboard</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-6 border border-border rounded-lg">
<h2 className="text-xl font-semibold mb-2">AI Chat</h2>
<p className="text-muted-foreground mb-4">
Start a conversation with AI using the Vercel AI SDK
</p>
{(diagnosticsLoading || !isAiReady) ? (
<Button disabled={true}>
Go to Chat
</Button>
) : (
<Button asChild>
<Link href="/chat">Go to Chat</Link>
</Button>
)}
</div>
<div className="p-6 border border-border rounded-lg">
<h2 className="text-xl font-semibold mb-2">Profile</h2>
<p className="text-muted-foreground mb-4">
Manage your account settings and preferences
</p>
<div className="space-y-2">
<p>
<strong>Name:</strong> {session.user.name}
</p>
<p>
<strong>Email:</strong> {session.user.email}
</p>
</div>
</div>
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,117 @@
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,47 @@
import { ThemeProvider } from "@/components/theme-provider";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Agentic Coding Boilerplate",
description:
"Complete agentic coding boilerplate with authentication, database, AI integration, and modern tooling - perfect for building AI-powered applications and autonomous agents by Leon van Zyl",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SiteHeader />
{children}
<SiteFooter />
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { SetupChecklist } from "@/components/setup-checklist";
import { useDiagnostics } from "@/hooks/use-diagnostics";
import { StarterPromptModal } from "@/components/starter-prompt-modal";
import { Video, Shield, Database, Palette, Bot } from "lucide-react";
export default function Home() {
const { isAuthReady, isAiReady, loading } = useDiagnostics();
return (
<main className="flex-1 container mx-auto px-4 py-12">
<div className="max-w-4xl mx-auto text-center space-y-8">
<div className="space-y-4">
<div className="flex items-center justify-center gap-3 mb-2">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10">
<Bot className="h-7 w-7 text-primary" />
</div>
<h1 className="text-5xl font-bold tracking-tight bg-gradient-to-r from-primary via-primary/90 to-primary/70 bg-clip-text text-transparent">
Starter Kit
</h1>
</div>
<h2 className="text-2xl font-semibold text-muted-foreground">
Complete Boilerplate for AI Applications
</h2>
<p className="text-xl text-muted-foreground">
A complete agentic coding boilerplate with authentication, database, AI
integration, and modern tooling for building AI-powered applications
</p>
</div>
{/* YouTube Tutorial Video */}
<div className="space-y-4">
<h3 className="text-2xl font-semibold flex items-center justify-center gap-2">
<Video className="h-6 w-6" />
Video Tutorial
</h3>
<p className="text-muted-foreground">
Watch the complete walkthrough of this agentic coding boilerplate:
</p>
<div className="relative w-full max-w-3xl mx-auto">
<div className="relative pb-[56.25%] h-0 overflow-hidden rounded-lg border">
<iframe
className="absolute top-0 left-0 w-full h-full"
src="https://www.youtube.com/embed/T0zFZsr_d0Q"
title="Agentic Coding Boilerplate Tutorial"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
<div className="p-6 border rounded-lg">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Shield className="h-4 w-4" />
Authentication
</h3>
<p className="text-sm text-muted-foreground">
Better Auth with Google OAuth integration
</p>
</div>
<div className="p-6 border rounded-lg">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Database className="h-4 w-4" />
Database
</h3>
<p className="text-sm text-muted-foreground">
Drizzle ORM with PostgreSQL setup
</p>
</div>
<div className="p-6 border rounded-lg">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Bot className="h-4 w-4" />
AI Ready
</h3>
<p className="text-sm text-muted-foreground">
Vercel AI SDK with OpenAI integration
</p>
</div>
<div className="p-6 border rounded-lg">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Palette className="h-4 w-4" />
UI Components
</h3>
<p className="text-sm text-muted-foreground">
shadcn/ui with Tailwind CSS
</p>
</div>
</div>
<div className="space-y-6 mt-12">
<SetupChecklist />
<h3 className="text-2xl font-semibold">Next Steps</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-left">
<div className="p-4 border rounded-lg">
<h4 className="font-medium mb-2">
1. Set up environment variables
</h4>
<p className="text-sm text-muted-foreground mb-2">
Copy <code>.env.example</code> to <code>.env.local</code> and
configure:
</p>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>POSTGRES_URL (PostgreSQL connection string)</li>
<li>GOOGLE_CLIENT_ID (OAuth credentials)</li>
<li>GOOGLE_CLIENT_SECRET (OAuth credentials)</li>
<li>OPENAI_API_KEY (for AI functionality)</li>
</ul>
</div>
<div className="p-4 border rounded-lg">
<h4 className="font-medium mb-2">2. Set up your database</h4>
<p className="text-sm text-muted-foreground mb-2">
Run database migrations:
</p>
<div className="space-y-2">
<code className="text-sm bg-muted p-2 rounded block">
npm run db:generate
</code>
<code className="text-sm bg-muted p-2 rounded block">
npm run db:migrate
</code>
</div>
</div>
<div className="p-4 border rounded-lg">
<h4 className="font-medium mb-2">3. Try the features</h4>
<div className="space-y-2">
{loading || !isAuthReady ? (
<Button size="sm" className="w-full" disabled={true}>
View Dashboard
</Button>
) : (
<Button asChild size="sm" className="w-full">
<Link href="/dashboard">View Dashboard</Link>
</Button>
)}
{loading || !isAiReady ? (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={true}
>
Try AI Chat
</Button>
) : (
<Button
asChild
variant="outline"
size="sm"
className="w-full"
>
<Link href="/chat">Try AI Chat</Link>
</Button>
)}
</div>
</div>
<div className="p-4 border rounded-lg">
<h4 className="font-medium mb-2">4. Start building</h4>
<p className="text-sm text-muted-foreground mb-3">
Customize the components, add your own pages, and build your
application on top of this solid foundation.
</p>
<StarterPromptModal />
</div>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useSession } from "@/lib/auth-client";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Mail, Calendar, User, Shield, ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
export default function ProfilePage() {
const { data: session, isPending } = useSession();
const router = useRouter();
if (isPending) {
return (
<div className="flex items-center justify-center min-h-screen">
<div>Loading...</div>
</div>
);
}
if (!session) {
router.push("/");
return null;
}
const user = session.user;
const createdDate = user.createdAt ? new Date(user.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : null;
return (
<div className="container max-w-4xl mx-auto py-8 px-4">
<div className="flex items-center gap-4 mb-8">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<h1 className="text-3xl font-bold">Your Profile</h1>
</div>
<div className="grid gap-6">
{/* Profile Overview Card */}
<Card>
<CardHeader>
<div className="flex items-center space-x-4">
<Avatar className="h-20 w-20">
<AvatarImage
src={user.image || ""}
alt={user.name || "User"}
referrerPolicy="no-referrer"
/>
<AvatarFallback className="text-lg">
{(
user.name?.[0] ||
user.email?.[0] ||
"U"
).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-2">
<h2 className="text-2xl font-semibold">{user.name}</h2>
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4" />
<span>{user.email}</span>
{user.emailVerified && (
<Badge variant="outline" className="text-green-600 border-green-600">
<Shield className="h-3 w-3 mr-1" />
Verified
</Badge>
)}
</div>
{createdDate && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Calendar className="h-4 w-4" />
<span>Member since {createdDate}</span>
</div>
)}
</div>
</div>
</CardHeader>
</Card>
{/* Account Information */}
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>
Your account details and settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">
Full Name
</label>
<div className="p-3 border rounded-md bg-muted/10">
{user.name || "Not provided"}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">
Email Address
</label>
<div className="p-3 border rounded-md bg-muted/10 flex items-center justify-between">
<span>{user.email}</span>
{user.emailVerified && (
<Badge variant="outline" className="text-green-600 border-green-600">
Verified
</Badge>
)}
</div>
</div>
</div>
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-medium">Account Status</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-1">
<p className="font-medium">Email Verification</p>
<p className="text-sm text-muted-foreground">
Email address verification status
</p>
</div>
<Badge variant={user.emailVerified ? "default" : "secondary"}>
{user.emailVerified ? "Verified" : "Unverified"}
</Badge>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-1">
<p className="font-medium">Account Type</p>
<p className="text-sm text-muted-foreground">
Your account access level
</p>
</div>
<Badge variant="outline">Standard</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Account Activity */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Your recent account activity and sessions
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-3">
<div className="h-2 w-2 bg-green-500 rounded-full"></div>
<div>
<p className="font-medium">Current Session</p>
<p className="text-sm text-muted-foreground">Active now</p>
</div>
</div>
<Badge variant="outline" className="text-green-600 border-green-600">
Active
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>
Manage your account settings and preferences
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button variant="outline" className="justify-start h-auto p-4" disabled>
<User className="h-4 w-4 mr-2" />
<div className="text-left">
<div className="font-medium">Edit Profile</div>
<div className="text-xs text-muted-foreground">Update your information</div>
</div>
</Button>
<Button variant="outline" className="justify-start h-auto p-4" disabled>
<Shield className="h-4 w-4 mr-2" />
<div className="text-left">
<div className="font-medium">Security Settings</div>
<div className="text-xs text-muted-foreground">Manage security options</div>
</div>
</Button>
<Button variant="outline" className="justify-start h-auto p-4" disabled>
<Mail className="h-4 w-4 mr-2" />
<div className="text-left">
<div className="font-medium">Email Preferences</div>
<div className="text-xs text-muted-foreground">Configure notifications</div>
</div>
</Button>
</div>
<p className="text-xs text-muted-foreground mt-4">
Additional profile management features coming soon.
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { signIn, useSession } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export function SignInButton() {
const { data: session, isPending } = useSession();
if (isPending) {
return <Button disabled>Loading...</Button>;
}
if (session) {
return null;
}
return (
<Button
onClick={async () => {
await signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
}}
>
Sign in
</Button>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { signOut, useSession } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
export function SignOutButton() {
const { data: session, isPending } = useSession();
const router = useRouter();
if (isPending) {
return <Button disabled>Loading...</Button>;
}
if (!session) {
return null;
}
return (
<Button
variant="outline"
onClick={async () => {
await signOut();
router.replace("/");
router.refresh();
}}
>
Sign out
</Button>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useSession, signOut } from "@/lib/auth-client";
import { SignInButton } from "./sign-in-button";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, LogOut } from "lucide-react";
export function UserProfile() {
const { data: session, isPending } = useSession();
const router = useRouter();
if (isPending) {
return <div>Loading...</div>;
}
if (!session) {
return (
<div className="flex flex-col items-center gap-4 p-6">
<SignInButton />
</div>
);
}
const handleSignOut = async () => {
await signOut();
router.replace("/");
router.refresh();
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="size-8 cursor-pointer hover:opacity-80 transition-opacity">
<AvatarImage
src={session.user?.image || ""}
alt={session.user?.name || "User"}
referrerPolicy="no-referrer"
/>
<AvatarFallback>
{(
session.user?.name?.[0] ||
session.user?.email?.[0] ||
"U"
).toUpperCase()}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{session.user?.name}
</p>
<p className="text-xs leading-none text-muted-foreground">
{session.user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile" className="flex items-center">
<User className="mr-2 h-4 w-4" />
Your Profile
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} variant="destructive">
<LogOut className="mr-2 h-4 w-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,148 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle2, XCircle } from "lucide-react";
type DiagnosticsResponse = {
timestamp: string;
env: {
POSTGRES_URL: boolean;
BETTER_AUTH_SECRET: boolean;
GOOGLE_CLIENT_ID: boolean;
GOOGLE_CLIENT_SECRET: boolean;
OPENAI_API_KEY: boolean;
NEXT_PUBLIC_APP_URL: boolean;
};
database: {
connected: boolean;
schemaApplied: boolean;
error?: string;
};
auth: {
configured: boolean;
routeResponding: boolean | null;
};
ai: {
configured: boolean;
};
overallStatus: "ok" | "warn" | "error";
};
function StatusIcon({ ok }: { ok: boolean }) {
return ok ? (
<div title="ok">
<CheckCircle2 className="h-4 w-4 text-green-600" aria-label="ok" />
</div>
) : (
<div title="not ok">
<XCircle className="h-4 w-4 text-red-600" aria-label="not-ok" />
</div>
);
}
export function SetupChecklist() {
const [data, setData] = useState<DiagnosticsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/diagnostics", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as DiagnosticsResponse;
setData(json);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load diagnostics");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
const steps = [
{
key: "env",
label: "Environment variables",
ok:
!!data?.env.POSTGRES_URL &&
!!data?.env.BETTER_AUTH_SECRET &&
!!data?.env.GOOGLE_CLIENT_ID &&
!!data?.env.GOOGLE_CLIENT_SECRET,
detail:
"Requires POSTGRES_URL, BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET",
},
{
key: "db",
label: "Database connected & schema",
ok: !!data?.database.connected && !!data?.database.schemaApplied,
detail: data?.database.error
? `Error: ${data.database.error}`
: undefined,
},
{
key: "auth",
label: "Auth configured",
ok: !!data?.auth.configured,
detail:
data?.auth.routeResponding === false
? "Auth route not responding"
: undefined,
},
{
key: "ai",
label: "AI integration (optional)",
ok: !!data?.ai.configured,
detail: !data?.ai.configured
? "Set OPENAI_API_KEY for AI chat"
: undefined,
},
] as const;
const completed = steps.filter((s) => s.ok).length;
return (
<div className="p-6 border rounded-lg text-left">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold">Setup checklist</h3>
<p className="text-sm text-muted-foreground">
{completed}/{steps.length} completed
</p>
</div>
<Button size="sm" onClick={load} disabled={loading}>
{loading ? "Checking..." : "Re-check"}
</Button>
</div>
{error ? <div className="text-sm text-destructive">{error}</div> : null}
<ul className="space-y-2">
{steps.map((s) => (
<li key={s.key} className="flex items-start gap-2">
<div className="mt-0.5">
<StatusIcon ok={Boolean(s.ok)} />
</div>
<div>
<div className="font-medium">{s.label}</div>
{s.detail ? (
<div className="text-sm text-muted-foreground">{s.detail}</div>
) : null}
</div>
</li>
))}
</ul>
{data ? (
<div className="mt-4 text-xs text-muted-foreground">
Last checked: {new Date(data.timestamp).toLocaleString()}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { GitHubStars } from "./ui/github-stars";
export function SiteFooter() {
return (
<footer className="border-t py-6 text-center text-sm text-muted-foreground">
<div className="container mx-auto px-4">
<div className="flex flex-col items-center space-y-3">
<GitHubStars repo="leonvanzyl/agentic-coding-starter-kit" />
<p>
Built using Agentic Coding Boilerplate by{" "}
<a
href="https://youtube.com/@leonvanzyl"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Leon van Zyl
</a>
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,30 @@
import Link from "next/link";
import { UserProfile } from "@/components/auth/user-profile";
import { ModeToggle } from "./ui/mode-toggle";
import { Bot } from "lucide-react";
export function SiteHeader() {
return (
<header className="border-b">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold">
<Link
href="/"
className="flex items-center gap-2 text-primary hover:text-primary/80 transition-colors"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<Bot className="h-5 w-5" />
</div>
<span className="bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
Starter Kit
</span>
</Link>
</h1>
<div className="flex items-center gap-4">
<UserProfile />
<ModeToggle />
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Copy, Check } from "lucide-react";
const STARTER_PROMPT = `I'm working with an agentic coding boilerplate project that includes authentication, database integration, and AI capabilities. Here's what's already set up:
## Current Agentic Coding Boilerplate Structure
- **Authentication**: Better Auth with Google OAuth integration
- **Database**: Drizzle ORM with PostgreSQL setup
- **AI Integration**: Vercel AI SDK with OpenAI integration
- **UI**: shadcn/ui components with Tailwind CSS
- **Current Routes**:
- \`/\` - Home page with setup instructions and feature overview
- \`/dashboard\` - Protected dashboard page (requires authentication)
- \`/chat\` - AI chat interface (requires OpenAI API key)
## Important Context
This is an **agentic coding boilerplate/starter template** - all existing pages and components are meant to be examples and should be **completely replaced** to build the actual AI-powered application.
### CRITICAL: You MUST Override All Boilerplate Content
**DO NOT keep any boilerplate components, text, or UI elements unless explicitly requested.** This includes:
- **Remove all placeholder/demo content** (setup checklists, welcome messages, boilerplate text)
- **Replace the entire navigation structure** - don't keep the existing site header or nav items
- **Override all page content completely** - don't append to existing pages, replace them entirely
- **Remove or replace all example components** (setup-checklist, starter-prompt-modal, etc.)
- **Replace placeholder routes and pages** with the actual application functionality
### Required Actions:
1. **Start Fresh**: Treat existing components as temporary scaffolding to be removed
2. **Complete Replacement**: Build the new application from scratch using the existing tech stack
3. **No Hybrid Approach**: Don't try to integrate new features alongside existing boilerplate content
4. **Clean Slate**: The final application should have NO trace of the original boilerplate UI or content
The only things to preserve are:
- **All installed libraries and dependencies** (DO NOT uninstall or remove any packages from package.json)
- **Authentication system** (but customize the UI/flow as needed)
- **Database setup and schema** (but modify schema as needed for your use case)
- **Core configuration files** (next.config.ts, tsconfig.json, tailwind.config.ts, etc.)
- **Build and development scripts** (keep all npm/pnpm scripts in package.json)
## Tech Stack
- Next.js 15 with App Router
- TypeScript
- Tailwind CSS
- Better Auth for authentication
- Drizzle ORM + PostgreSQL
- Vercel AI SDK
- shadcn/ui components
- Lucide React icons
## AI Model Configuration
**IMPORTANT**: When implementing any AI functionality, always use the \`OPENAI_MODEL\` environment variable for the model name instead of hardcoding it:
\`\`\`typescript
// ✓ Correct - Use environment variable
const model = process.env.OPENAI_MODEL || "gpt-5-mini";
model: openai(model)
// ✗ Incorrect - Don't hardcode model names
model: openai("gpt-5-mini")
\`\`\`
This allows for easy model switching without code changes and ensures consistency across the application.
## Component Development Guidelines
**Always prioritize shadcn/ui components** when building the application:
1. **First Choice**: Use existing shadcn/ui components from the project
2. **Second Choice**: Install additional shadcn/ui components using \`pnpm dlx shadcn@latest add <component-name>\`
3. **Last Resort**: Only create custom components or use other libraries if shadcn/ui doesn't provide a suitable option
The project already includes several shadcn/ui components (button, dialog, avatar, etc.) and follows their design system. Always check the [shadcn/ui documentation](https://ui.shadcn.com/docs/components) for available components before implementing alternatives.
## What I Want to Build
[PROJECT_DESCRIPTION]
## Request
Please help me transform this boilerplate into my actual application. **You MUST completely replace all existing boilerplate code** to match my project requirements. The current implementation is just temporary scaffolding that should be entirely removed and replaced.
## Final Reminder: COMPLETE REPLACEMENT REQUIRED
**⚠️ IMPORTANT**: Do not preserve any of the existing boilerplate UI, components, or content. The user expects a completely fresh application that implements their requirements from scratch. Any remnants of the original boilerplate (like setup checklists, welcome screens, demo content, or placeholder navigation) indicate incomplete implementation.
**Success Criteria**: The final application should look and function as if it was built from scratch for the specific use case, with no evidence of the original boilerplate template.
## Post-Implementation Documentation
After completing the implementation, you MUST document any new features or significant changes in the \`/docs/features/\` directory:
1. **Create Feature Documentation**: For each major feature implemented, create a markdown file in \`/docs/features/\` that explains:
- What the feature does
- How it works
- Key components and files involved
- Usage examples
- Any configuration or setup required
2. **Update Existing Documentation**: If you modify existing functionality, update the relevant documentation files to reflect the changes.
3. **Document Design Decisions**: Include any important architectural or design decisions made during implementation.
This documentation helps maintain the project and assists future developers working with the codebase.
Think hard about the solution and implementing the user's requirements.`;
export function StarterPromptModal() {
const [isOpen, setIsOpen] = useState(false);
const [projectDescription, setProjectDescription] = useState("");
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
const finalPrompt = projectDescription.trim()
? STARTER_PROMPT.replace(
"[PROJECT_DESCRIPTION]",
projectDescription.trim()
)
: STARTER_PROMPT.replace("\n[PROJECT_DESCRIPTION]\n", "");
try {
await navigator.clipboard.writeText(finalPrompt);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm" className="w-full">
<Copy className="w-4 h-4 mr-2" />
Get AI Starter Prompt
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Generate AI Starter Prompt</DialogTitle>
<DialogDescription>
Create a comprehensive prompt to help AI agents create your project
for you.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label
htmlFor="project-description"
className="text-sm font-medium mb-2 block"
>
Describe your project (optional)
</label>
<textarea
id="project-description"
placeholder="e.g., A task management app for teams with real-time collaboration, project timelines, and AI-powered task prioritization..."
value={projectDescription}
onChange={(e) => setProjectDescription(e.target.value)}
className="w-full h-24 px-3 py-2 border rounded-md resize-none text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Optional: Add details about your project to get a more tailored
prompt
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleCopy} className="flex-1">
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Copied!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Copy Starter Prompt
</>
)}
</Button>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
</div>
<div className="text-xs text-muted-foreground border-t pt-3">
<strong>How to use:</strong> Copy this prompt and paste it into
Claude Code, Cursor, or any AI coding assistant to get started with
your project.
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,53 @@
"use client";
import { useState, useEffect } from "react";
import { Github } from "lucide-react";
import { Button } from "@/components/ui/button";
interface GitHubStarsProps {
repo: string;
}
export function GitHubStars({ repo }: GitHubStarsProps) {
const [stars, setStars] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchStars() {
try {
const response = await fetch(`https://api.github.com/repos/${repo}`);
if (response.ok) {
const data = await response.json();
setStars(data.stargazers_count);
}
} catch (error) {
console.error("Failed to fetch GitHub stars:", error);
} finally {
setLoading(false);
}
}
fetchStars();
}, [repo]);
const formatStars = (count: number) => {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`;
}
return count.toString();
};
return (
<Button variant="outline" size="sm" asChild>
<a
href={`https://github.com/${repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<Github className="h-4 w-4" />
{loading ? "..." : stars !== null ? formatStars(stars) : "0"}
</a>
</Button>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical"
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
({ className, orientation = "horizontal", ...props }, ref) => (
<div
ref={ref}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }

View File

@@ -0,0 +1,68 @@
"use client";
import { useEffect, useState } from "react";
type DiagnosticsResponse = {
timestamp: string;
env: {
POSTGRES_URL: boolean;
BETTER_AUTH_SECRET: boolean;
GOOGLE_CLIENT_ID: boolean;
GOOGLE_CLIENT_SECRET: boolean;
OPENAI_API_KEY: boolean;
NEXT_PUBLIC_APP_URL: boolean;
};
database: {
connected: boolean;
schemaApplied: boolean;
error?: string;
};
auth: {
configured: boolean;
routeResponding: boolean | null;
};
ai: {
configured: boolean;
};
overallStatus: "ok" | "warn" | "error";
};
export function useDiagnostics() {
const [data, setData] = useState<DiagnosticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
async function fetchDiagnostics() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/diagnostics", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as DiagnosticsResponse;
setData(json);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load diagnostics");
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchDiagnostics();
}, []);
const isAuthReady =
data?.auth.configured &&
data?.database.connected &&
data?.database.schemaApplied;
const isAiReady = data?.ai.configured;
return {
data,
loading,
error,
refetch: fetchDiagnostics,
isAuthReady: Boolean(isAuthReady),
isAiReady: Boolean(isAiReady),
};
}

View File

@@ -0,0 +1,13 @@
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
})
export const {
signIn,
signOut,
signUp,
useSession,
getSession,
} = authClient

View File

@@ -0,0 +1,15 @@
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { db } from "./db"
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
},
})

View File

@@ -0,0 +1,12 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.POSTGRES_URL as string;
if (!connectionString) {
throw new Error("POSTGRES_URL environment variable is not set");
}
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

View File

@@ -0,0 +1,51 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("emailVerified"),
image: text("image"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expiresAt").notNull(),
createdAt: timestamp("createdAt").defaultNow(),
updatedAt: timestamp("updatedAt").defaultNow(),
});

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}