Init
This commit is contained in:
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Config } from "drizzle-kit"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
dialect: "postgresql",
|
||||||
|
schema: "./src/lib/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
} satisfies Config
|
||||||
1847
package-lock.json
generated
1847
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -6,22 +6,42 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^2.0.9",
|
||||||
|
"@ai-sdk/react": "^2.0.9",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"ai": "^5.0.9",
|
||||||
|
"better-auth": "^1.3.4",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-orm": "^0.44.4",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
|
"next": "15.4.6",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"postgres": "^3.4.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"next": "15.4.6"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zod": "^4.0.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.4.6",
|
"eslint-config-next": "15.4.6",
|
||||||
"@eslint/eslintrc": "^3"
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.3.6",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5018
pnpm-lock.yaml
generated
Normal file
5018
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
src/app/api/auth/[...all]/route.ts
Normal file
6
src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js"
|
||||||
|
|
||||||
|
const handler = toNextJsHandler(auth)
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST }
|
||||||
13
src/app/api/chat/route.ts
Normal file
13
src/app/api/chat/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { openai } from "@ai-sdk/openai"
|
||||||
|
import { streamText } from "ai"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { messages } = await req.json()
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: openai("gpt-3.5-turbo"),
|
||||||
|
messages,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.toDataStreamResponse()
|
||||||
|
}
|
||||||
72
src/app/chat/page.tsx
Normal file
72
src/app/chat/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useChat } from "ai/react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { UserProfile } from "@/components/auth/user-profile"
|
||||||
|
import { useSession } from "@/lib/auth-client"
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const { data: session, isPending } = useSession()
|
||||||
|
const { messages, input, handleInputChange, handleSubmit } = useChat()
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <div className="flex justify-center items-center h-screen">Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-screen">
|
||||||
|
<UserProfile />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
|
||||||
|
<div className="flex justify-between items-center mb-4 pb-4 border-b">
|
||||||
|
<h1 className="text-2xl font-bold">AI Chat</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Welcome, {session.user.name}!
|
||||||
|
</span>
|
||||||
|
<UserProfile />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 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>{message.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
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()}>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/app/dashboard/page.tsx
Normal file
54
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { UserProfile } from "@/components/auth/user-profile"
|
||||||
|
import { useSession } from "@/lib/auth-client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: session, isPending } = useSession()
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <div className="flex justify-center items-center h-screen">Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-screen">
|
||||||
|
<UserProfile />
|
||||||
|
</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>
|
||||||
|
<UserProfile />
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +1,122 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--radius: 0.625rem;
|
||||||
--foreground: #ededed;
|
--background: oklch(1 0 0);
|
||||||
}
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--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.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.dark {
|
||||||
background: var(--background);
|
--background: oklch(0.145 0 0);
|
||||||
color: var(--foreground);
|
--foreground: oklch(0.985 0 0);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--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.556 0 0);
|
||||||
|
--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.205 0 0);
|
||||||
|
--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.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
191
src/app/page.tsx
191
src/app/page.tsx
@@ -1,103 +1,106 @@
|
|||||||
import Image from "next/image";
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { UserProfile } from "@/components/auth/user-profile"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
<div className="min-h-screen flex flex-col">
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
<header className="border-b">
|
||||||
<Image
|
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
className="dark:invert"
|
<h1 className="text-2xl font-bold">Next.js Starter Kit</h1>
|
||||||
src="/next.svg"
|
<UserProfile />
|
||||||
alt="Next.js logo"
|
</div>
|
||||||
width={180}
|
</header>
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
<main className="flex-1 container mx-auto px-4 py-12">
|
||||||
<a
|
<div className="max-w-4xl mx-auto text-center space-y-8">
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
<div className="space-y-4">
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<h2 className="text-4xl font-bold tracking-tight">
|
||||||
target="_blank"
|
Welcome to Your Next.js Boilerplate
|
||||||
rel="noopener noreferrer"
|
</h2>
|
||||||
>
|
<p className="text-xl text-muted-foreground">
|
||||||
<Image
|
A complete starter kit with authentication, database, AI integration, and modern tooling
|
||||||
className="dark:invert"
|
</p>
|
||||||
src="/vercel.svg"
|
</div>
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
|
||||||
height={20}
|
<div className="p-6 border rounded-lg">
|
||||||
/>
|
<h3 className="font-semibold mb-2">🔐 Authentication</h3>
|
||||||
Deploy now
|
<p className="text-sm text-muted-foreground">
|
||||||
</a>
|
Better Auth with Google OAuth integration
|
||||||
<a
|
</p>
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
</div>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="p-6 border rounded-lg">
|
||||||
target="_blank"
|
<h3 className="font-semibold mb-2">🗄️ Database</h3>
|
||||||
rel="noopener noreferrer"
|
<p className="text-sm text-muted-foreground">
|
||||||
>
|
Drizzle ORM with PostgreSQL setup
|
||||||
Read our docs
|
</p>
|
||||||
</a>
|
</div>
|
||||||
|
<div className="p-6 border rounded-lg">
|
||||||
|
<h3 className="font-semibold mb-2">🤖 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">🎨 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">
|
||||||
|
<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>DATABASE_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>
|
||||||
|
<code className="text-sm bg-muted p-2 rounded block">
|
||||||
|
npx drizzle-kit push
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<h4 className="font-medium mb-2">3. Try the features</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button asChild size="sm" className="w-full">
|
||||||
|
<Link href="/dashboard">View Dashboard</Link>
|
||||||
|
</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">
|
||||||
|
Customize the components, add your own pages, and build your application on top of this solid foundation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
<footer className="border-t py-8 text-center text-sm text-muted-foreground">
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<div className="container mx-auto px-4">
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
Built with Next.js, Better Auth, Drizzle ORM, and Vercel AI SDK
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/components/auth/sign-in-button.tsx
Normal file
29
src/components/auth/sign-in-button.tsx
Normal 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 with Google
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/components/auth/sign-out-button.tsx
Normal file
33
src/components/auth/sign-out-button.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { signOut, useSession } from "@/lib/auth-client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export function SignOutButton() {
|
||||||
|
const { data: session, isPending } = useSession()
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <Button disabled>Loading...</Button>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
await signOut({
|
||||||
|
fetchOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
window.location.href = "/"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/auth/user-profile.tsx
Normal file
40
src/components/auth/user-profile.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSession } from "@/lib/auth-client"
|
||||||
|
import { SignInButton } from "./sign-in-button"
|
||||||
|
import { SignOutButton } from "./sign-out-button"
|
||||||
|
|
||||||
|
export function UserProfile() {
|
||||||
|
const { data: session, isPending } = useSession()
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <div>Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-6">
|
||||||
|
<h2 className="text-xl font-semibold">Welcome</h2>
|
||||||
|
<p className="text-muted-foreground">Please sign in to continue</p>
|
||||||
|
<SignInButton />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
{session.user?.image && (
|
||||||
|
<img
|
||||||
|
src={session.user.image}
|
||||||
|
alt={session.user.name || "User"}
|
||||||
|
className="w-16 h-16 rounded-full mx-auto mb-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h2 className="text-xl font-semibold">{session.user?.name}</h2>
|
||||||
|
<p className="text-muted-foreground">{session.user?.email}</p>
|
||||||
|
</div>
|
||||||
|
<SignOutButton />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal 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-xs 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 }
|
||||||
13
src/lib/auth-client.ts
Normal file
13
src/lib/auth-client.ts
Normal 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
|
||||||
15
src/lib/auth.ts
Normal file
15
src/lib/auth.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
12
src/lib/db.ts
Normal file
12
src/lib/db.ts
Normal 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.DATABASE_URL as string
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL environment variable is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = postgres(connectionString)
|
||||||
|
export const db = drizzle(client, { schema })
|
||||||
47
src/lib/schema.ts
Normal file
47
src/lib/schema.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { pgTable, text, timestamp, boolean, primaryKey } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
export const user = pgTable("user", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name"),
|
||||||
|
email: text("email").unique(),
|
||||||
|
emailVerified: boolean("emailVerified"),
|
||||||
|
image: text("image"),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const session = pgTable("session", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
expiresAt: timestamp("expiresAt"),
|
||||||
|
token: text("token").unique(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
|
ipAddress: text("ipAddress"),
|
||||||
|
userAgent: text("userAgent"),
|
||||||
|
userId: text("userId").references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const account = pgTable("account", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
accountId: text("accountId"),
|
||||||
|
providerId: text("providerId"),
|
||||||
|
userId: text("userId").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").defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const verification = pgTable("verification", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
identifier: text("identifier"),
|
||||||
|
value: text("value"),
|
||||||
|
expiresAt: timestamp("expiresAt"),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
|
})
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user